2026-06-21 04:36:58 +00:00
|
|
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
2026-06-11 14:38:36 +00:00
|
|
|
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';
|
2026-06-21 04:36:58 +00:00
|
|
|
import { MoreHorizontal, Play, Square, RotateCcw, Power, PlayCircle, Pause, X, MoveRight, Copy } from 'lucide-react';
|
|
|
|
|
import { invoke } from '@tauri-apps/api/core';
|
2026-06-21 14:38:10 +00:00
|
|
|
import { confirm } from '@tauri-apps/plugin-dialog';
|
2026-06-21 04:36:58 +00:00
|
|
|
import { toast } from 'sonner';
|
2026-06-21 16:17:31 +00:00
|
|
|
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';
|
2026-06-11 14:38:36 +00:00
|
|
|
|
|
|
|
|
interface VMInfo {
|
|
|
|
|
id: string;
|
|
|
|
|
vmid: number;
|
|
|
|
|
name: string;
|
|
|
|
|
node: string;
|
|
|
|
|
status: 'running' | 'stopped' | 'paused';
|
|
|
|
|
cpu: number;
|
|
|
|
|
memory: number;
|
|
|
|
|
memoryTotal: number;
|
|
|
|
|
disk: number;
|
|
|
|
|
diskTotal: number;
|
2026-06-21 04:36:58 +00:00
|
|
|
uptime?: number;
|
2026-06-11 14:38:36 +00:00
|
|
|
tags?: string[];
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-21 16:17:31 +00:00
|
|
|
interface RawVMInfo {
|
|
|
|
|
id: number;
|
|
|
|
|
vmid?: number;
|
|
|
|
|
name?: string;
|
|
|
|
|
node?: string;
|
|
|
|
|
status?: string;
|
|
|
|
|
cpu?: number;
|
|
|
|
|
mem?: number;
|
|
|
|
|
max_mem?: number;
|
|
|
|
|
memory?: number;
|
2026-06-21 16:36:32 +00:00
|
|
|
memoryTotal?: number;
|
2026-06-21 16:17:31 +00:00
|
|
|
disk?: number;
|
|
|
|
|
max_disk?: number;
|
|
|
|
|
diskTotal?: number;
|
|
|
|
|
uptime?: number;
|
2026-06-21 16:36:32 +00:00
|
|
|
tags?: string[];
|
2026-06-21 16:17:31 +00:00
|
|
|
}
|
|
|
|
|
|
2026-06-11 14:38:36 +00:00
|
|
|
interface VMListProps {
|
2026-06-21 16:17:31 +00:00
|
|
|
vms: RawVMInfo[];
|
2026-06-11 14:38:36 +00:00
|
|
|
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<string>;
|
|
|
|
|
onToggleSelect?: (vm: VMInfo) => void;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-21 04:36:58 +00:00
|
|
|
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];
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-11 14:38:36 +00:00
|
|
|
export function VMList({
|
2026-06-21 16:17:31 +00:00
|
|
|
vms: rawVms,
|
2026-06-11 14:38:36 +00:00
|
|
|
onRefresh,
|
|
|
|
|
isLoading,
|
|
|
|
|
onSnapshotAction,
|
|
|
|
|
onMigrate,
|
|
|
|
|
onClone,
|
|
|
|
|
onDelete,
|
|
|
|
|
selectedVMs = new Set<string>(),
|
|
|
|
|
onToggleSelect,
|
|
|
|
|
}: VMListProps) {
|
2026-06-21 04:36:58 +00:00
|
|
|
const [clusterId, setClusterId] = useState<string>('');
|
2026-06-21 16:17:31 +00:00
|
|
|
const [migrationVM, setMigrationVM] = useState<VMInfo | null>(null);
|
|
|
|
|
const [targetNode, setTargetNode] = useState<string>('');
|
|
|
|
|
const [onlineMigration, setOnlineMigration] = useState(true);
|
|
|
|
|
const [maxDowntime, setMaxDowntime] = useState(30);
|
|
|
|
|
|
|
|
|
|
// Transform raw VM data to VMInfo format
|
|
|
|
|
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]);
|
2026-06-21 04:36:58 +00:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-06-21 15:00:44 +00:00
|
|
|
invoke<string[]>('list_proxmox_clusters')
|
|
|
|
|
.then((clusters: any[]) => {
|
|
|
|
|
if (clusters.length > 0) {
|
|
|
|
|
setClusterId(clusters[0].id);
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.catch(() => {});
|
2026-06-21 04:36:58 +00:00
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
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`);
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-06-21 16:17:31 +00:00
|
|
|
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] || '');
|
|
|
|
|
}, [vms]);
|
2026-06-21 04:36:58 +00:00
|
|
|
|
2026-06-21 16:17:31 +00:00
|
|
|
const submitMigration = useCallback(async () => {
|
|
|
|
|
if (!migrationVM || !targetNode) {
|
|
|
|
|
toast.error('Please select a target node');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
2026-06-21 04:36:58 +00:00
|
|
|
await invoke('migrate_vm', {
|
|
|
|
|
clusterId,
|
2026-06-21 16:17:31 +00:00
|
|
|
nodeId: migrationVM.node,
|
|
|
|
|
vmId: migrationVM.vmid,
|
2026-06-21 04:36:58 +00:00
|
|
|
targetNode,
|
2026-06-21 16:17:31 +00:00
|
|
|
online: onlineMigration,
|
|
|
|
|
max_downtime: maxDowntime,
|
2026-06-21 04:36:58 +00:00
|
|
|
});
|
|
|
|
|
|
2026-06-21 16:17:31 +00:00
|
|
|
toast.success(`VM ${migrationVM.name} migration started to ${targetNode}`);
|
|
|
|
|
setMigrationVM(null);
|
|
|
|
|
setTargetNode('');
|
2026-06-21 04:36:58 +00:00
|
|
|
onRefresh?.();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Failed to migrate VM:', error);
|
2026-06-21 16:17:31 +00:00
|
|
|
toast.error(`Failed to migrate VM ${migrationVM.name}: ${error}`);
|
2026-06-21 04:36:58 +00:00
|
|
|
}
|
2026-06-21 16:17:31 +00:00
|
|
|
}, [migrationVM, targetNode, onlineMigration, maxDowntime, clusterId, onRefresh]);
|
2026-06-21 04:36:58 +00:00
|
|
|
|
|
|
|
|
const handleClone = useCallback(async (vm: VMInfo) => {
|
|
|
|
|
try {
|
2026-06-21 16:17:31 +00:00
|
|
|
const nextVmid = Math.max(...vms.map((v) => v.vmid), 100) + 1;
|
|
|
|
|
const newVmidStr = window.prompt(`Enter new VM ID for ${vm.name}:`, `${nextVmid}`);
|
2026-06-21 14:38:10 +00:00
|
|
|
if (!newVmidStr) {
|
|
|
|
|
toast.info('Clone cancelled');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const newVmid = parseInt(newVmidStr);
|
2026-06-21 16:17:31 +00:00
|
|
|
if (isNaN(newVmid) || newVmid < 100) {
|
|
|
|
|
toast.error('Invalid VM ID. Must be >= 100');
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-06-21 14:38:10 +00:00
|
|
|
const newName = window.prompt(`Enter name for cloned VM:`, `${vm.name}-clone`);
|
|
|
|
|
if (!newName) {
|
|
|
|
|
toast.info('Clone cancelled');
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-06-21 04:36:58 +00:00
|
|
|
|
|
|
|
|
await invoke('clone_vm', {
|
|
|
|
|
clusterId,
|
|
|
|
|
nodeId: vm.node,
|
|
|
|
|
vmId: vm.vmid,
|
|
|
|
|
newVmid,
|
2026-06-21 16:17:31 +00:00
|
|
|
name: newName,
|
2026-06-21 04:36:58 +00:00
|
|
|
});
|
|
|
|
|
|
2026-06-21 16:17:31 +00:00
|
|
|
toast.success(`VM ${vm.name} cloned successfully to VM ${newVmid}`);
|
2026-06-21 04:36:58 +00:00
|
|
|
onRefresh?.();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Failed to clone VM:', error);
|
|
|
|
|
toast.error(`Failed to clone VM ${vm.name}: ${error}`);
|
|
|
|
|
}
|
2026-06-21 16:17:31 +00:00
|
|
|
}, [clusterId, vms, onRefresh]);
|
2026-06-21 04:36:58 +00:00
|
|
|
|
|
|
|
|
const handleDelete = useCallback(async (vm: VMInfo) => {
|
2026-06-21 16:17:31 +00:00
|
|
|
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) {
|
2026-06-21 04:36:58 +00:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await invoke('delete_vm', {
|
|
|
|
|
clusterId,
|
|
|
|
|
nodeId: vm.node,
|
|
|
|
|
vmId: vm.vmid,
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-21 16:17:31 +00:00
|
|
|
toast.success(`VM ${vm.name} deleted successfully`);
|
2026-06-21 04:36:58 +00:00
|
|
|
onRefresh?.();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Failed to delete VM:', error);
|
|
|
|
|
toast.error(`Failed to delete VM ${vm.name}: ${error}`);
|
|
|
|
|
}
|
|
|
|
|
}, [clusterId, onRefresh]);
|
|
|
|
|
|
2026-06-11 14:38:36 +00:00
|
|
|
return (
|
|
|
|
|
<Card>
|
|
|
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
|
|
|
<CardTitle>Virtual Machines</CardTitle>
|
|
|
|
|
<div className="flex space-x-2">
|
|
|
|
|
<Button variant="outline" size="sm" onClick={onRefresh} disabled={isLoading}>
|
|
|
|
|
Refresh
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent>
|
|
|
|
|
<div className="overflow-auto">
|
|
|
|
|
<Table>
|
|
|
|
|
<TableHeader>
|
|
|
|
|
<TableRow>
|
|
|
|
|
<TableHead className="w-[40px]">
|
|
|
|
|
<Checkbox
|
|
|
|
|
checked={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));
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</TableHead>
|
|
|
|
|
<TableHead>Name</TableHead>
|
|
|
|
|
<TableHead>VM ID</TableHead>
|
|
|
|
|
<TableHead>Node</TableHead>
|
|
|
|
|
<TableHead>Status</TableHead>
|
|
|
|
|
<TableHead>CPU</TableHead>
|
|
|
|
|
<TableHead>Memory</TableHead>
|
|
|
|
|
<TableHead>Disk</TableHead>
|
|
|
|
|
<TableHead>Uptime</TableHead>
|
|
|
|
|
<TableHead className="text-right">Actions</TableHead>
|
|
|
|
|
</TableRow>
|
|
|
|
|
</TableHeader>
|
|
|
|
|
<TableBody>
|
2026-06-21 04:36:58 +00:00
|
|
|
{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 (
|
|
|
|
|
<TableRow key={vm.id}>
|
|
|
|
|
<TableCell>
|
|
|
|
|
<Checkbox
|
|
|
|
|
checked={selectedVMs.has(vm.id)}
|
|
|
|
|
onCheckedChange={() => onToggleSelect?.(vm)}
|
|
|
|
|
/>
|
|
|
|
|
</TableCell>
|
|
|
|
|
<TableCell className="font-medium">{vm.name}</TableCell>
|
|
|
|
|
<TableCell>{vm.vmid}</TableCell>
|
|
|
|
|
<TableCell>{vm.node}</TableCell>
|
|
|
|
|
<TableCell>
|
|
|
|
|
<span className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${
|
|
|
|
|
vm.status === 'running' ? 'bg-green-100 text-green-800' :
|
|
|
|
|
vm.status === 'stopped' ? 'bg-red-100 text-red-800' :
|
|
|
|
|
'bg-yellow-100 text-yellow-800'
|
|
|
|
|
}`}>
|
|
|
|
|
{vm.status}
|
|
|
|
|
</span>
|
|
|
|
|
</TableCell>
|
|
|
|
|
<TableCell>{cpuPercent.toFixed(2)}%</TableCell>
|
|
|
|
|
<TableCell>
|
|
|
|
|
{vm.memoryTotal > 0 ? (
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<div className="flex-1 h-2 bg-gray-200 rounded-full overflow-hidden">
|
|
|
|
|
<div
|
|
|
|
|
className="h-full bg-blue-500"
|
|
|
|
|
style={{ width: `${memoryPercent}%` }}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<span className="text-xs text-muted-foreground">
|
|
|
|
|
{formatBytes(vm.memory)} / {formatBytes(vm.memoryTotal)}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<span className="text-muted-foreground">-</span>
|
|
|
|
|
)}
|
|
|
|
|
</TableCell>
|
|
|
|
|
<TableCell>
|
|
|
|
|
{vm.diskTotal > 0 ? (
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<div className="flex-1 h-2 bg-gray-200 rounded-full overflow-hidden">
|
|
|
|
|
<div
|
|
|
|
|
className="h-full bg-green-500"
|
|
|
|
|
style={{ width: `${diskPercent}%` }}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<span className="text-xs text-muted-foreground">
|
|
|
|
|
{formatBytes(vm.disk)} / {formatBytes(vm.diskTotal)}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<span className="text-muted-foreground">-</span>
|
|
|
|
|
)}
|
|
|
|
|
</TableCell>
|
|
|
|
|
<TableCell>{formatUptime(vm.uptime || 0)}</TableCell>
|
|
|
|
|
<TableCell className="text-right">
|
2026-06-11 14:38:36 +00:00
|
|
|
<VMActionMenu
|
|
|
|
|
vm={vm}
|
2026-06-21 04:36:58 +00:00
|
|
|
onVMAction={handleVMAction}
|
|
|
|
|
onSnapshotAction={handleSnapshotAction}
|
|
|
|
|
onMigrate={handleMigrate}
|
|
|
|
|
onClone={handleClone}
|
|
|
|
|
onDelete={handleDelete}
|
2026-06-11 14:38:36 +00:00
|
|
|
/>
|
2026-06-21 04:36:58 +00:00
|
|
|
</TableCell>
|
|
|
|
|
</TableRow>
|
|
|
|
|
);
|
|
|
|
|
})}
|
2026-06-11 14:38:36 +00:00
|
|
|
</TableBody>
|
|
|
|
|
</Table>
|
|
|
|
|
</div>
|
|
|
|
|
</CardContent>
|
2026-06-21 16:17:31 +00:00
|
|
|
|
|
|
|
|
<MigrationDialog
|
|
|
|
|
vm={migrationVM}
|
|
|
|
|
isOpen={!!migrationVM}
|
|
|
|
|
onClose={() => setMigrationVM(null)}
|
|
|
|
|
onSubmit={submitMigration}
|
|
|
|
|
availableNodes={vms}
|
|
|
|
|
targetNode={targetNode}
|
|
|
|
|
onTargetNodeChange={setTargetNode}
|
|
|
|
|
online={onlineMigration}
|
|
|
|
|
onOnlineChange={setOnlineMigration}
|
|
|
|
|
maxDowntime={maxDowntime}
|
|
|
|
|
onMaxDowntimeChange={setMaxDowntime}
|
|
|
|
|
/>
|
2026-06-11 14:38:36 +00:00
|
|
|
</Card>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface VMActionMenuProps {
|
|
|
|
|
vm: VMInfo;
|
2026-06-21 04:36:58 +00:00
|
|
|
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;
|
2026-06-11 14:38:36 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function VMActionMenu({
|
|
|
|
|
vm,
|
|
|
|
|
onVMAction,
|
|
|
|
|
onSnapshotAction,
|
|
|
|
|
onMigrate,
|
|
|
|
|
onClone,
|
|
|
|
|
onDelete,
|
|
|
|
|
}: VMActionMenuProps) {
|
2026-06-21 04:36:58 +00:00
|
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
|
|
|
const menuRef = useRef<HTMLDivElement>(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;
|
2026-06-21 14:38:10 +00:00
|
|
|
const viewportWidth = window.innerWidth;
|
2026-06-21 04:36:58 +00:00
|
|
|
const buttonRect = menuRef.current?.querySelector('button')?.getBoundingClientRect();
|
|
|
|
|
|
|
|
|
|
if (!buttonRect) return { top: '100%', left: 0 };
|
|
|
|
|
|
|
|
|
|
const menuHeight = 400; // approximate menu height
|
2026-06-21 14:38:10 +00:00
|
|
|
const menuWidth = 192; // approximate menu width (w-48 = 12rem = 192px)
|
2026-06-21 04:36:58 +00:00
|
|
|
const spaceBelow = viewportHeight - buttonRect.bottom;
|
|
|
|
|
const spaceAbove = buttonRect.top;
|
2026-06-21 14:38:10 +00:00
|
|
|
const spaceRight = viewportWidth - buttonRect.right;
|
2026-06-21 04:36:58 +00:00
|
|
|
|
2026-06-21 14:38:10 +00:00
|
|
|
// Vertical positioning
|
|
|
|
|
let verticalPos: { top?: string; bottom?: string } = { top: '100%' };
|
2026-06-21 04:36:58 +00:00
|
|
|
if (spaceBelow >= menuHeight) {
|
2026-06-21 14:38:10 +00:00
|
|
|
verticalPos = { top: '100%' };
|
2026-06-21 04:36:58 +00:00
|
|
|
} else if (spaceAbove >= menuHeight) {
|
2026-06-21 14:38:10 +00:00
|
|
|
verticalPos = { bottom: '100%' };
|
2026-06-21 04:36:58 +00:00
|
|
|
}
|
2026-06-21 14:38:10 +00:00
|
|
|
|
|
|
|
|
// 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 };
|
2026-06-21 04:36:58 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const position = getMenuPosition();
|
2026-06-11 14:38:36 +00:00
|
|
|
|
|
|
|
|
return (
|
2026-06-21 04:36:58 +00:00
|
|
|
<div className="relative" ref={menuRef}>
|
2026-06-11 14:38:36 +00:00
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
2026-06-21 04:36:58 +00:00
|
|
|
onClick={toggleMenu}
|
|
|
|
|
className="h-8 w-8 p-0"
|
2026-06-11 14:38:36 +00:00
|
|
|
>
|
|
|
|
|
<MoreHorizontal className="h-4 w-4" />
|
|
|
|
|
</Button>
|
|
|
|
|
{isOpen && (
|
2026-06-21 04:36:58 +00:00
|
|
|
<div
|
|
|
|
|
className={`absolute z-50 w-48 rounded-md border bg-background shadow-md ${
|
|
|
|
|
position.bottom ? 'bottom-full mb-2' : 'top-full mt-2'
|
2026-06-21 14:38:10 +00:00
|
|
|
} ${position.right ? 'right-0' : ''}`}
|
2026-06-21 15:00:44 +00:00
|
|
|
style={{ left: position.left ?? undefined, right: position.right ?? undefined }}
|
2026-06-21 04:36:58 +00:00
|
|
|
>
|
|
|
|
|
<div className="space-y-1 p-1">
|
|
|
|
|
{vm.status === 'stopped' && (
|
2026-06-11 14:38:36 +00:00
|
|
|
<button
|
|
|
|
|
className="flex w-full items-center rounded-md px-2 py-1.5 text-sm hover:bg-accent"
|
2026-06-21 04:36:58 +00:00
|
|
|
onClick={() => onVMAction(vm, 'start')}
|
2026-06-11 14:38:36 +00:00
|
|
|
>
|
2026-06-21 04:36:58 +00:00
|
|
|
<Play className="mr-2 h-4 w-4" />
|
|
|
|
|
Start
|
2026-06-11 14:38:36 +00:00
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
{vm.status === 'running' && (
|
2026-06-21 04:36:58 +00:00
|
|
|
<>
|
|
|
|
|
<button
|
|
|
|
|
className="flex w-full items-center rounded-md px-2 py-1.5 text-sm hover:bg-accent"
|
|
|
|
|
onClick={() => onVMAction(vm, 'stop')}
|
|
|
|
|
>
|
|
|
|
|
<Square className="mr-2 h-4 w-4" />
|
|
|
|
|
Stop
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
className="flex w-full items-center rounded-md px-2 py-1.5 text-sm hover:bg-accent"
|
|
|
|
|
onClick={() => onVMAction(vm, 'reboot')}
|
|
|
|
|
>
|
|
|
|
|
<RotateCcw className="mr-2 h-4 w-4" />
|
|
|
|
|
Reboot
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
className="flex w-full items-center rounded-md px-2 py-1.5 text-sm hover:bg-accent"
|
|
|
|
|
onClick={() => onVMAction(vm, 'shutdown')}
|
|
|
|
|
>
|
|
|
|
|
<Power className="mr-2 h-4 w-4" />
|
|
|
|
|
Shutdown
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
className="flex w-full items-center rounded-md px-2 py-1.5 text-sm hover:bg-accent"
|
|
|
|
|
onClick={() => onVMAction(vm, 'suspend')}
|
|
|
|
|
>
|
|
|
|
|
<Pause className="mr-2 h-4 w-4" />
|
|
|
|
|
Suspend
|
|
|
|
|
</button>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
{vm.status === 'paused' && (
|
2026-06-11 14:38:36 +00:00
|
|
|
<button
|
|
|
|
|
className="flex w-full items-center rounded-md px-2 py-1.5 text-sm hover:bg-accent"
|
2026-06-21 04:36:58 +00:00
|
|
|
onClick={() => onVMAction(vm, 'resume')}
|
2026-06-11 14:38:36 +00:00
|
|
|
>
|
2026-06-21 04:36:58 +00:00
|
|
|
<PlayCircle className="mr-2 h-4 w-4" />
|
|
|
|
|
Resume
|
2026-06-11 14:38:36 +00:00
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
<div className="h-px bg-border my-1" />
|
|
|
|
|
<button
|
|
|
|
|
className="flex w-full items-center rounded-md px-2 py-1.5 text-sm hover:bg-accent"
|
2026-06-21 04:36:58 +00:00
|
|
|
onClick={() => onSnapshotAction(vm, 'create')}
|
2026-06-11 14:38:36 +00:00
|
|
|
>
|
2026-06-21 04:36:58 +00:00
|
|
|
📸 Create Snapshot
|
2026-06-11 14:38:36 +00:00
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
className="flex w-full items-center rounded-md px-2 py-1.5 text-sm hover:bg-accent"
|
2026-06-21 04:36:58 +00:00
|
|
|
onClick={() => onSnapshotAction(vm, 'list')}
|
2026-06-11 14:38:36 +00:00
|
|
|
>
|
2026-06-21 04:36:58 +00:00
|
|
|
📋 List Snapshots
|
2026-06-11 14:38:36 +00:00
|
|
|
</button>
|
|
|
|
|
<div className="h-px bg-border my-1" />
|
|
|
|
|
<button
|
|
|
|
|
className="flex w-full items-center rounded-md px-2 py-1.5 text-sm hover:bg-accent"
|
2026-06-21 04:36:58 +00:00
|
|
|
onClick={() => onMigrate(vm)}
|
2026-06-11 14:38:36 +00:00
|
|
|
>
|
2026-06-21 04:36:58 +00:00
|
|
|
<MoveRight className="mr-2 h-4 w-4" />
|
2026-06-11 14:38:36 +00:00
|
|
|
Migrate
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
className="flex w-full items-center rounded-md px-2 py-1.5 text-sm hover:bg-accent"
|
2026-06-21 04:36:58 +00:00
|
|
|
onClick={() => onClone(vm)}
|
2026-06-11 14:38:36 +00:00
|
|
|
>
|
2026-06-21 04:36:58 +00:00
|
|
|
<Copy className="mr-2 h-4 w-4" />
|
2026-06-11 14:38:36 +00:00
|
|
|
Clone
|
|
|
|
|
</button>
|
|
|
|
|
<div className="h-px bg-border my-1" />
|
|
|
|
|
<button
|
|
|
|
|
className="flex w-full items-center rounded-md px-2 py-1.5 text-sm hover:bg-red-100 hover:text-red-600"
|
2026-06-21 04:36:58 +00:00
|
|
|
onClick={() => onDelete(vm)}
|
2026-06-11 14:38:36 +00:00
|
|
|
>
|
|
|
|
|
<X className="mr-2 h-4 w-4" />
|
|
|
|
|
Delete
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-06-21 16:17:31 +00:00
|
|
|
|
|
|
|
|
interface MigrationDialogProps {
|
|
|
|
|
vm: VMInfo | null;
|
|
|
|
|
isOpen: boolean;
|
|
|
|
|
onClose: () => void;
|
|
|
|
|
onSubmit: () => void;
|
|
|
|
|
availableNodes: VMInfo[];
|
|
|
|
|
targetNode: string;
|
|
|
|
|
onTargetNodeChange: (node: string) => void;
|
|
|
|
|
online: boolean;
|
|
|
|
|
onOnlineChange: (online: boolean) => void;
|
|
|
|
|
maxDowntime: number;
|
|
|
|
|
onMaxDowntimeChange: (downtime: number) => void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function MigrationDialog({
|
|
|
|
|
vm,
|
|
|
|
|
isOpen,
|
|
|
|
|
onClose,
|
|
|
|
|
onSubmit,
|
|
|
|
|
availableNodes,
|
|
|
|
|
targetNode,
|
|
|
|
|
onTargetNodeChange,
|
|
|
|
|
online,
|
|
|
|
|
onOnlineChange,
|
|
|
|
|
maxDowntime,
|
|
|
|
|
onMaxDowntimeChange,
|
|
|
|
|
}: MigrationDialogProps) {
|
|
|
|
|
if (!vm) return null;
|
|
|
|
|
|
|
|
|
|
const availableTargets = availableNodes
|
|
|
|
|
.map((v) => v.node)
|
|
|
|
|
.filter((node, index, self) => self.indexOf(node) === index && node !== vm.node);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
|
|
|
|
<DialogContent>
|
|
|
|
|
<DialogHeader>
|
|
|
|
|
<DialogTitle>Migrate {vm.name} (VM {vm.vmid})</DialogTitle>
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
<div className="space-y-4 py-4">
|
|
|
|
|
<Alert variant="destructive">
|
|
|
|
|
<AlertCircle className="h-4 w-4" />
|
|
|
|
|
<AlertDescription>
|
|
|
|
|
Live migration requires the same hardware configuration on both nodes. Ensure storage is accessible from both nodes.
|
|
|
|
|
</AlertDescription>
|
|
|
|
|
</Alert>
|
|
|
|
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label htmlFor="targetNode">Target Node</Label>
|
|
|
|
|
<Select value={targetNode} onValueChange={onTargetNodeChange}>
|
|
|
|
|
<SelectTrigger>
|
|
|
|
|
<SelectValue placeholder="Select target node" />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
{availableTargets.map((node) => (
|
|
|
|
|
<SelectItem key={node} value={node}>
|
|
|
|
|
{node}
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
{availableTargets.length === 0 && (
|
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
|
|
|
No other nodes available for migration
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<div className="flex items-center space-x-2">
|
|
|
|
|
<UICheckbox
|
|
|
|
|
id="online"
|
|
|
|
|
checked={online}
|
|
|
|
|
onCheckedChange={(checked) => onOnlineChange(checked as boolean)}
|
|
|
|
|
/>
|
|
|
|
|
<Label htmlFor="online">Live Migration</Label>
|
|
|
|
|
</div>
|
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
|
|
|
{online ? 'Keep VM running during migration' : 'VM will be stopped during migration'}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{online && (
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label htmlFor="maxDowntime">Max Downtime (ms)</Label>
|
|
|
|
|
<Input
|
|
|
|
|
id="maxDowntime"
|
|
|
|
|
type="number"
|
|
|
|
|
value={maxDowntime}
|
|
|
|
|
onChange={(e) => onMaxDowntimeChange(Number(e.target.value))}
|
|
|
|
|
min={10}
|
|
|
|
|
max={10000}
|
|
|
|
|
/>
|
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
|
|
|
Maximum allowed downtime during live migration
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<DialogFooter>
|
|
|
|
|
<Button variant="outline" onClick={onClose}>
|
|
|
|
|
Cancel
|
|
|
|
|
</Button>
|
|
|
|
|
<Button onClick={onSubmit} disabled={!targetNode || availableTargets.length === 0}>
|
|
|
|
|
Start Migration
|
|
|
|
|
</Button>
|
|
|
|
|
</DialogFooter>
|
|
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
|
|
|
|
);
|
|
|
|
|
}
|