diff --git a/src-tauri/src/commands/proxmox.rs b/src-tauri/src/commands/proxmox.rs index a78c1fd6..fd477b3c 100644 --- a/src-tauri/src/commands/proxmox.rs +++ b/src-tauri/src/commands/proxmox.rs @@ -535,58 +535,112 @@ pub async fn shutdown_proxmox_vm( .map_err(|e| format!("Failed to shutdown VM {}: {}", vm_id, e)) } -/// List Proxmox Backup Jobs +/// List Proxmox Backup Jobs (cluster-level, not node-level) #[tauri::command] pub async fn list_proxmox_backup_jobs( cluster_id: String, - node: String, + _node: String, // Node parameter kept for compatibility but not used state: State<'_, AppState>, ) -> Result, String> { let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?; let client_guard = client.lock().await; - let jobs = crate::proxmox::backup::list_backup_jobs( - &client_guard, - &node, - client_guard.ticket.as_deref().unwrap_or(""), - ) - .await - .map_err(|e| format!("Failed to list backup jobs: {}", e))?; + // Proxmox VE backup jobs are at cluster level, not node level + let path = "cluster/backup"; + let response: serde_json::Value = client_guard + .get(path, Some(client_guard.ticket.as_deref().unwrap_or(""))) + .await + .map_err(|e| format!("Failed to list backup jobs: {}", e))?; - let json_jobs: Vec = jobs - .into_iter() - .map(|job| { - serde_json::to_value(job).map_err(|e| format!("Failed to serialize backup job: {}", e)) + let mut jobs: Vec = response + .as_array() + .map(|arr| { + arr.iter() + .cloned() + .map(|mut job| { + // Add id field if missing for frontend compatibility + if let Some(job_obj) = job.as_object_mut() { + if !job_obj.contains_key("id") { + if let Some(jobid) = job_obj.get("id").and_then(|v| v.as_str()) { + job_obj.insert("id".to_string(), serde_json::Value::String(jobid.to_string())); + } else { + let storage = job_obj.get("storage") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(); + job_obj.insert("id".to_string(), serde_json::Value::String(format!("backup-{}", storage))); + } + } + } + job + }) + .collect() }) - .collect::, _>>()?; + .ok_or_else(|| "Invalid response format".to_string())?; - Ok(json_jobs) + // Apply limit if needed (Proxmox may return many jobs) + if jobs.len() > 100 { + jobs.truncate(100); + } + + Ok(jobs) } -/// List Proxmox Datastores +/// List Proxmox Datastores (Storage per node) #[tauri::command] pub async fn list_proxmox_datastores( cluster_id: String, - state: State<'_, AppState>, + _state: State<'_, AppState>, ) -> Result, String> { - let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?; + // Note: Proxmox VE storage is per-node, not cluster-wide + // We need to get all nodes first, then fetch storage for each node + let client = get_proxmox_client_for_cluster(&cluster_id, &_state).await?; let client_guard = client.lock().await; - let datastores = crate::proxmox::backup::list_datastores( - &client_guard, - client_guard.ticket.as_deref().unwrap_or(""), - ) - .await - .map_err(|e| format!("Failed to list datastores: {}", e))?; + // First, get all nodes + let nodes_path = "cluster/resources?type=node"; + let nodes_response: serde_json::Value = client_guard + .get(nodes_path, Some(client_guard.ticket.as_deref().unwrap_or(""))) + .await + .map_err(|e| format!("Failed to list nodes: {}", e))?; - let json_datastores: Vec = datastores - .into_iter() - .map(|ds| { - serde_json::to_value(ds).map_err(|e| format!("Failed to serialize datastore: {}", e)) - }) - .collect::, _>>()?; + let nodes: Vec = nodes_response + .as_array() + .unwrap_or(&vec![]) + .iter() + .filter_map(|n| n.get("node").and_then(|node| node.as_str()).map(|s| s.to_string())) + .collect(); - Ok(json_datastores) + if nodes.is_empty() { + return Ok(vec![]); + } + + // Fetch storage for each node + let mut all_storage: Vec = vec![]; + + for node in nodes { + let storage_path = format!("nodes/{}/storage", node); + let storage_response: serde_json::Value = client_guard + .get(&storage_path, Some(client_guard.ticket.as_deref().unwrap_or(""))) + .await + .map_err(|e| format!("Failed to list storage for node {}: {}", node, e))?; + + if let Some(storage_array) = storage_response.as_array() { + for mut storage in storage_array.clone() { + // Add node information to each storage entry + if let Some(storage_obj) = storage.as_object_mut() { + storage_obj.insert("node".to_string(), serde_json::Value::String(node.clone())); + // Create a unique ID + if let Some(storage_name) = storage_obj.get("storage").and_then(|s| s.as_str()) { + storage_obj.insert("id".to_string(), serde_json::Value::String(format!("storage/{}", storage_name))); + } + } + all_storage.push(storage); + } + } + } + + Ok(all_storage) } /// Trigger Proxmox Backup Job @@ -1007,8 +1061,17 @@ pub async fn list_views( &client_guard, client_guard.ticket.as_deref().unwrap_or(""), ) - .await - .map_err(|e| format!("Failed to list views: {}", e))?; + .await; + + // Handle 501 Not Implemented gracefully - return empty array + if let Err(e) = &views { + if e.contains("501") || e.contains("Not Implemented") || e.contains("not implemented") { + tracing::warn!("Views API not implemented by Proxmox server, returning empty list"); + return Ok(vec![]); + } + } + + let views = views.map_err(|e| format!("Failed to list views: {}", e))?; let json_views: Vec = views .into_iter() @@ -1137,8 +1200,17 @@ pub async fn list_certificates( &client_guard, client_guard.ticket.as_deref().unwrap_or(""), ) - .await - .map_err(|e| format!("Failed to list certificates: {}", e))?; + .await; + + // Handle 501 Not Implemented gracefully - return empty array + if let Err(e) = &certs { + if e.contains("501") || e.contains("Not Implemented") || e.contains("not implemented") { + tracing::warn!("Certificates API not implemented by Proxmox server, returning empty list"); + return Ok(vec![]); + } + } + + let certs = certs.map_err(|e| format!("Failed to list certificates: {}", e))?; let json_certs: Vec = certs .into_iter() @@ -2086,23 +2158,31 @@ pub async fn get_subscription_status( #[tauri::command] pub async fn list_cluster_tasks( cluster_id: String, - limit: Option, + _limit: Option, state: State<'_, AppState>, ) -> Result, String> { let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?; let client_guard = client.lock().await; - let limit_val = limit.unwrap_or(50); - let path = format!("cluster/tasks?limit={}", limit_val); + // Note: Proxmox API doesn't support limit parameter for cluster/tasks + // We fetch all tasks and limit client-side if needed + let path = "cluster/tasks"; let response: serde_json::Value = client_guard - .get(&path, Some(client_guard.ticket.as_deref().unwrap_or(""))) + .get(path, Some(client_guard.ticket.as_deref().unwrap_or(""))) .await .map_err(|e| format!("Failed to list cluster tasks: {}", e))?; - response + let mut tasks: Vec = response .as_array() .map(|arr| arr.to_vec()) - .ok_or_else(|| "Invalid response format".to_string()) + .ok_or_else(|| "Invalid response format".to_string())?; + + // Apply limit client-side if specified + if let Some(limit) = _limit { + tasks.truncate(limit as usize); + } + + Ok(tasks) } /// List Proxmox LXC containers diff --git a/src-tauri/src/proxmox/client.rs b/src-tauri/src/proxmox/client.rs index ad770562..8e61ec56 100644 --- a/src-tauri/src/proxmox/client.rs +++ b/src-tauri/src/proxmox/client.rs @@ -502,7 +502,8 @@ mod tests { } }; - let mut client = ProxmoxClient::new("proxmox-server", 8006, "root@pam"); + let host = std::env::var("PROXMOX_HOST").unwrap_or_else(|_| "proxmox-server".to_string()); + let mut client = ProxmoxClient::new(&host, 8006, "root@pam"); client .authenticate(&password) .await @@ -544,7 +545,8 @@ mod tests { } }; - let mut client = ProxmoxClient::new("proxmox-server", 8006, "root@pam"); + let host = std::env::var("PROXMOX_HOST").unwrap_or_else(|_| "proxmox-server".to_string()); + let mut client = ProxmoxClient::new(&host, 8006, "root@pam"); client .authenticate(&password) .await diff --git a/src/components/Proxmox/StorageList.tsx b/src/components/Proxmox/StorageList.tsx index 5d76f6ba..83a4e9ed 100644 --- a/src/components/Proxmox/StorageList.tsx +++ b/src/components/Proxmox/StorageList.tsx @@ -6,13 +6,14 @@ import { MoreHorizontal } from 'lucide-react'; interface StorageInfo { id: string; + storage: string; name: string; type: string; - remote: string; + content: string; node?: string; - used: string; - total: string; - available: string; + size: number; + used: number; + available: number; status: string; } @@ -24,6 +25,14 @@ interface StorageListProps { onDelete?: (storage: StorageInfo) => void; } +function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; +} + export function StorageList({ storages, onRefresh, @@ -46,7 +55,7 @@ export function StorageList({ Name Type - Remote + Content Node Used Total @@ -56,46 +65,54 @@ export function StorageList({ - {storages.map((storage) => ( - - {storage.name} - {storage.type} - {storage.remote} - {storage.node || '-'} - {storage.used} - {storage.total} - {storage.available} - - - {storage.status} - - - -
- - - -
+ {storages.length === 0 ? ( + + + No storage configured or unable to fetch storage data - ))} + ) : ( + storages.map((storage) => ( + + {storage.storage || storage.name} + {storage.type || '-'} + {storage.content || '-'} + {storage.node || '-'} + {storage.used ? formatBytes(storage.used) : '-'} + {storage.size ? formatBytes(storage.size) : '-'} + {storage.available ? formatBytes(storage.available) : '-'} + + + {storage.status || 'available'} + + + +
+ + + +
+
+
+ )) + )}
diff --git a/src/components/Proxmox/VMList.tsx b/src/components/Proxmox/VMList.tsx index 5aac8afe..6e85ca72 100644 --- a/src/components/Proxmox/VMList.tsx +++ b/src/components/Proxmox/VMList.tsx @@ -1,9 +1,11 @@ -import React from 'react'; +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 } from 'lucide-react'; +import { MoreHorizontal, Play, Square, RotateCcw, Power, PlayCircle, Pause, X, MoveRight, Copy } from 'lucide-react'; +import { invoke } from '@tauri-apps/api/core'; +import { toast } from 'sonner'; interface VMInfo { id: string; @@ -16,7 +18,7 @@ interface VMInfo { memoryTotal: number; disk: number; diskTotal: number; - uptime?: string; + uptime?: number; tags?: string[]; } @@ -24,7 +26,6 @@ interface VMListProps { vms: VMInfo[]; onRefresh?: () => void; isLoading?: boolean; - 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; @@ -33,11 +34,33 @@ interface VMListProps { 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, - onVMAction, onSnapshotAction, onMigrate, onClone, @@ -45,6 +68,165 @@ export function VMList({ selectedVMs = new Set(), onToggleSelect, }: VMListProps) { + const [clusterId, setClusterId] = useState(''); + + useEffect(() => { + invoke('get_current_proxmox_cluster').catch(() => { + // Fallback: try to get first cluster + invoke('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; + } + + const targetNode = await prompt(`Select target node: ${targetNodes.join(', ')}`, targetNodes[0]); + + 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 = await prompt(`Enter new VM ID for ${vm.name}:`, `${vm.vmid + 1}`); + const newVmid = newVmidStr ? parseInt(newVmidStr) : vm.vmid + 1; + const newName = await prompt(`Enter name for cloned VM:`, `${vm.name}-clone`); + + 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 ( @@ -84,44 +266,80 @@ export function VMList({ - {vms.map((vm) => ( - - - onToggleSelect?.(vm)} - /> - - {vm.name} - {vm.vmid} - {vm.node} - - - {vm.status} - - - {vm.cpu}% - {Math.round((vm.memory / vm.memoryTotal) * 100)}% - {Math.round((vm.disk / vm.diskTotal) * 100)}% - {vm.uptime || '-'} - -
+ {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)} + -
-
- - ))} + + + ); + })}
@@ -132,11 +350,11 @@ export function VMList({ 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; + 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({ @@ -147,133 +365,160 @@ function VMActionMenu({ onClone, onDelete, }: VMActionMenuProps) { - const [isOpen, setIsOpen] = React.useState(false); + 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 buttonRect = menuRef.current?.querySelector('button')?.getBoundingClientRect(); + + if (!buttonRect) return { top: '100%', left: 0 }; + + const menuHeight = 400; // approximate menu height + const spaceBelow = viewportHeight - buttonRect.bottom; + const spaceAbove = buttonRect.top; + + if (spaceBelow >= menuHeight) { + return { top: '100%', left: 0 }; + } else if (spaceAbove >= menuHeight) { + return { bottom: '100%', left: 0 }; + } else { + // Menu will fit somewhere in the middle + return { top: '100%', left: 0 }; + } + }; + + const position = getMenuPosition(); return ( -
+
{isOpen && ( -
-
- - - - +
+
+ {vm.status === 'stopped' && ( + + )} + {vm.status === 'running' && ( + <> + + + + + + )} {vm.status === 'paused' && ( )} - {vm.status === 'running' && ( - - )}
+ )} +
-
loadJobs(selectedClusterId, nodeId)} + onRefresh={() => loadJobs(selectedClusterId)} />
); diff --git a/src/pages/Proxmox/VMsPage.tsx b/src/pages/Proxmox/VMsPage.tsx index 37edd046..a61c615b 100644 --- a/src/pages/Proxmox/VMsPage.tsx +++ b/src/pages/Proxmox/VMsPage.tsx @@ -88,7 +88,6 @@ export function ProxmoxVMsPage() { loadVms(selectedClusterId)} - onVMAction={(_vm, _action) => { toast.info('VM action — not yet implemented'); }} onSnapshotAction={(_vm, _action) => { toast.info('Snapshot action — not yet implemented'); }} onMigrate={(_vm) => { toast.info('Migrate — not yet implemented'); }} onClone={(_vm) => { toast.info('Clone — not yet implemented'); }}