import React, { useState, useEffect, useCallback, useRef } from 'react'; 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'; 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 VMListProps { vms: VMInfo[]; 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, onRefresh, isLoading, onSnapshotAction, onMigrate, onClone, onDelete, selectedVMs = new Set(), onToggleSelect, }: VMListProps) { const [clusterId, setClusterId] = useState(''); useEffect(() => { // Use list_proxmox_clusters and select the first cluster invoke('list_proxmox_clusters') .then((clusters: any[]) => { if (clusters.length > 0) { setClusterId(clusters[0].id); } }) .catch(() => {}); }, []); const handleVMAction = useCallback(async (vm: VMInfo, action: string) => { 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(async (vm: VMInfo) => { try { const targetNodes = vms .map((v) => v.node) .filter((node, index, self) => self.indexOf(node) === index && node !== vm.node); if (targetNodes.length === 0) { toast.error('No target nodes available for migration'); return; } const targetNode = window.prompt(`Select target node: ${targetNodes.join(', ')}`, targetNodes[0]); if (!targetNode) { toast.info('Migration cancelled'); return; } await invoke('migrate_vm', { clusterId, nodeId: vm.node, vmId: vm.vmid, targetNode, online: vm.status === 'running', }); toast.success(`VM ${vm.name} migration started`); onRefresh?.(); } catch (error) { console.error('Failed to migrate VM:', error); toast.error(`Failed to migrate VM ${vm.name}: ${error}`); } }, [clusterId, vms, onRefresh]); const handleClone = useCallback(async (vm: VMInfo) => { try { const newVmidStr = window.prompt(`Enter new VM ID for ${vm.name}:`, `${vm.vmid + 1}`); if (!newVmidStr) { toast.info('Clone cancelled'); return; } const newVmid = parseInt(newVmidStr); 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 || `${vm.name}-clone`, }); toast.success(`VM ${vm.name} cloned successfully`); onRefresh?.(); } catch (error) { console.error('Failed to clone VM:', error); toast.error(`Failed to clone VM ${vm.name}: ${error}`); } }, [clusterId, onRefresh]); const handleDelete = useCallback(async (vm: VMInfo) => { if (!confirm(`Are you sure you want to delete VM ${vm.name}?`)) { return; } try { await invoke('delete_vm', { clusterId, nodeId: vm.node, vmId: vm.vmid, }); toast.success(`VM ${vm.name} deleted`); onRefresh?.(); } catch (error) { console.error('Failed to delete VM:', error); toast.error(`Failed to delete VM ${vm.name}: ${error}`); } }, [clusterId, onRefresh]); return ( Virtual Machines
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)} ); })}
); } 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 menuRef = useRef(null); // Close menu when clicking outside useEffect(() => { function handleClickOutside(event: MouseEvent) { if (menuRef.current && !menuRef.current.contains(event.target as Node)) { setIsOpen(false); } } if (isOpen) { document.addEventListener('mousedown', handleClickOutside); } return () => { document.removeEventListener('mousedown', handleClickOutside); }; }, [isOpen]); const toggleMenu = (e: React.MouseEvent) => { e.stopPropagation(); setIsOpen(!isOpen); }; const handleAction = (action: () => void) => (e: React.MouseEvent) => { e.stopPropagation(); action(); setIsOpen(false); }; // Calculate menu position to avoid overflow const getMenuPosition = () => { const viewportHeight = window.innerHeight; const viewportWidth = window.innerWidth; const buttonRect = menuRef.current?.querySelector('button')?.getBoundingClientRect(); if (!buttonRect) return { top: '100%', left: 0 }; const menuHeight = 400; // approximate menu height const menuWidth = 192; // approximate menu width (w-48 = 12rem = 192px) const spaceBelow = viewportHeight - buttonRect.bottom; const spaceAbove = buttonRect.top; const spaceRight = viewportWidth - buttonRect.right; // Vertical positioning let verticalPos: { top?: string; bottom?: string } = { top: '100%' }; if (spaceBelow >= menuHeight) { verticalPos = { top: '100%' }; } else if (spaceAbove >= menuHeight) { verticalPos = { bottom: '100%' }; } // Horizontal positioning - account for overflow on the right let horizontalPos: { left?: number; right?: number } = { left: 0 }; if (spaceRight < menuWidth) { horizontalPos = { right: 0 }; } return { ...verticalPos, ...horizontalPos }; }; const position = getMenuPosition(); return (
{isOpen && (
{vm.status === 'stopped' && ( )} {vm.status === 'running' && ( <> )} {vm.status === 'paused' && ( )}
)}
); }