fix(proxmox): fix VM actions, remove Disk column, add Create VM #130
73
docs/tickets/proxmox-vm-actions-v3.md
Normal file
73
docs/tickets/proxmox-vm-actions-v3.md
Normal 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` (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
|
||||
@ -629,3 +629,48 @@ CREATE TABLE credentials (
|
||||
// Backend: src-tauri/src/integrations/auth.rs
|
||||
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`: 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.
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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"]}}
|
||||
@ -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<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)
|
||||
#[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<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");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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() {
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
<Toaster richColors position="top-right" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
278
src/components/Proxmox/CreateVmDialog.tsx
Normal file
278
src/components/Proxmox/CreateVmDialog.tsx
Normal file
@ -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<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('');
|
||||
|
||||
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 (
|
||||
<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={128}
|
||||
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) => setIso(e.target.value)}
|
||||
placeholder="local:iso/ubuntu-24.04.iso"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Format: storage:iso/filename.iso</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleClose} disabled={isSubmitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={isSubmitting || !nodeId || !name.trim()}>
|
||||
{isSubmitting ? 'Creating...' : 'Create VM'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@ -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<string>('');
|
||||
const [onlineMigration, setOnlineMigration] = useState(true);
|
||||
const [maxDowntime, setMaxDowntime] = useState(30);
|
||||
const [clusterNodes, setClusterNodes] = useState<string[]>([]);
|
||||
|
||||
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({
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>CPU</TableHead>
|
||||
<TableHead>Memory</TableHead>
|
||||
<TableHead>Disk</TableHead>
|
||||
<TableHead>Uptime</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
@ -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 (
|
||||
<TableRow key={vm.id}>
|
||||
@ -387,23 +392,6 @@ export function VMList({
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{vm.diskTotal > 0 ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-green-500"
|
||||
style={{ width: `${diskPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatBytes(vm.disk)} / {formatBytes(vm.diskTotal)}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{formatUptime(vm.uptime || 0)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<VMActionMenu
|
||||
@ -426,9 +414,9 @@ export function VMList({
|
||||
<MigrationDialog
|
||||
vm={migrationVM}
|
||||
isOpen={!!migrationVM}
|
||||
onClose={() => { 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({
|
||||
<SelectValue placeholder="Select target node" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableTargets.map((node) => (
|
||||
{availableNodeNames.map((node) => (
|
||||
<SelectItem key={node} value={node}>
|
||||
{node}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{availableTargets.length === 0 && (
|
||||
{availableNodeNames.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No other nodes available in this cluster
|
||||
</p>
|
||||
|
||||
@ -35,3 +35,4 @@ export { ContainerOverview } from './ContainerOverview';
|
||||
export { AclList } from './AclList';
|
||||
export { VMConsole } from './VMConsole';
|
||||
export { ContainerConsole } from './ContainerConsole';
|
||||
export { CreateVmDialog } from './CreateVmDialog';
|
||||
|
||||
@ -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<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
|
||||
* @param clusterId - Cluster identifier
|
||||
|
||||
@ -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<any[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [selectedVMs, setSelectedVMs] = useState<Set<string>>(new Set());
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
listProxmoxClusters()
|
||||
@ -82,6 +83,10 @@ export function ProxmoxVMsPage() {
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setShowCreateDialog(true)} disabled={!selectedClusterId}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add VM
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -100,6 +105,13 @@ export function ProxmoxVMsPage() {
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<CreateVmDialog
|
||||
isOpen={showCreateDialog}
|
||||
clusterId={selectedClusterId}
|
||||
onClose={() => setShowCreateDialog(false)}
|
||||
onCreated={() => loadVms(selectedClusterId)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
313
tests/unit/VMList.test.tsx
Normal file
313
tests/unit/VMList.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user