import React, { useState, useEffect, useCallback, useRef } from 'react'; import { clsx } from 'clsx'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/index'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/index'; import { Button } from '@/components/ui/index'; import { Checkbox } from '@/components/ui/index'; import { MoreHorizontal, Play, Square, RotateCcw, Power, PlayCircle, Pause, X, MoveRight, Copy } from 'lucide-react'; 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/index'; import { Label } from '@/components/ui/index'; import { Checkbox as UICheckbox } from '@/components/ui/index'; import { Input } from '@/components/ui/index'; import { AlertCircle } from 'lucide-react'; import { Alert, AlertDescription } from '@/components/ui/index'; import type { ClusterInfo } from '@/lib/domain'; interface VMInfo { id: string; vmid: number; name: string; node: string; status: 'running' | 'stopped' | 'paused'; cpu: number; memory: number; memoryTotal: number; disk: number; diskTotal: number; uptime?: number; tags?: string[]; } interface RawVMInfo { id: number; vmid?: number; name?: string; node?: string; status?: string; cpu?: number; mem?: number; max_mem?: number; memory?: number; memoryTotal?: number; disk?: number; max_disk?: number; diskTotal?: number; uptime?: number; tags?: string[]; } interface VMListProps { vms: RawVMInfo[]; clusterId: string; clusters?: ClusterInfo[]; onRefresh?: () => void; isLoading?: boolean; onSnapshotAction?: (vm: VMInfo, action: 'create' | 'list' | 'rollback' | 'delete') => void; onMigrate?: (vm: VMInfo) => void; onClone?: (vm: VMInfo) => void; onDelete?: (vm: VMInfo) => void; selectedVMs?: Set; onToggleSelect?: (vm: VMInfo) => void; } function formatUptime(seconds: number): string { if (seconds <= 0) return '-'; const days = Math.floor(seconds / 86400); const hours = Math.floor((seconds % 86400) / 3600); const minutes = Math.floor((seconds % 3600) / 60); const parts: string[] = []; if (days > 0) parts.push(`${days}d`); if (hours > 0) parts.push(`${hours}h`); if (minutes > 0 || parts.length === 0) parts.push(`${minutes}m`); return parts.join(' '); } function formatBytes(bytes: number): string { if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; } export function VMList({ vms: rawVms, clusterId, clusters = [], onRefresh, isLoading, onSnapshotAction: _onSnapshotAction, onMigrate: _onMigrate, onClone: _onClone, onDelete: _onDelete, selectedVMs = new Set(), onToggleSelect, }: VMListProps) { const [migrationVM, setMigrationVM] = useState(null); const [targetNode, setTargetNode] = useState(''); const [targetCluster, setTargetCluster] = useState(''); const [onlineMigration, setOnlineMigration] = useState(true); const [maxDowntime, setMaxDowntime] = useState(30); const vms: VMInfo[] = React.useMemo(() => { return rawVms.map((vm) => ({ id: String(vm.id || vm.vmid), vmid: vm.vmid || vm.id, name: vm.name || `VM-${vm.vmid || vm.id}`, node: vm.node || '', status: (vm.status || 'stopped') as 'running' | 'stopped' | 'paused', cpu: vm.cpu || 0, memory: vm.mem || vm.memory || 0, memoryTotal: vm.max_mem || vm.memoryTotal || 0, disk: vm.disk || 0, diskTotal: vm.max_disk || vm.diskTotal || 0, uptime: vm.uptime, tags: vm.tags, })); }, [rawVms]); // clusterId comes from props (not captured via closure over state), so it is always // current when an action fires even if the user switches clusters mid-session. const handleVMAction = useCallback(async (vm: VMInfo, action: string) => { if (!clusterId) { toast.error('No cluster selected'); return; } try { switch (action) { case 'start': await invoke('start_proxmox_vm', { clusterId, nodeId: vm.node, vmId: vm.vmid, }); toast.success(`VM ${vm.name} started`); onRefresh?.(); break; case 'stop': await invoke('stop_proxmox_vm', { clusterId, nodeId: vm.node, vmId: vm.vmid, }); toast.success(`VM ${vm.name} stopped`); onRefresh?.(); break; case 'reboot': await invoke('reboot_proxmox_vm', { clusterId, nodeId: vm.node, vmId: vm.vmid, }); toast.success(`VM ${vm.name} rebooting`); onRefresh?.(); break; case 'shutdown': await invoke('shutdown_proxmox_vm', { clusterId, nodeId: vm.node, vmId: vm.vmid, }); toast.success(`VM ${vm.name} shutting down`); onRefresh?.(); break; case 'resume': await invoke('resume_proxmox_vm', { clusterId, nodeId: vm.node, vmId: vm.vmid, }); toast.success(`VM ${vm.name} resumed`); onRefresh?.(); break; case 'suspend': await invoke('suspend_proxmox_vm', { clusterId, nodeId: vm.node, vmId: vm.vmid, }); toast.success(`VM ${vm.name} suspended`); onRefresh?.(); break; default: toast.error(`Unknown action: ${action}`); } } catch (error) { console.error(`Failed to ${action} VM ${vm.name}:`, error); toast.error(`Failed to ${action} VM ${vm.name}: ${error}`); } }, [clusterId, onRefresh]); const handleSnapshotAction = useCallback((vm: VMInfo, action: 'create' | 'list' | 'rollback' | 'delete') => { toast.info(`Snapshot ${action} for ${vm.name} - not yet implemented`); }, []); const handleMigrate = useCallback((vm: VMInfo) => { setMigrationVM(vm); const availableNodes = vms .map((v) => v.node) .filter((node, index, self) => self.indexOf(node) === index && node !== vm.node); setTargetNode(availableNodes[0] || ''); setTargetCluster(clusterId); }, [vms, clusterId]); const submitMigration = useCallback(async () => { if (!migrationVM || !targetNode) { toast.error('Please select a target node'); return; } const sourceCluster = clusterId; const destCluster = targetCluster || clusterId; try { await invoke('migrate_vm', { clusterId: sourceCluster, nodeId: migrationVM.node, vmId: migrationVM.vmid, targetNode, targetCluster: destCluster, }); toast.success(`VM ${migrationVM.name} migration started to ${targetNode}${destCluster !== sourceCluster ? ` (cluster: ${destCluster})` : ''}`); setMigrationVM(null); setTargetNode(''); setTargetCluster(''); onRefresh?.(); } catch (error) { console.error('Failed to migrate VM:', error); toast.error(`Failed to migrate VM ${migrationVM.name}: ${error}`); } }, [migrationVM, targetNode, targetCluster, clusterId, onRefresh]); const handleClone = useCallback(async (vm: VMInfo) => { if (!clusterId) { toast.error('No cluster selected'); return; } try { const nextVmid = Math.max(...vms.map((v) => v.vmid), 100) + 1; const newVmidStr = window.prompt(`Enter new VM ID for ${vm.name}:`, `${nextVmid}`); if (!newVmidStr) { toast.info('Clone cancelled'); return; } const newVmid = parseInt(newVmidStr); if (isNaN(newVmid) || newVmid < 100) { toast.error('Invalid VM ID. Must be >= 100'); return; } const newName = window.prompt(`Enter name for cloned VM:`, `${vm.name}-clone`); if (!newName) { toast.info('Clone cancelled'); return; } await invoke('clone_vm', { clusterId, nodeId: vm.node, vmId: vm.vmid, newVmid, name: newName, }); toast.success(`VM ${vm.name} cloned successfully to VM ${newVmid}`); onRefresh?.(); } catch (error) { console.error('Failed to clone VM:', error); toast.error(`Failed to clone VM ${vm.name}: ${error}`); } }, [clusterId, vms, onRefresh]); const handleDelete = useCallback(async (vm: VMInfo) => { if (!clusterId) { toast.error('No cluster selected'); return; } const confirmed = await confirm(`Are you sure you want to delete VM ${vm.name} (VMID: ${vm.vmid})? This action cannot be undone!`, { title: 'Delete VM', kind: 'warning', }); if (!confirmed) { return; } try { await invoke('delete_vm', { clusterId, nodeId: vm.node, vmId: vm.vmid, }); toast.success(`VM ${vm.name} deleted successfully`); onRefresh?.(); } catch (error) { console.error('Failed to delete VM:', error); toast.error(`Failed to delete VM ${vm.name}: ${error}`); } }, [clusterId, onRefresh]); return ( Virtual Machines
0 && vms.every((vm) => selectedVMs.has(vm.id))} onCheckedChange={(checked) => { if (checked) { vms.forEach((vm) => selectedVMs.add(vm.id)); } else { vms.forEach((vm) => selectedVMs.delete(vm.id)); } }} /> Name VM ID Node Status CPU Memory Disk Uptime Actions {vms.map((vm) => { const cpuPercent = vm.cpu > 0 ? Math.min(vm.cpu * 100, 100) : 0; const memoryPercent = vm.memoryTotal > 0 ? (vm.memory / vm.memoryTotal) * 100 : 0; const diskPercent = vm.diskTotal > 0 ? (vm.disk / vm.diskTotal) * 100 : 0; return ( onToggleSelect?.(vm)} /> {vm.name} {vm.vmid} {vm.node} {vm.status} {cpuPercent.toFixed(2)}% {vm.memoryTotal > 0 ? (
{formatBytes(vm.memory)} / {formatBytes(vm.memoryTotal)}
) : ( - )} {vm.diskTotal > 0 ? (
{formatBytes(vm.disk)} / {formatBytes(vm.diskTotal)}
) : ( - )} {formatUptime(vm.uptime || 0)} ); })}
{ setMigrationVM(null); setTargetNode(''); setTargetCluster(''); }} onSubmit={submitMigration} availableNodes={vms} clusters={clusters} currentClusterId={clusterId} targetNode={targetNode} onTargetNodeChange={setTargetNode} targetCluster={targetCluster} onTargetClusterChange={setTargetCluster} online={onlineMigration} onOnlineChange={setOnlineMigration} maxDowntime={maxDowntime} onMaxDowntimeChange={setMaxDowntime} />
); } interface VMActionMenuProps { vm: VMInfo; onVMAction: (vm: VMInfo, action: 'start' | 'stop' | 'reboot' | 'shutdown' | 'resume' | 'suspend') => void; onSnapshotAction: (vm: VMInfo, action: 'create' | 'list' | 'rollback' | 'delete') => void; onMigrate: (vm: VMInfo) => void; onClone: (vm: VMInfo) => void; onDelete: (vm: VMInfo) => void; } function VMActionMenu({ vm, onVMAction, onSnapshotAction, onMigrate, onClone, onDelete, }: VMActionMenuProps) { const [isOpen, setIsOpen] = useState(false); const [flipUpward, setFlipUpward] = useState(false); const containerRef = useRef(null); const menuContentRef = useRef(null); useEffect(() => { function handleClickOutside(event: MouseEvent) { if (containerRef.current && !containerRef.current.contains(event.target as Node)) { setIsOpen(false); } } if (isOpen) { document.addEventListener('mousedown', handleClickOutside); } return () => { document.removeEventListener('mousedown', handleClickOutside); }; }, [isOpen]); // After the menu renders, check whether it overflows the viewport bottom and flip if needed. // Done in useEffect (not during render) to avoid the react-hooks/refs ESLint violation. useEffect(() => { if (!isOpen || !menuContentRef.current) return; const rect = menuContentRef.current.getBoundingClientRect(); setFlipUpward(window.innerHeight - rect.bottom < 20); }, [isOpen]); const toggleMenu = (e: React.MouseEvent) => { e.stopPropagation(); setIsOpen(!isOpen); }; const handleAction = (action: () => void) => (e: React.MouseEvent) => { e.stopPropagation(); setIsOpen(false); action(); }; return (
{isOpen && (
{vm.status === 'stopped' && ( )} {vm.status === 'running' && ( <> )} {vm.status === 'paused' && ( )}
)}
); } interface MigrationDialogProps { vm: VMInfo | null; isOpen: boolean; onClose: () => void; onSubmit: () => void; availableNodes: VMInfo[]; clusters: ClusterInfo[]; currentClusterId: string; targetNode: string; onTargetNodeChange: (node: string) => void; targetCluster: string; onTargetClusterChange: (clusterId: string) => void; online: boolean; onOnlineChange: (online: boolean) => void; maxDowntime: number; onMaxDowntimeChange: (downtime: number) => void; } function MigrationDialog({ vm, isOpen, onClose, onSubmit, availableNodes, clusters, currentClusterId, targetNode, onTargetNodeChange, targetCluster, onTargetClusterChange, online, onOnlineChange, maxDowntime, onMaxDowntimeChange, }: MigrationDialogProps) { if (!vm) return null; const isCrossCluster = targetCluster && targetCluster !== currentClusterId; const availableTargets = availableNodes .map((v) => v.node) .filter((node, index, self) => self.indexOf(node) === index && node !== vm.node); const canSubmitMigration = () => { if (!targetNode) return false; if (isCrossCluster) return true; return availableTargets.length > 0; }; return ( Migrate {vm.name} (VM {vm.vmid})
Live migration requires the same hardware configuration on both nodes. Ensure storage is accessible from both nodes. {clusters.length > 1 && (
{isCrossCluster && (

Cross-cluster migration — VM will be moved to a different datacenter.

)}
)}
{isCrossCluster ? ( <> onTargetNodeChange(e.target.value)} placeholder="Enter target node name" />

Enter the node name on the destination cluster

) : ( <> {availableTargets.length === 0 && (

No other nodes available in this cluster

)} )}
onOnlineChange(checked as boolean)} />

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

{online && (
onMaxDowntimeChange(Number(e.target.value))} min={10} max={10000} />

Maximum allowed downtime during live migration

)}
); }