diff --git a/src-tauri/src/commands/proxmox.rs b/src-tauri/src/commands/proxmox.rs index 8361e8ca..7e0f3730 100644 --- a/src-tauri/src/commands/proxmox.rs +++ b/src-tauri/src/commands/proxmox.rs @@ -2355,6 +2355,8 @@ pub async fn get_syslog( // ─── Phase 12 - Network Interfaces ─────────────────────────────────────────── +use crate::proxmox::network::NetworkInterfaceConfig; + /// List network interfaces on a node #[tauri::command] pub async fn list_network_interfaces( @@ -2377,47 +2379,6 @@ pub async fn list_network_interfaces( .ok_or_else(|| "Invalid response format".to_string()) } -/// Network interface configuration for creation/update -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct NetworkInterfaceConfig { - pub iface: String, - #[serde(rename = "type")] - pub iface_type: String, - #[serde(default)] - pub address: Option, - #[serde(default)] - pub netmask: Option, - #[serde(default)] - pub gateway: Option, - #[serde(default, with = "serde_bool_as_int")] - pub active: bool, - #[serde(default, with = "serde_bool_as_int")] - pub autostart: bool, - #[serde(default)] - pub comments: Option, -} - -/// Helper module for serde bool-as-int conversion (Proxmox API expects 0/1) -mod serde_bool_as_int { - use serde::{Deserialize, Deserializer, Serializer}; - - pub fn serialize(value: &bool, serializer: S) -> Result - where - S: Serializer, - { - serializer.serialize_i8(if *value { 1 } else { 0 }) - } - - pub fn deserialize<'de, D>(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let val = i8::deserialize(deserializer)?; - Ok(val != 0) - } -} - /// Create a network interface #[tauri::command] pub async fn create_network_interface( diff --git a/src/components/Proxmox/VMList.tsx b/src/components/Proxmox/VMList.tsx index 91e77fce..9dea304e 100644 --- a/src/components/Proxmox/VMList.tsx +++ b/src/components/Proxmox/VMList.tsx @@ -8,7 +8,7 @@ import { MoreHorizontal, Play, Square, RotateCcw, Power, PlayCircle, Pause, X, M import { invoke } from '@tauri-apps/api/core'; import { confirm } from '@tauri-apps/plugin-dialog'; import { toast } from 'sonner'; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/index'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/index'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/index'; import { Label } from '@/components/ui/index'; import { Checkbox as UICheckbox } from '@/components/ui/index'; @@ -30,6 +30,15 @@ interface VMInfo { tags?: string[]; } +interface ProxmoxSnapshot { + snapname: string; + vmid: number; + name?: string; + ctime: number; + parent?: string; + description?: string; +} + interface RawVMInfo { id: number; vmid?: number; @@ -105,6 +114,14 @@ export function VMList({ const [maxDowntime, setMaxDowntime] = useState(30); const [clusterNodes, setClusterNodes] = useState([]); const [nodesLoading, setNodesLoading] = useState(false); + const [snapshotDialog, setSnapshotDialog] = useState<{ + isOpen: boolean; + vm: VMInfo | null; + action: 'create' | 'list' | 'rollback' | 'delete' | null; + snapshots: ProxmoxSnapshot[]; + }>({ isOpen: false, vm: null, action: null, snapshots: [] }); + const [snapshotName, setSnapshotName] = useState(''); + const [selectedSnapshot, setSelectedSnapshot] = useState(''); const vms: VMInfo[] = React.useMemo(() => { return rawVms.map((vm) => ({ @@ -194,65 +211,100 @@ export function VMList({ }, [clusterId, onRefresh]); const handleSnapshotAction = useCallback(async (vm: VMInfo, action: 'create' | 'list' | 'rollback' | 'delete') => { + if (action === 'list') { + try { + const snapshots = await invoke('list_proxmox_snapshots', { + clusterId, + nodeId: vm.node, + vmid: vm.vmid, + }); + setSnapshotDialog({ isOpen: true, vm, action: 'list', snapshots }); + } catch (error) { + console.error('Failed to list snapshots:', error); + toast.error(`Failed to list snapshots: ${error}`); + } + return; + } + + if (action === 'rollback' || action === 'delete') { + try { + const snapshots = await invoke('list_proxmox_snapshots', { + clusterId, + nodeId: vm.node, + vmid: vm.vmid, + }); + if (snapshots.length === 0) { + toast.error(`No snapshots found for ${vm.name}`); + return; + } + setSnapshotDialog({ isOpen: true, vm, action, snapshots }); + } catch (error) { + console.error('Failed to list snapshots:', error); + toast.error(`Failed to list snapshots: ${error}`); + } + return; + } + + if (action === 'create') { + setSnapshotName(''); + setSnapshotDialog({ isOpen: true, vm, action: 'create', snapshots: [] }); + } + }, [clusterId]); + + const handleSnapshotSubmit = useCallback(async () => { + if (!snapshotDialog.vm || !snapshotDialog.action) return; + + const { vm, action } = snapshotDialog; + try { - switch (action) { - case 'create': { - const snapshotName = window.prompt('Enter snapshot name:'); - if (!snapshotName) return; - await invoke('create_proxmox_snapshot', { + if (action === 'create') { + if (!snapshotName.trim()) { + toast.error('Snapshot name is required'); + return; + } + await invoke('create_proxmox_snapshot', { + clusterId, + nodeId: vm.node, + vmid: vm.vmid, + snapshotName: snapshotName.trim(), + }); + toast.success(`Snapshot "${snapshotName}" created for ${vm.name}`); + } else if (action === 'rollback' && selectedSnapshot) { + if (await confirm(`Are you sure you want to rollback ${vm.name} to "${selectedSnapshot}"? This may cause downtime.`)) { + await invoke('rollback_proxmox_snapshot', { clusterId, nodeId: vm.node, vmid: vm.vmid, - snapshotName, + snapshotName: selectedSnapshot, }); - toast.success(`Snapshot "${snapshotName}" created for ${vm.name}`); - break; + toast.success(`Rolled back ${vm.name} to "${selectedSnapshot}"`); } - case 'list': { - const snapshots = await invoke('list_proxmox_snapshots', { + } else if (action === 'delete' && selectedSnapshot) { + if (await confirm(`Are you sure you want to delete snapshot "${selectedSnapshot}" for ${vm.name}?`)) { + await invoke('delete_proxmox_snapshot', { clusterId, nodeId: vm.node, vmid: vm.vmid, + snapshotName: selectedSnapshot, }); - console.log('Snapshots for', vm.name, ':', snapshots); - toast.success(`Found ${snapshots.length} snapshot(s) for ${vm.name}`); - break; - } - case 'rollback': { - const snapshotName = window.prompt('Enter snapshot name to rollback to:'); - if (!snapshotName) return; - if (await confirm(`Are you sure you want to rollback ${vm.name} to "${snapshotName}"?`)) { - await invoke('rollback_proxmox_snapshot', { - clusterId, - nodeId: vm.node, - vmid: vm.vmid, - snapshotName, - }); - toast.success(`Rolled back ${vm.name} to "${snapshotName}"`); - } - break; - } - case 'delete': { - const snapshotName = window.prompt('Enter snapshot name to delete:'); - if (!snapshotName) return; - if (await confirm(`Are you sure you want to delete snapshot "${snapshotName}" for ${vm.name}?`)) { - await invoke('delete_proxmox_snapshot', { - clusterId, - nodeId: vm.node, - vmid: vm.vmid, - snapshotName, - }); - toast.success(`Deleted snapshot "${snapshotName}" for ${vm.name}`); - } - break; + toast.success(`Deleted snapshot "${selectedSnapshot}" for ${vm.name}`); } } + setSnapshotDialog({ isOpen: false, vm: null, action: null, snapshots: [] }); + setSnapshotName(''); + setSelectedSnapshot(''); onRefresh?.(); } catch (error) { - console.error(`Failed to ${action} snapshot for ${vm.name}:`, error); + console.error(`Failed to ${action} snapshot:`, error); toast.error(`Failed to ${action} snapshot: ${error}`); } - }, [clusterId, onRefresh]); + }, [snapshotDialog, clusterId, snapshotName, selectedSnapshot, onRefresh]); + + const handleSnapshotClose = useCallback(() => { + setSnapshotDialog({ isOpen: false, vm: null, action: null, snapshots: [] }); + setSnapshotName(''); + setSelectedSnapshot(''); + }, []); const handleMigrate = useCallback(async (vm: VMInfo) => { setMigrationVM(vm); @@ -485,11 +537,24 @@ export function VMList({ onTargetNodeChange={setTargetNode} targetCluster={targetCluster} onTargetClusterChange={setTargetCluster} - online={onlineMigration} - onOnlineChange={setOnlineMigration} + onlineMigration={onlineMigration} + onOnlineMigrationChange={setOnlineMigration} maxDowntime={maxDowntime} onMaxDowntimeChange={setMaxDowntime} /> + + ); } @@ -676,8 +741,8 @@ interface MigrationDialogProps { onTargetNodeChange: (node: string) => void; targetCluster: string; onTargetClusterChange: (clusterId: string) => void; - online: boolean; - onOnlineChange: (online: boolean) => void; + onlineMigration: boolean; + onOnlineMigrationChange: (online: boolean) => void; maxDowntime: number; onMaxDowntimeChange: (downtime: number) => void; } @@ -695,8 +760,8 @@ function MigrationDialog({ onTargetNodeChange, targetCluster, onTargetClusterChange, - online, - onOnlineChange, + onlineMigration, + onOnlineMigrationChange, maxDowntime, onMaxDowntimeChange, }: MigrationDialogProps) { @@ -789,18 +854,18 @@ function MigrationDialog({
onOnlineChange(checked as boolean)} + id="onlineMigration" + checked={onlineMigration} + onCheckedChange={(checked) => onOnlineMigrationChange(checked as boolean)} /> - +

- {online ? 'Keep VM running during migration' : 'VM will be stopped during migration'} + {onlineMigration ? 'Keep VM running during migration' : 'VM will be stopped during migration'}

- {online && ( + {onlineMigration && (
); } + +// ─── Snapshot Dialog ────────────────────────────────────────────────────────── + +interface SnapshotDialogProps { + isOpen: boolean; + vm: VMInfo | null; + action: 'create' | 'list' | 'rollback' | 'delete' | null; + snapshots: ProxmoxSnapshot[]; + snapshotName: string; + selectedSnapshot: string; + onSnapshotNameChange: (value: string) => void; + onSelectedSnapshotChange: (value: string) => void; + onSubmit: () => void; + onClose: () => void; +} + +function SnapshotDialog({ + isOpen, + vm, + action, + snapshots, + snapshotName, + selectedSnapshot, + onSnapshotNameChange, + onSelectedSnapshotChange, + onSubmit, + onClose, +}: SnapshotDialogProps) { + if (!vm) return null; + + return ( + !open && onClose()}> + + + + {action === 'create' && `Create Snapshot for ${vm.name}`} + {action === 'list' && `Snapshots for ${vm.name}`} + {action === 'rollback' && `Rollback ${vm.name}`} + {action === 'delete' && `Delete Snapshot for ${vm.name}`} + + + {action === 'create' && 'Enter a name for the new snapshot'} + {action === 'list' && 'View all snapshots for this VM'} + {action === 'rollback' && 'Select a snapshot to rollback to'} + {action === 'delete' && 'Select a snapshot to delete'} + + + +
+ {action === 'create' && ( +
+ + onSnapshotNameChange(e.target.value)} + placeholder="e.g., before-upgrade" + /> +
+ )} + + {(action === 'list' || action === 'rollback' || action === 'delete') && ( +
+ + {snapshots.length === 0 ? ( +

No snapshots found

+ ) : ( + + )} + + {action === 'list' && snapshots.length > 0 && ( +
+ + {snapshots.map((snap) => ( +
+
{snap.snapname}
+
+ Created: {new Date(snap.ctime * 1000).toLocaleString()} + {snap.description &&
Description: {snap.description}
} + {snap.parent &&
Parent: {snap.parent}
} +
+
+ ))} +
+ )} +
+ )} +
+ + + + + +
+
+ ); +}