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-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[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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<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({
|
|
|
|
|
vms,
|
|
|
|
|
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>('');
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
invoke<string>('get_current_proxmox_cluster').catch(() => {
|
|
|
|
|
// Fallback: try to get first cluster
|
|
|
|
|
invoke<string[]>('list_proxmox_clusters')
|
|
|
|
|
.then((clusters: any[]) => {
|
|
|
|
|
if (clusters.length > 0) {
|
|
|
|
|
setClusterId(clusters[0].id);
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.catch(() => {});
|
|
|
|
|
})
|
|
|
|
|
.then((id) => {
|
|
|
|
|
if (id) setClusterId(id);
|
|
|
|
|
});
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-21 14:38:10 +00:00
|
|
|
const targetNode = window.prompt(`Select target node: ${targetNodes.join(', ')}`, targetNodes[0]);
|
|
|
|
|
|
|
|
|
|
if (!targetNode) {
|
|
|
|
|
toast.info('Migration cancelled');
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-06-21 04:36:58 +00:00
|
|
|
|
|
|
|
|
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 {
|
2026-06-21 14:38:10 +00:00
|
|
|
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;
|
|
|
|
|
}
|
2026-06-21 04:36:58 +00:00
|
|
|
|
|
|
|
|
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]);
|
|
|
|
|
|
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>
|
|
|
|
|
</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' : ''}`}
|
|
|
|
|
style={{ left: position.left ?? 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>
|
|
|
|
|
);
|
|
|
|
|
}
|