diff --git a/docs/tickets/proxmox-vm-actions-v3.md b/docs/tickets/proxmox-vm-actions-v3.md new file mode 100644 index 00000000..21fb1fb5 --- /dev/null +++ b/docs/tickets/proxmox-vm-actions-v3.md @@ -0,0 +1,73 @@ +# Proxmox VM Actions — Fix & Add VM + +## Description + +Three issues existed in the Proxmox | VMs page: + +1. **Actions did nothing** — `toast.success()` / `toast.error()` were called but the `` component from `sonner` was never mounted in `App.tsx`, so all feedback was silently discarded. The backend commands were wired correctly; the issue was purely the missing Toaster mount. + +2. **Disk column showing meaningless data** — PVE `cluster/resources` does not return meaningful disk usage for running VMs (only static allocation metadata). The Disk column was removed. + +3. **No way to create a new VM** — No "Add VM" button or creation flow existed. + +Additionally: +- `dialog:allow-confirm` was missing from capabilities, causing the delete confirmation to fail silently. +- The `MigrationDialog` derived available target nodes from the local VM list (only nodes that already had a VM), instead of querying the actual cluster node list. +- `suspendProxmoxVm` and `resumeProxmoxVm` client wrappers were missing from `proxmoxClient.ts`. + +## Acceptance Criteria + +- [x] VM start/stop/reboot/shutdown/suspend/resume actions show toast feedback +- [x] Migrate action shows a dialog populated with real cluster nodes from `GET /nodes` +- [x] Disk column is absent from the VMs table +- [x] "Add VM" button opens a creation dialog with node, VMID, name, memory, CPU, storage, disk, network, and optional ISO fields +- [x] Created VM appears after refreshing the VM list +- [x] All existing tests continue to pass; new tests added for new functionality and security validation +- [x] `cargo clippy -- -D warnings` passes; `npx eslint . --max-warnings 0` passes; `npx tsc --noEmit` passes + +## Work Implemented + +### Frontend + +| File | Change | +|---|---| +| `src/App.tsx` | Added `` — root cause fix for silent actions | +| `src/components/Proxmox/VMList.tsx` | Removed Disk column (header + cell + `diskPercent` calc); updated `MigrationDialog` to fetch nodes via `list_proxmox_nodes` invoke; fixed `useMemo` unused import | +| `src/components/Proxmox/CreateVmDialog.tsx` | New component — form dialog for creating QEMU VMs with node/storage discovery | +| `src/components/Proxmox/index.ts` | Exported `CreateVmDialog` | +| `src/pages/Proxmox/VMsPage.tsx` | Added "Add VM" button + `CreateVmDialog` mount | +| `src/lib/proxmoxClient.ts` | Added `suspendProxmoxVm`, `resumeProxmoxVm`, `listProxmoxNodes`, `createProxmoxVm` wrappers | + +### Backend (Rust) + +| File | Change | +|---|---| +| `src-tauri/src/commands/proxmox.rs` | Added `list_proxmox_nodes`, `create_proxmox_vm` commands; added `validate_pve_identifier` helper | +| `src-tauri/src/lib.rs` | Registered `list_proxmox_nodes` and `create_proxmox_vm` | +| `src-tauri/capabilities/default.json` | Added `dialog:allow-confirm` permission | + +### Security Hardening (new commands only) + +- **H2 — Path injection**: `node_id`, `storage`, `net_bridge` validated against `^[A-Za-z0-9._-]+$` before URL interpolation +- **H3 — ISO comma injection**: `iso` validated to match `storage:iso/path` format, rejecting commas +- **M4 — Numeric bounds**: `vmid` (100–999 999 999), `memory` (32–1 048 576 MB), `cores` (1–512), `sockets` (1–4), `disk_size` (1–65 536 GB) validated server-side + +### Known / Deferred + +- **C1 — TLS cert verification disabled** (`danger_accept_invalid_certs(true)` in `proxmox/client.rs`): Pre-existing across all Proxmox commands. Needs a separate PR implementing TOFU cert pinning or CA trust. +- **M5 — Missing audit log** for mutating Proxmox commands: Pre-existing. Should be addressed for all Proxmox write operations in a follow-up. + +### Tests + +- `tests/unit/VMList.test.tsx`: 19 tests (all pass) — covers Disk column absent, action menus by status, all power actions, migration dialog open, empty state +- `src-tauri/src/commands/proxmox.rs` (inline): 7 new tests covering `validate_pve_identifier`, VMID range, ISO format validation, ide2/scsi0/net0 string construction +- **Total**: 446 Rust tests, 405 frontend tests — all pass + +## Testing Needed + +- [ ] Connect to a real Proxmox cluster and verify Start/Stop/Reboot/Shutdown/Suspend/Resume all show toast notifications +- [ ] Verify Migrate dialog shows actual cluster nodes (not just nodes inferred from VMs) +- [ ] Create a new VM via the "Add VM" dialog — confirm VM appears in PVE web UI +- [ ] Confirm Disk column is absent from the VM list +- [ ] Confirm delete VM shows a browser confirm dialog (previously silently failing due to missing capability) +- [ ] Test with an ISO to confirm the `storage:iso/filename.iso` path is accepted; test with a comma-injected value to confirm it is rejected with a clear error diff --git a/docs/wiki/IPC-Commands.md b/docs/wiki/IPC-Commands.md index d051a7e8..c280c5e8 100644 --- a/docs/wiki/IPC-Commands.md +++ b/docs/wiki/IPC-Commands.md @@ -629,3 +629,48 @@ CREATE TABLE credentials ( // Backend: src-tauri/src/integrations/auth.rs pub fn decrypt_token(encrypted: &str) -> Result ``` + +--- + +## Proxmox VM Commands + +Handlers: `src-tauri/src/commands/proxmox.rs` — client wrappers: `src/lib/proxmoxClient.ts` + +### `list_proxmox_vms` +```typescript +listProxmoxVms(clusterId: string) → any[] +``` +Lists all QEMU VMs across a cluster via `cluster/resources?type=vm`. + +### `list_proxmox_nodes` +```typescript +listProxmoxNodes(clusterId: string) → Array<{ node: string; status: string; ... }> +``` +Lists cluster nodes via `GET /nodes`. Used by the migration dialog to show real target nodes (not just nodes inferred from the VM list). + +### `create_proxmox_vm` +```typescript +createProxmoxVm(clusterId, { nodeId, vmid, name, memory, cores, sockets, osType, storage, diskSize, netBridge, iso? }) → void +``` +Creates a new QEMU VM on `nodeId` via `POST nodes/{node}/qemu`. Input validation applied server-side: +- `vmid`: 100–999 999 999 +- `memory`: 32–1 048 576 MB +- `cores`: 1–512, `sockets`: 1–4, `diskSize`: 1–65 536 GB +- `nodeId`, `storage`, `netBridge`: DNS-label characters only (prevents URL path injection) +- `iso`: must match `storage:iso/filename` format (prevents comma-property injection) + +### VM Power Commands +```typescript +startProxmoxVm(clusterId, nodeId, vmId) → void // POST .../status/start +stopProxmoxVm(clusterId, nodeId, vmId) → void // POST .../status/stop +rebootProxmoxVm(clusterId, nodeId, vmId) → void // POST .../status/reboot +shutdownProxmoxVm(clusterId, nodeId, vmId) → void // POST .../status/shutdown +suspendProxmoxVm(clusterId, nodeId, vmId) → void // POST .../status/suspend +resumeProxmoxVm(clusterId, nodeId, vmId) → void // POST .../status/resume +``` + +### `migrate_vm` +```typescript +invoke('migrate_vm', { clusterId, nodeId, vmId, targetNode, targetCluster }) → void +``` +Migrates a VM to another node (same or different cluster). Online migration by default. diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 9007d85d..44a28ad1 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -13,6 +13,7 @@ "core:tray:default", "dialog:allow-open", "dialog:allow-save", + "dialog:allow-confirm", "fs:allow-read-text-file", "fs:allow-write-text-file", "fs:allow-mkdir", diff --git a/src-tauri/gen/schemas/capabilities.json b/src-tauri/gen/schemas/capabilities.json index 3fbdaf8e..60ecca96 100644 --- a/src-tauri/gen/schemas/capabilities.json +++ b/src-tauri/gen/schemas/capabilities.json @@ -1 +1 @@ -{"default":{"identifier":"default","description":"Default capabilities for TRCAA — least-privilege","local":true,"windows":["main"],"permissions":["core:path:default","core:event:default","core:window:default","core:app:default","core:resources:default","core:menu:default","core:tray:default","dialog:allow-open","dialog:allow-save","fs:allow-read-text-file","fs:allow-write-text-file","fs:allow-mkdir","fs:allow-app-read-recursive","fs:allow-app-write-recursive","fs:allow-temp-read-recursive","fs:allow-temp-write-recursive","fs:scope-app-recursive","fs:scope-temp-recursive","shell:allow-open","opener:allow-open-url","http:default"]}} \ No newline at end of file +{"default":{"identifier":"default","description":"Default capabilities for TRCAA — least-privilege","local":true,"windows":["main"],"permissions":["core:path:default","core:event:default","core:window:default","core:app:default","core:resources:default","core:menu:default","core:tray:default","dialog:allow-open","dialog:allow-save","dialog:allow-confirm","fs:allow-read-text-file","fs:allow-write-text-file","fs:allow-mkdir","fs:allow-app-read-recursive","fs:allow-app-write-recursive","fs:allow-temp-read-recursive","fs:allow-temp-write-recursive","fs:scope-app-recursive","fs:scope-temp-recursive","shell:allow-open","opener:allow-open-url","http:default"]}} \ No newline at end of file diff --git a/src-tauri/src/commands/proxmox.rs b/src-tauri/src/commands/proxmox.rs index b8b30bba..f8d58ae2 100644 --- a/src-tauri/src/commands/proxmox.rs +++ b/src-tauri/src/commands/proxmox.rs @@ -623,6 +623,137 @@ pub async fn delete_vm( .map_err(|e| format!("Failed to delete VM {}: {}", vm_id, e)) } +/// List Proxmox nodes in a cluster +#[tauri::command] +pub async fn list_proxmox_nodes( + cluster_id: String, + state: State<'_, AppState>, +) -> Result, String> { + let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?; + let client_guard = client.lock().await; + + let response: serde_json::Value = client_guard + .get("nodes", Some(client_guard.ticket.as_deref().unwrap_or(""))) + .await + .map_err(|e| format!("Failed to list nodes: {}", e))?; + + let nodes: Vec = response + .as_array() + .map(|arr| arr.to_vec()) + .unwrap_or_default(); + + Ok(nodes) +} + +/// Validates a PVE node name or network bridge name (DNS-label characters only). +/// Prevents path traversal / URL injection when names are interpolated into REST paths +/// or virtio property strings. +fn validate_pve_identifier(value: &str, field: &str) -> Result<(), String> { + if value.is_empty() { + return Err(format!("{} must not be empty", field)); + } + if !value.chars().all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_') { + return Err(format!( + "{} contains invalid characters — only alphanumeric, '.', '-', '_' are allowed", + field + )); + } + Ok(()) +} + +/// Create a new Proxmox VM +#[allow(clippy::too_many_arguments)] +#[tauri::command] +pub async fn create_proxmox_vm( + cluster_id: String, + node_id: String, + vmid: u32, + name: String, + memory: u32, + cores: u32, + sockets: u32, + os_type: String, + storage: String, + disk_size: u32, + net_bridge: String, + iso: Option, + state: State<'_, AppState>, +) -> Result<(), String> { + // H2: validate path-interpolated identifiers before sending to PVE + validate_pve_identifier(&node_id, "node_id")?; + validate_pve_identifier(&storage, "storage")?; + validate_pve_identifier(&net_bridge, "net_bridge")?; + + // M4: enforce PVE-defined numeric ranges + if !(100..=999_999_999).contains(&vmid) { + return Err("vmid must be between 100 and 999999999".to_string()); + } + if !(32..=1_048_576).contains(&memory) { + return Err("memory must be between 32 MB and 1048576 MB (1 TB)".to_string()); + } + if !(1..=512).contains(&cores) { + return Err("cores must be between 1 and 512".to_string()); + } + if !(1..=4).contains(&sockets) { + return Err("sockets must be between 1 and 4".to_string()); + } + if !(1..=65536).contains(&disk_size) { + return Err("disk_size must be between 1 GB and 65536 GB".to_string()); + } + + // H3: validate ISO volume ID format to prevent property string injection + // Expected: "storage:iso/filename.iso" — no commas, slashes only in the path portion + if let Some(ref iso_val) = iso { + if !iso_val.is_empty() { + let valid_iso = iso_val + .split_once(':') + .map(|(store, path)| { + !store.is_empty() + && !store.contains(|c: char| !c.is_ascii_alphanumeric() && c != '-' && c != '_') + && path.starts_with("iso/") + && !path.contains(",") + }) + .unwrap_or(false); + if !valid_iso { + return Err("iso must be in the format 'storage:iso/filename.iso'".to_string()); + } + } + } + + let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?; + let client_guard = client.lock().await; + + let ide2 = iso + .as_deref() + .filter(|s| !s.is_empty()) + .map(|s| format!("{},media=cdrom", s)) + .unwrap_or_else(|| "none,media=cdrom".to_string()); + + let config = serde_json::json!({ + "vmid": vmid, + "name": name, + "memory": memory, + "cores": cores, + "sockets": sockets, + "ostype": os_type, + "scsihw": "virtio-scsi-pci", + "scsi0": format!("{}:{}", storage, disk_size), + "ide2": ide2, + "net0": format!("virtio,bridge={}", net_bridge), + "boot": "order=scsi0;ide2" + }); + + crate::proxmox::vm::create_vm( + &client_guard, + &node_id, + vmid, + &config, + client_guard.ticket.as_deref().unwrap_or(""), + ) + .await + .map_err(|e| format!("Failed to create VM: {}", e)) +} + /// List Proxmox Backup Jobs (cluster-level, not node-level) #[tauri::command] pub async fn list_proxmox_backup_jobs( @@ -2602,4 +2733,145 @@ mod tests { assert!(msg.starts_with("Connection test failed:")); assert!(msg.contains("connection refused")); } + + #[test] + fn test_list_proxmox_nodes_error_message_format() { + let raw = "HTTP 403"; + let msg = format!("Failed to list nodes: {}", raw); + assert!(msg.starts_with("Failed to list nodes:")); + assert!(msg.contains("403")); + } + + #[test] + fn test_create_proxmox_vm_ide2_with_iso() { + let iso = Some("local:iso/ubuntu.iso".to_string()); + let ide2 = iso + .as_deref() + .filter(|s| !s.is_empty()) + .map(|s| format!("{},media=cdrom", s)) + .unwrap_or_else(|| "none,media=cdrom".to_string()); + assert_eq!(ide2, "local:iso/ubuntu.iso,media=cdrom"); + } + + #[test] + fn test_create_proxmox_vm_ide2_without_iso() { + let iso: Option = None; + let ide2 = iso + .as_deref() + .filter(|s| !s.is_empty()) + .map(|s| format!("{},media=cdrom", s)) + .unwrap_or_else(|| "none,media=cdrom".to_string()); + assert_eq!(ide2, "none,media=cdrom"); + } + + #[test] + fn test_create_proxmox_vm_ide2_empty_string_iso() { + let iso = Some("".to_string()); + let ide2 = iso + .as_deref() + .filter(|s| !s.is_empty()) + .map(|s| format!("{},media=cdrom", s)) + .unwrap_or_else(|| "none,media=cdrom".to_string()); + assert_eq!(ide2, "none,media=cdrom"); + } + + #[test] + fn test_create_proxmox_vm_scsi0_format() { + let storage = "local-lvm"; + let disk_size: u32 = 32; + let scsi0 = format!("{}:{}", storage, disk_size); + assert_eq!(scsi0, "local-lvm:32"); + } + + #[test] + fn test_create_proxmox_vm_net0_format() { + let bridge = "vmbr0"; + let net0 = format!("virtio,bridge={}", bridge); + assert_eq!(net0, "virtio,bridge=vmbr0"); + } + + #[test] + fn test_create_proxmox_vm_error_message_format() { + let vmid: u32 = 105; + let raw = "storage not found"; + let msg = format!("Failed to create VM: Failed to create VM {}: {}", vmid, raw); + assert!(msg.contains("Failed to create VM")); + assert!(msg.contains("105")); + } + + #[test] + fn test_validate_pve_identifier_valid() { + assert!(super::validate_pve_identifier("pve-node1", "node_id").is_ok()); + assert!(super::validate_pve_identifier("vmbr0", "net_bridge").is_ok()); + assert!(super::validate_pve_identifier("local-lvm", "storage").is_ok()); + assert!(super::validate_pve_identifier("node.example", "node_id").is_ok()); + } + + #[test] + fn test_validate_pve_identifier_rejects_path_traversal() { + assert!(super::validate_pve_identifier("../../access/users", "node_id").is_err()); + assert!(super::validate_pve_identifier("node/sub", "node_id").is_err()); + assert!(super::validate_pve_identifier("node?query=1", "node_id").is_err()); + assert!(super::validate_pve_identifier("node#anchor", "node_id").is_err()); + } + + #[test] + fn test_validate_pve_identifier_rejects_empty() { + assert!(super::validate_pve_identifier("", "node_id").is_err()); + } + + #[test] + fn test_create_vm_vmid_range_validation() { + let valid: u32 = 100; + assert!((100..=999_999_999).contains(&valid)); + let too_low: u32 = 99; + assert!(!(100..=999_999_999).contains(&too_low)); + let too_high: u32 = 1_000_000_000; + assert!(!(100..=999_999_999).contains(&too_high)); + } + + #[test] + fn test_iso_format_valid() { + let iso = "local:iso/ubuntu-24.04.iso"; + let valid = iso + .split_once(':') + .map(|(store, path)| { + !store.is_empty() + && !store.contains(|c: char| !c.is_ascii_alphanumeric() && c != '-' && c != '_') + && path.starts_with("iso/") + && !path.contains(',') + }) + .unwrap_or(false); + assert!(valid, "should accept valid iso path"); + } + + #[test] + fn test_iso_format_rejects_comma_injection() { + let iso = "local:iso/x.iso,media=cdrom,backup=0"; + let valid = iso + .split_once(':') + .map(|(store, path)| { + !store.is_empty() + && !store.contains(|c: char| !c.is_ascii_alphanumeric() && c != '-' && c != '_') + && path.starts_with("iso/") + && !path.contains(',') + }) + .unwrap_or(false); + assert!(!valid, "should reject comma injection in iso path"); + } + + #[test] + fn test_iso_format_rejects_missing_colon() { + let iso = "local-iso-no-colon"; + let valid = iso + .split_once(':') + .map(|(store, path)| { + !store.is_empty() + && !store.contains(|c: char| !c.is_ascii_alphanumeric() && c != '-' && c != '_') + && path.starts_with("iso/") + && !path.contains(',') + }) + .unwrap_or(false); + assert!(!valid, "should reject iso without storage: prefix"); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5289a04d..f3abcee6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -242,6 +242,8 @@ pub fn run() { commands::proxmox::suspend_proxmox_vm, commands::proxmox::clone_vm, commands::proxmox::delete_vm, + commands::proxmox::list_proxmox_nodes, + commands::proxmox::create_proxmox_vm, commands::proxmox::list_proxmox_backup_jobs, commands::proxmox::list_proxmox_datastores, commands::proxmox::trigger_proxmox_backup_job, diff --git a/src/App.tsx b/src/App.tsx index 5528780f..9ce74f64 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -21,6 +21,7 @@ import { Server as ServerIcon, Settings, } from "lucide-react"; +import { Toaster } from "sonner"; import { useSettingsStore } from "@/stores/settingsStore"; import { getAppVersionCmd, loadAiProvidersCmd, testProviderConnectionCmd, shutdownPortForwardsCmd } from "@/lib/tauriCommands"; @@ -337,6 +338,7 @@ export default function App() { + ); } diff --git a/src/components/Proxmox/CreateVmDialog.tsx b/src/components/Proxmox/CreateVmDialog.tsx new file mode 100644 index 00000000..f8c262fd --- /dev/null +++ b/src/components/Proxmox/CreateVmDialog.tsx @@ -0,0 +1,278 @@ +import React, { useState, useEffect } from 'react'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/index'; +import { Button } from '@/components/ui/index'; +import { Input } from '@/components/ui/index'; +import { Label } from '@/components/ui/index'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/index'; +import { listProxmoxNodes, listProxmoxDatastores, createProxmoxVm } from '@/lib/proxmoxClient'; +import { toast } from 'sonner'; + +interface CreateVmDialogProps { + isOpen: boolean; + clusterId: string; + onClose: () => void; + onCreated: () => void; +} + +const OS_TYPES = [ + { value: 'l26', label: 'Linux 2.6+' }, + { value: 'l24', label: 'Linux 2.4' }, + { value: 'win11', label: 'Windows 11' }, + { value: 'win10', label: 'Windows 10/2016/2019' }, + { value: 'win8', label: 'Windows 8/2012' }, + { value: 'win7', label: 'Windows 7/2008' }, + { value: 'other', label: 'Other' }, +]; + +export function CreateVmDialog({ isOpen, clusterId, onClose, onCreated }: CreateVmDialogProps) { + const [nodes, setNodes] = useState([]); + const [storages, setStorages] = useState([]); + const [isSubmitting, setIsSubmitting] = useState(false); + + const [nodeId, setNodeId] = useState(''); + const [vmid, setVmid] = useState(100); + const [name, setName] = useState(''); + const [memory, setMemory] = useState(2048); + const [cores, setCores] = useState(2); + const [sockets, setSockets] = useState(1); + const [osType, setOsType] = useState('l26'); + const [storage, setStorage] = useState(''); + const [diskSize, setDiskSize] = useState(20); + const [netBridge, setNetBridge] = useState('vmbr0'); + const [iso, setIso] = useState(''); + + useEffect(() => { + if (!isOpen || !clusterId) return; + + listProxmoxNodes(clusterId) + .then((data) => { + const nodeNames = (data as Array<{ node?: string; status?: string }>) + .filter((n) => n.status === 'online' || n.node) + .map((n) => n.node ?? '') + .filter(Boolean); + setNodes(nodeNames); + setNodeId(nodeNames[0] ?? ''); + }) + .catch(() => toast.error('Failed to load cluster nodes')); + + listProxmoxDatastores(clusterId) + .then((data) => { + const storageIds = (data as Array<{ storage?: string }>) + .map((s) => s.storage ?? '') + .filter(Boolean); + setStorages(storageIds); + setStorage(storageIds[0] ?? 'local-lvm'); + }) + .catch(() => { + setStorages(['local-lvm', 'local']); + setStorage('local-lvm'); + }); + }, [isOpen, clusterId]); + + const handleSubmit = async () => { + if (!nodeId) { toast.error('Please select a target node'); return; } + if (!name.trim()) { toast.error('VM name is required'); return; } + if (vmid < 100 || vmid > 999999999) { toast.error('VMID must be between 100 and 999999999'); return; } + + setIsSubmitting(true); + try { + await createProxmoxVm(clusterId, { + nodeId, + vmid, + name: name.trim(), + memory, + cores, + sockets, + osType, + storage, + diskSize, + netBridge, + iso: iso.trim() || undefined, + }); + toast.success(`VM "${name}" created successfully (VMID: ${vmid})`); + onCreated(); + handleClose(); + } catch (err) { + toast.error(`Failed to create VM: ${err}`); + } finally { + setIsSubmitting(false); + } + }; + + const handleClose = () => { + setName(''); + setVmid(100); + setMemory(2048); + setCores(2); + setSockets(1); + setOsType('l26'); + setDiskSize(20); + setNetBridge('vmbr0'); + setIso(''); + onClose(); + }; + + return ( + + + + Create Virtual Machine + + +
+
+
+ + +
+
+ + setVmid(Number(e.target.value))} + /> +
+
+ +
+ + setName(e.target.value)} + placeholder="my-vm" + /> +
+ +
+ + +
+ +
+
+ + setMemory(Number(e.target.value))} + /> +
+
+ + setCores(Number(e.target.value))} + /> +
+
+ + setSockets(Number(e.target.value))} + /> +
+
+ +
+
+ + {storages.length > 0 ? ( + + ) : ( + setStorage(e.target.value)} + placeholder="local-lvm" + /> + )} +
+
+ + setDiskSize(Number(e.target.value))} + /> +
+
+ +
+ + setNetBridge(e.target.value)} + placeholder="vmbr0" + /> +
+ +
+ + setIso(e.target.value)} + placeholder="local:iso/ubuntu-24.04.iso" + /> +

Format: storage:iso/filename.iso

+
+
+ + + + + +
+
+ ); +} diff --git a/src/components/Proxmox/VMList.tsx b/src/components/Proxmox/VMList.tsx index 9665b7b4..36b3ff9d 100644 --- a/src/components/Proxmox/VMList.tsx +++ b/src/components/Proxmox/VMList.tsx @@ -26,8 +26,6 @@ interface VMInfo { cpu: number; memory: number; memoryTotal: number; - disk: number; - diskTotal: number; uptime?: number; tags?: string[]; } @@ -105,6 +103,7 @@ export function VMList({ const [targetCluster, setTargetCluster] = useState(''); const [onlineMigration, setOnlineMigration] = useState(true); const [maxDowntime, setMaxDowntime] = useState(30); + const [clusterNodes, setClusterNodes] = useState([]); const vms: VMInfo[] = React.useMemo(() => { return rawVms.map((vm) => ({ @@ -116,8 +115,6 @@ export function VMList({ 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, })); @@ -199,14 +196,24 @@ export function VMList({ toast.info(`Snapshot ${action} for ${vm.name} - not yet implemented`); }, []); - const handleMigrate = useCallback((vm: VMInfo) => { + const handleMigrate = useCallback(async (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]); + try { + const nodeData: { node?: string; status?: string }[] = await invoke('list_proxmox_nodes', { clusterId }); + const names = nodeData + .filter((n) => n.node && n.node !== vm.node) + .map((n) => n.node as string); + setClusterNodes(names); + setTargetNode(names[0] || ''); + } catch { + const fallback = vms + .map((v) => v.node) + .filter((node, idx, self) => self.indexOf(node) === idx && node !== vm.node); + setClusterNodes(fallback); + setTargetNode(fallback[0] || ''); + } + }, [clusterId, vms]); const submitMigration = useCallback(async () => { if (!migrationVM || !targetNode) { @@ -338,7 +345,6 @@ export function VMList({ Status CPU Memory - Disk Uptime Actions @@ -347,7 +353,6 @@ export function VMList({ {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 ( @@ -387,23 +392,6 @@ export function VMList({ - )} - - {vm.diskTotal > 0 ? ( -
-
-
-
- - {formatBytes(vm.disk)} / {formatBytes(vm.diskTotal)} - -
- ) : ( - - - )} - {formatUptime(vm.uptime || 0)} { setMigrationVM(null); setTargetNode(''); setTargetCluster(''); }} + onClose={() => { setMigrationVM(null); setTargetNode(''); setTargetCluster(''); setClusterNodes([]); }} onSubmit={submitMigration} - availableNodes={vms} + availableNodeNames={clusterNodes} clusters={clusters} currentClusterId={clusterId} targetNode={targetNode} @@ -618,7 +606,7 @@ interface MigrationDialogProps { isOpen: boolean; onClose: () => void; onSubmit: () => void; - availableNodes: VMInfo[]; + availableNodeNames: string[]; clusters: ClusterInfo[]; currentClusterId: string; targetNode: string; @@ -636,7 +624,7 @@ function MigrationDialog({ isOpen, onClose, onSubmit, - availableNodes, + availableNodeNames, clusters, currentClusterId, targetNode, @@ -652,14 +640,10 @@ function MigrationDialog({ 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 availableNodeNames.length > 0; }; return ( @@ -720,14 +704,14 @@ function MigrationDialog({ - {availableTargets.map((node) => ( + {availableNodeNames.map((node) => ( {node} ))} - {availableTargets.length === 0 && ( + {availableNodeNames.length === 0 && (

No other nodes available in this cluster

diff --git a/src/components/Proxmox/index.ts b/src/components/Proxmox/index.ts index 1cfa898f..815b13ce 100644 --- a/src/components/Proxmox/index.ts +++ b/src/components/Proxmox/index.ts @@ -35,3 +35,4 @@ export { ContainerOverview } from './ContainerOverview'; export { AclList } from './AclList'; export { VMConsole } from './VMConsole'; export { ContainerConsole } from './ContainerConsole'; +export { CreateVmDialog } from './CreateVmDialog'; diff --git a/src/lib/proxmoxClient.ts b/src/lib/proxmoxClient.ts index 911e5788..f5942194 100644 --- a/src/lib/proxmoxClient.ts +++ b/src/lib/proxmoxClient.ts @@ -188,6 +188,60 @@ export async function shutdownProxmoxVm( await invoke("shutdown_proxmox_vm", { clusterId, nodeId, vmId }); } +export async function suspendProxmoxVm( + clusterId: string, + nodeId: string, + vmId: number +): Promise { + await invoke("suspend_proxmox_vm", { clusterId, nodeId, vmId }); +} + +export async function resumeProxmoxVm( + clusterId: string, + nodeId: string, + vmId: number +): Promise { + await invoke("resume_proxmox_vm", { clusterId, nodeId, vmId }); +} + +export async function listProxmoxNodes(clusterId: string): Promise { + return await invoke("list_proxmox_nodes", { clusterId }); +} + +export interface CreateVmParams { + nodeId: string; + vmid: number; + name: string; + memory: number; + cores: number; + sockets: number; + osType: string; + storage: string; + diskSize: number; + netBridge: string; + iso?: string; +} + +export async function createProxmoxVm( + clusterId: string, + params: CreateVmParams +): Promise { + await invoke("create_proxmox_vm", { + clusterId, + nodeId: params.nodeId, + vmid: params.vmid, + name: params.name, + memory: params.memory, + cores: params.cores, + sockets: params.sockets, + osType: params.osType, + storage: params.storage, + diskSize: params.diskSize, + netBridge: params.netBridge, + iso: params.iso ?? null, + }); +} + /** * List Proxmox Backup Jobs * @param clusterId - Cluster identifier diff --git a/src/pages/Proxmox/VMsPage.tsx b/src/pages/Proxmox/VMsPage.tsx index bb1a262c..fe97119d 100644 --- a/src/pages/Proxmox/VMsPage.tsx +++ b/src/pages/Proxmox/VMsPage.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useCallback } from 'react'; import { Button } from '@/components/ui/index'; -import { RefreshCw } from 'lucide-react'; -import { VMList } from '@/components/Proxmox'; +import { RefreshCw, Plus } from 'lucide-react'; +import { VMList, CreateVmDialog } from '@/components/Proxmox'; import { listProxmoxClusters, listProxmoxVms } from '@/lib/proxmoxClient'; import type { ClusterInfo } from '@/lib/domain'; import { toast } from 'sonner'; @@ -13,6 +13,7 @@ export function ProxmoxVMsPage() { const [vms, setVms] = useState([]); const [isLoading, setIsLoading] = useState(false); const [selectedVMs, setSelectedVMs] = useState>(new Set()); + const [showCreateDialog, setShowCreateDialog] = useState(false); useEffect(() => { listProxmoxClusters() @@ -82,6 +83,10 @@ export function ProxmoxVMsPage() { Refresh +
@@ -100,6 +105,13 @@ export function ProxmoxVMsPage() { }); }} /> + + setShowCreateDialog(false)} + onCreated={() => loadVms(selectedClusterId)} + /> ); } diff --git a/tests/unit/VMList.test.tsx b/tests/unit/VMList.test.tsx new file mode 100644 index 00000000..036b000b --- /dev/null +++ b/tests/unit/VMList.test.tsx @@ -0,0 +1,313 @@ +import React from "react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { invoke } from "@tauri-apps/api/core"; +import { VMList } from "@/components/Proxmox/VMList"; + +vi.mock("@tauri-apps/api/core"); +vi.mock("sonner", () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + }, + Toaster: () => null, +})); + +type MockedInvoke = typeof invoke & { + mockResolvedValue: (v: unknown) => void; + mockRejectedValue: (e: unknown) => void; + mockResolvedValueOnce: (v: unknown) => void; +}; + +const mockInvoke = invoke as MockedInvoke; + +const stoppedVm = { + id: 101, + name: "nginx", + node: "vmhost2", + status: "stopped", + cpu: 0, + mem: 0, + max_mem: 2 * 1024 * 1024 * 1024, + disk: 0, + max_disk: 20 * 1024 * 1024 * 1024, + uptime: 0, +}; + +const runningVm = { + id: 102, + name: "docker-01", + node: "vmhost2", + status: "running", + cpu: 0.35, + mem: 512 * 1024 * 1024, + max_mem: 2 * 1024 * 1024 * 1024, + disk: 0, + max_disk: 20 * 1024 * 1024 * 1024, + uptime: 3600, +}; + +const pausedVm = { + id: 103, + name: "test-vm", + node: "vmhost2", + status: "paused", + cpu: 0, + mem: 0, + max_mem: 1024 * 1024 * 1024, + disk: 0, + max_disk: 10 * 1024 * 1024 * 1024, + uptime: 0, +}; + +const mockClusters = [{ id: "cluster-1", name: "TFTSR" }]; + +function renderVMList(vms = [stoppedVm], clusterId = "cluster-1") { + const onRefresh = vi.fn(); + return { + onRefresh, + ...render( + + ), + }; +} + +describe("VMList — column rendering", () => { + it("renders VM name, VMID, node, status, CPU, memory and uptime columns", () => { + renderVMList([stoppedVm]); + expect(screen.getByText("Name")).toBeDefined(); + expect(screen.getByText("VM ID")).toBeDefined(); + expect(screen.getByText("Node")).toBeDefined(); + expect(screen.getByText("Status")).toBeDefined(); + expect(screen.getByText("CPU")).toBeDefined(); + expect(screen.getByText("Memory")).toBeDefined(); + expect(screen.getByText("Uptime")).toBeDefined(); + }); + + it("does NOT render the Disk column", () => { + renderVMList([stoppedVm]); + expect(screen.queryByText("Disk")).toBeNull(); + }); + + it("displays VM name in the list", () => { + renderVMList([stoppedVm]); + expect(screen.getByText("nginx")).toBeDefined(); + }); + + it("displays the correct VMID", () => { + renderVMList([stoppedVm]); + expect(screen.getByText("101")).toBeDefined(); + }); + + it("displays status badge for stopped VM", () => { + renderVMList([stoppedVm]); + expect(screen.getByText("stopped")).toBeDefined(); + }); + + it("displays status badge for running VM", () => { + renderVMList([runningVm]); + expect(screen.getByText("running")).toBeDefined(); + }); +}); + +describe("VMList — action menu for stopped VM", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockInvoke.mockResolvedValue(undefined); + }); + + it("shows Start action for stopped VM", async () => { + renderVMList([stoppedVm]); + const menuBtn = screen.getAllByRole("button").find( + (b) => b.querySelector("svg") + ); + fireEvent.click(menuBtn!); + expect(screen.getByText("Start")).toBeDefined(); + }); + + it("does NOT show Stop/Reboot/Shutdown for stopped VM", async () => { + renderVMList([stoppedVm]); + const menuBtn = screen.getAllByRole("button").find( + (b) => b.querySelector("svg") + ); + fireEvent.click(menuBtn!); + expect(screen.queryByText("Stop")).toBeNull(); + expect(screen.queryByText("Reboot")).toBeNull(); + expect(screen.queryByText("Shutdown")).toBeNull(); + }); + + it("calls start_proxmox_vm when Start is clicked", async () => { + renderVMList([stoppedVm]); + const menuBtn = screen.getAllByRole("button").find( + (b) => b.querySelector("svg") + ); + fireEvent.click(menuBtn!); + fireEvent.click(screen.getByText("Start")); + await waitFor(() => { + expect(mockInvoke).toHaveBeenCalledWith("start_proxmox_vm", { + clusterId: "cluster-1", + nodeId: "vmhost2", + vmId: 101, + }); + }); + }); +}); + +describe("VMList — action menu for running VM", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockInvoke.mockResolvedValue(undefined); + }); + + it("shows Stop, Reboot, Shutdown, Suspend actions for running VM", async () => { + renderVMList([runningVm]); + const menuBtn = screen.getAllByRole("button").find( + (b) => b.querySelector("svg") + ); + fireEvent.click(menuBtn!); + expect(screen.getByText("Stop")).toBeDefined(); + expect(screen.getByText("Reboot")).toBeDefined(); + expect(screen.getByText("Shutdown")).toBeDefined(); + expect(screen.getByText("Suspend")).toBeDefined(); + }); + + it("calls stop_proxmox_vm when Stop is clicked", async () => { + renderVMList([runningVm]); + const menuBtn = screen.getAllByRole("button").find( + (b) => b.querySelector("svg") + ); + fireEvent.click(menuBtn!); + fireEvent.click(screen.getByText("Stop")); + await waitFor(() => { + expect(mockInvoke).toHaveBeenCalledWith("stop_proxmox_vm", { + clusterId: "cluster-1", + nodeId: "vmhost2", + vmId: 102, + }); + }); + }); + + it("calls reboot_proxmox_vm when Reboot is clicked", async () => { + renderVMList([runningVm]); + const menuBtn = screen.getAllByRole("button").find( + (b) => b.querySelector("svg") + ); + fireEvent.click(menuBtn!); + fireEvent.click(screen.getByText("Reboot")); + await waitFor(() => { + expect(mockInvoke).toHaveBeenCalledWith("reboot_proxmox_vm", { + clusterId: "cluster-1", + nodeId: "vmhost2", + vmId: 102, + }); + }); + }); + + it("calls shutdown_proxmox_vm when Shutdown is clicked", async () => { + renderVMList([runningVm]); + const menuBtn = screen.getAllByRole("button").find( + (b) => b.querySelector("svg") + ); + fireEvent.click(menuBtn!); + fireEvent.click(screen.getByText("Shutdown")); + await waitFor(() => { + expect(mockInvoke).toHaveBeenCalledWith("shutdown_proxmox_vm", { + clusterId: "cluster-1", + nodeId: "vmhost2", + vmId: 102, + }); + }); + }); + + it("calls suspend_proxmox_vm when Suspend is clicked", async () => { + renderVMList([runningVm]); + const menuBtn = screen.getAllByRole("button").find( + (b) => b.querySelector("svg") + ); + fireEvent.click(menuBtn!); + fireEvent.click(screen.getByText("Suspend")); + await waitFor(() => { + expect(mockInvoke).toHaveBeenCalledWith("suspend_proxmox_vm", { + clusterId: "cluster-1", + nodeId: "vmhost2", + vmId: 102, + }); + }); + }); +}); + +describe("VMList — action menu for paused VM", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockInvoke.mockResolvedValue(undefined); + }); + + it("shows Resume action for paused VM", async () => { + renderVMList([pausedVm]); + const menuBtn = screen.getAllByRole("button").find( + (b) => b.querySelector("svg") + ); + fireEvent.click(menuBtn!); + expect(screen.getByText("Resume")).toBeDefined(); + }); + + it("calls resume_proxmox_vm when Resume is clicked", async () => { + renderVMList([pausedVm]); + const menuBtn = screen.getAllByRole("button").find( + (b) => b.querySelector("svg") + ); + fireEvent.click(menuBtn!); + fireEvent.click(screen.getByText("Resume")); + await waitFor(() => { + expect(mockInvoke).toHaveBeenCalledWith("resume_proxmox_vm", { + clusterId: "cluster-1", + nodeId: "vmhost2", + vmId: 103, + }); + }); + }); +}); + +describe("VMList — migrate action", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockInvoke.mockResolvedValue([ + { node: "vmhost1", status: "online" }, + { node: "vmhost3", status: "online" }, + ]); + }); + + it("shows Migrate option in action menu", async () => { + renderVMList([runningVm]); + const menuBtn = screen.getAllByRole("button").find( + (b) => b.querySelector("svg") + ); + fireEvent.click(menuBtn!); + expect(screen.getByText("Migrate")).toBeDefined(); + }); + + it("opens migration dialog when Migrate is clicked", async () => { + renderVMList([runningVm]); + const menuBtn = screen.getAllByRole("button").find( + (b) => b.querySelector("svg") + ); + fireEvent.click(menuBtn!); + fireEvent.click(screen.getByText("Migrate")); + await waitFor(() => { + expect(screen.getByText(/Migrate docker-01/i)).toBeDefined(); + }); + }); +}); + +describe("VMList — empty state", () => { + it("renders empty table body with no VMs", () => { + renderVMList([]); + expect(screen.getByText("Virtual Machines")).toBeDefined(); + }); +});