fix(proxmox): fix VM actions, remove Disk column, add Create VM #130

Merged
sarman merged 4 commits from fix/proxmox-vm-actions-v3 into beta 2026-06-21 23:45:17 +00:00
13 changed files with 1114 additions and 44 deletions

View File

@ -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 `<Toaster>` 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 `<Toaster richColors position="top-right" />` — 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` (100999 999 999), `memory` (321 048 576 MB), `cores` (1512), `sockets` (14), `disk_size` (165 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

View File

@ -629,3 +629,48 @@ CREATE TABLE credentials (
// Backend: src-tauri/src/integrations/auth.rs // Backend: src-tauri/src/integrations/auth.rs
pub fn decrypt_token(encrypted: &str) -> Result<String, String> pub fn decrypt_token(encrypted: &str) -> Result<String, String>
``` ```
---
## 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`: 100999 999 999
- `memory`: 321 048 576 MB
- `cores`: 1512, `sockets`: 14, `diskSize`: 165 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.

View File

@ -13,6 +13,7 @@
"core:tray:default", "core:tray:default",
"dialog:allow-open", "dialog:allow-open",
"dialog:allow-save", "dialog:allow-save",
"dialog:allow-confirm",
"fs:allow-read-text-file", "fs:allow-read-text-file",
"fs:allow-write-text-file", "fs:allow-write-text-file",
"fs:allow-mkdir", "fs:allow-mkdir",

View File

@ -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"]}} {"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"]}}

View File

@ -623,6 +623,141 @@ pub async fn delete_vm(
.map_err(|e| format!("Failed to delete VM {}: {}", vm_id, e)) .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<Vec<serde_json::Value>, 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<serde_json::Value> = 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<String>,
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) /// List Proxmox Backup Jobs (cluster-level, not node-level)
#[tauri::command] #[tauri::command]
pub async fn list_proxmox_backup_jobs( pub async fn list_proxmox_backup_jobs(
@ -2602,4 +2737,145 @@ mod tests {
assert!(msg.starts_with("Connection test failed:")); assert!(msg.starts_with("Connection test failed:"));
assert!(msg.contains("connection refused")); 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<String> = 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");
}
} }

View File

@ -242,6 +242,8 @@ pub fn run() {
commands::proxmox::suspend_proxmox_vm, commands::proxmox::suspend_proxmox_vm,
commands::proxmox::clone_vm, commands::proxmox::clone_vm,
commands::proxmox::delete_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_backup_jobs,
commands::proxmox::list_proxmox_datastores, commands::proxmox::list_proxmox_datastores,
commands::proxmox::trigger_proxmox_backup_job, commands::proxmox::trigger_proxmox_backup_job,

View File

@ -21,6 +21,7 @@ import {
Server as ServerIcon, Server as ServerIcon,
Settings, Settings,
} from "lucide-react"; } from "lucide-react";
import { Toaster } from "sonner";
import { useSettingsStore } from "@/stores/settingsStore"; import { useSettingsStore } from "@/stores/settingsStore";
import { getAppVersionCmd, loadAiProvidersCmd, testProviderConnectionCmd, shutdownPortForwardsCmd } from "@/lib/tauriCommands"; import { getAppVersionCmd, loadAiProvidersCmd, testProviderConnectionCmd, shutdownPortForwardsCmd } from "@/lib/tauriCommands";
@ -337,6 +338,7 @@ export default function App() {
</Routes> </Routes>
</main> </main>
</div> </div>
<Toaster richColors position="top-right" />
</> </>
); );
} }

View File

@ -0,0 +1,298 @@
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<string[]>([]);
const [storages, setStorages] = useState<string[]>([]);
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('');
const [isoError, setIsoError] = 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 ISO_RE = /^[a-zA-Z0-9_-]+:iso\/[^,]+$/;
const validateIso = (value: string): string => {
if (!value) return '';
return ISO_RE.test(value) ? '' : "Must be in the format 'storage:iso/filename'";
};
const handleIsoChange = (value: string) => {
setIso(value);
setIsoError(validateIso(value));
};
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; }
if (isoError) { toast.error(isoError); 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('');
setIsoError('');
onClose();
};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Create Virtual Machine</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<Label htmlFor="vm-node">Node</Label>
<Select value={nodeId} onValueChange={setNodeId}>
<SelectTrigger>
<SelectValue placeholder="Select node" />
</SelectTrigger>
<SelectContent>
{nodes.map((n) => (
<SelectItem key={n} value={n}>{n}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label htmlFor="vm-vmid">VM ID</Label>
<Input
id="vm-vmid"
type="number"
min={100}
max={999999999}
value={vmid}
onChange={(e) => setVmid(Number(e.target.value))}
/>
</div>
</div>
<div className="space-y-1">
<Label htmlFor="vm-name">Name</Label>
<Input
id="vm-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="my-vm"
/>
</div>
<div className="space-y-1">
<Label htmlFor="vm-ostype">OS Type</Label>
<Select value={osType} onValueChange={setOsType}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{OS_TYPES.map((o) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-1">
<Label htmlFor="vm-memory">Memory (MB)</Label>
<Input
id="vm-memory"
type="number"
min={256}
step={256}
value={memory}
onChange={(e) => setMemory(Number(e.target.value))}
/>
</div>
<div className="space-y-1">
<Label htmlFor="vm-cores">Cores</Label>
<Input
id="vm-cores"
type="number"
min={1}
max={512}
value={cores}
onChange={(e) => setCores(Number(e.target.value))}
/>
</div>
<div className="space-y-1">
<Label htmlFor="vm-sockets">Sockets</Label>
<Input
id="vm-sockets"
type="number"
min={1}
max={4}
value={sockets}
onChange={(e) => setSockets(Number(e.target.value))}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<Label htmlFor="vm-storage">Storage</Label>
{storages.length > 0 ? (
<Select value={storage} onValueChange={setStorage}>
<SelectTrigger>
<SelectValue placeholder="Select storage" />
</SelectTrigger>
<SelectContent>
{storages.map((s) => (
<SelectItem key={s} value={s}>{s}</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
id="vm-storage"
value={storage}
onChange={(e) => setStorage(e.target.value)}
placeholder="local-lvm"
/>
)}
</div>
<div className="space-y-1">
<Label htmlFor="vm-disksize">Disk Size (GB)</Label>
<Input
id="vm-disksize"
type="number"
min={1}
value={diskSize}
onChange={(e) => setDiskSize(Number(e.target.value))}
/>
</div>
</div>
<div className="space-y-1">
<Label htmlFor="vm-bridge">Network Bridge</Label>
<Input
id="vm-bridge"
value={netBridge}
onChange={(e) => setNetBridge(e.target.value)}
placeholder="vmbr0"
/>
</div>
<div className="space-y-1">
<Label htmlFor="vm-iso">ISO Image (optional)</Label>
<Input
id="vm-iso"
value={iso}
onChange={(e) => handleIsoChange(e.target.value)}
placeholder="local:iso/ubuntu-24.04.iso"
className={isoError ? 'border-red-500' : ''}
/>
{isoError ? (
<p className="text-xs text-red-500">{isoError}</p>
) : (
<p className="text-xs text-muted-foreground">Format: storage:iso/filename</p>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={handleClose} disabled={isSubmitting}>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={isSubmitting || !nodeId || !name.trim() || !!isoError}>
{isSubmitting ? 'Creating...' : 'Create VM'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -26,8 +26,6 @@ interface VMInfo {
cpu: number; cpu: number;
memory: number; memory: number;
memoryTotal: number; memoryTotal: number;
disk: number;
diskTotal: number;
uptime?: number; uptime?: number;
tags?: string[]; tags?: string[];
} }
@ -105,6 +103,8 @@ export function VMList({
const [targetCluster, setTargetCluster] = useState<string>(''); const [targetCluster, setTargetCluster] = useState<string>('');
const [onlineMigration, setOnlineMigration] = useState(true); const [onlineMigration, setOnlineMigration] = useState(true);
const [maxDowntime, setMaxDowntime] = useState(30); const [maxDowntime, setMaxDowntime] = useState(30);
const [clusterNodes, setClusterNodes] = useState<string[]>([]);
const [nodesLoading, setNodesLoading] = useState(false);
const vms: VMInfo[] = React.useMemo(() => { const vms: VMInfo[] = React.useMemo(() => {
return rawVms.map((vm) => ({ return rawVms.map((vm) => ({
@ -116,8 +116,6 @@ export function VMList({
cpu: vm.cpu || 0, cpu: vm.cpu || 0,
memory: vm.mem || vm.memory || 0, memory: vm.mem || vm.memory || 0,
memoryTotal: vm.max_mem || vm.memoryTotal || 0, memoryTotal: vm.max_mem || vm.memoryTotal || 0,
disk: vm.disk || 0,
diskTotal: vm.max_disk || vm.diskTotal || 0,
uptime: vm.uptime, uptime: vm.uptime,
tags: vm.tags, tags: vm.tags,
})); }));
@ -199,14 +197,27 @@ export function VMList({
toast.info(`Snapshot ${action} for ${vm.name} - not yet implemented`); toast.info(`Snapshot ${action} for ${vm.name} - not yet implemented`);
}, []); }, []);
const handleMigrate = useCallback((vm: VMInfo) => { const handleMigrate = useCallback(async (vm: VMInfo) => {
setMigrationVM(vm); 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); setTargetCluster(clusterId);
}, [vms, clusterId]); setNodesLoading(true);
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] || '');
} finally {
setNodesLoading(false);
}
}, [clusterId, vms]);
const submitMigration = useCallback(async () => { const submitMigration = useCallback(async () => {
if (!migrationVM || !targetNode) { if (!migrationVM || !targetNode) {
@ -338,7 +349,6 @@ export function VMList({
<TableHead>Status</TableHead> <TableHead>Status</TableHead>
<TableHead>CPU</TableHead> <TableHead>CPU</TableHead>
<TableHead>Memory</TableHead> <TableHead>Memory</TableHead>
<TableHead>Disk</TableHead>
<TableHead>Uptime</TableHead> <TableHead>Uptime</TableHead>
<TableHead className="text-right">Actions</TableHead> <TableHead className="text-right">Actions</TableHead>
</TableRow> </TableRow>
@ -347,7 +357,6 @@ export function VMList({
{vms.map((vm) => { {vms.map((vm) => {
const cpuPercent = vm.cpu > 0 ? Math.min(vm.cpu * 100, 100) : 0; const cpuPercent = vm.cpu > 0 ? Math.min(vm.cpu * 100, 100) : 0;
const memoryPercent = vm.memoryTotal > 0 ? (vm.memory / vm.memoryTotal) * 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 ( return (
<TableRow key={vm.id}> <TableRow key={vm.id}>
@ -387,23 +396,6 @@ export function VMList({
<span className="text-muted-foreground">-</span> <span className="text-muted-foreground">-</span>
)} )}
</TableCell> </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>{formatUptime(vm.uptime || 0)}</TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
<VMActionMenu <VMActionMenu
@ -426,9 +418,10 @@ export function VMList({
<MigrationDialog <MigrationDialog
vm={migrationVM} vm={migrationVM}
isOpen={!!migrationVM} isOpen={!!migrationVM}
onClose={() => { setMigrationVM(null); setTargetNode(''); setTargetCluster(''); }} onClose={() => { setMigrationVM(null); setTargetNode(''); setTargetCluster(''); setClusterNodes([]); }}
onSubmit={submitMigration} onSubmit={submitMigration}
availableNodes={vms} availableNodeNames={clusterNodes}
nodesLoading={nodesLoading}
clusters={clusters} clusters={clusters}
currentClusterId={clusterId} currentClusterId={clusterId}
targetNode={targetNode} targetNode={targetNode}
@ -618,7 +611,8 @@ interface MigrationDialogProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
onSubmit: () => void; onSubmit: () => void;
availableNodes: VMInfo[]; availableNodeNames: string[];
nodesLoading: boolean;
clusters: ClusterInfo[]; clusters: ClusterInfo[];
currentClusterId: string; currentClusterId: string;
targetNode: string; targetNode: string;
@ -636,7 +630,8 @@ function MigrationDialog({
isOpen, isOpen,
onClose, onClose,
onSubmit, onSubmit,
availableNodes, availableNodeNames,
nodesLoading,
clusters, clusters,
currentClusterId, currentClusterId,
targetNode, targetNode,
@ -652,14 +647,10 @@ function MigrationDialog({
const isCrossCluster = targetCluster && targetCluster !== currentClusterId; 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 = () => { const canSubmitMigration = () => {
if (!targetNode) return false; if (!targetNode) return false;
if (isCrossCluster) return true; if (isCrossCluster) return true;
return availableTargets.length > 0; return availableNodeNames.length > 0;
}; };
return ( return (
@ -701,7 +692,9 @@ function MigrationDialog({
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="targetNode">Target Node</Label> <Label htmlFor="targetNode">Target Node</Label>
{isCrossCluster ? ( {nodesLoading ? (
<p className="text-sm text-muted-foreground animate-pulse">Loading nodes</p>
) : isCrossCluster ? (
<> <>
<Input <Input
id="targetNode" id="targetNode"
@ -720,14 +713,14 @@ function MigrationDialog({
<SelectValue placeholder="Select target node" /> <SelectValue placeholder="Select target node" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{availableTargets.map((node) => ( {availableNodeNames.map((node) => (
<SelectItem key={node} value={node}> <SelectItem key={node} value={node}>
{node} {node}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
{availableTargets.length === 0 && ( {availableNodeNames.length === 0 && (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
No other nodes available in this cluster No other nodes available in this cluster
</p> </p>

View File

@ -35,3 +35,4 @@ export { ContainerOverview } from './ContainerOverview';
export { AclList } from './AclList'; export { AclList } from './AclList';
export { VMConsole } from './VMConsole'; export { VMConsole } from './VMConsole';
export { ContainerConsole } from './ContainerConsole'; export { ContainerConsole } from './ContainerConsole';
export { CreateVmDialog } from './CreateVmDialog';

View File

@ -188,6 +188,60 @@ export async function shutdownProxmoxVm(
await invoke("shutdown_proxmox_vm", { clusterId, nodeId, vmId }); await invoke("shutdown_proxmox_vm", { clusterId, nodeId, vmId });
} }
export async function suspendProxmoxVm(
clusterId: string,
nodeId: string,
vmId: number
): Promise<void> {
await invoke("suspend_proxmox_vm", { clusterId, nodeId, vmId });
}
export async function resumeProxmoxVm(
clusterId: string,
nodeId: string,
vmId: number
): Promise<void> {
await invoke("resume_proxmox_vm", { clusterId, nodeId, vmId });
}
export async function listProxmoxNodes(clusterId: string): Promise<any[]> {
return await invoke<any[]>("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<void> {
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 * List Proxmox Backup Jobs
* @param clusterId - Cluster identifier * @param clusterId - Cluster identifier

View File

@ -1,7 +1,7 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { Button } from '@/components/ui/index'; import { Button } from '@/components/ui/index';
import { RefreshCw } from 'lucide-react'; import { RefreshCw, Plus } from 'lucide-react';
import { VMList } from '@/components/Proxmox'; import { VMList, CreateVmDialog } from '@/components/Proxmox';
import { listProxmoxClusters, listProxmoxVms } from '@/lib/proxmoxClient'; import { listProxmoxClusters, listProxmoxVms } from '@/lib/proxmoxClient';
import type { ClusterInfo } from '@/lib/domain'; import type { ClusterInfo } from '@/lib/domain';
import { toast } from 'sonner'; import { toast } from 'sonner';
@ -13,6 +13,7 @@ export function ProxmoxVMsPage() {
const [vms, setVms] = useState<any[]>([]); const [vms, setVms] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [selectedVMs, setSelectedVMs] = useState<Set<string>>(new Set()); const [selectedVMs, setSelectedVMs] = useState<Set<string>>(new Set());
const [showCreateDialog, setShowCreateDialog] = useState(false);
useEffect(() => { useEffect(() => {
listProxmoxClusters() listProxmoxClusters()
@ -82,6 +83,10 @@ export function ProxmoxVMsPage() {
<RefreshCw className="mr-2 h-4 w-4" /> <RefreshCw className="mr-2 h-4 w-4" />
Refresh Refresh
</Button> </Button>
<Button size="sm" onClick={() => setShowCreateDialog(true)} disabled={!selectedClusterId}>
<Plus className="mr-2 h-4 w-4" />
Add VM
</Button>
</div> </div>
</div> </div>
@ -100,6 +105,13 @@ export function ProxmoxVMsPage() {
}); });
}} }}
/> />
<CreateVmDialog
isOpen={showCreateDialog}
clusterId={selectedClusterId}
onClose={() => setShowCreateDialog(false)}
onCreated={() => loadVms(selectedClusterId)}
/>
</div> </div>
); );
} }

313
tests/unit/VMList.test.tsx Normal file
View File

@ -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(
<VMList
vms={vms}
clusterId={clusterId}
clusters={mockClusters as never}
onRefresh={onRefresh}
/>
),
};
}
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();
});
});