Merge pull request 'fix(proxmox): fix VM actions, remove Disk column, add Create VM' (#130) from fix/proxmox-vm-actions-v3 into beta
All checks were successful
Release Beta / autotag (push) Successful in 9s
Release Beta / changelog (push) Successful in 1m38s
Test / frontend-typecheck (push) Successful in 1m50s
Test / frontend-tests (push) Successful in 1m54s
Release Beta / build-macos-arm64 (push) Successful in 8m4s
Release Beta / build-linux-amd64 (push) Successful in 10m41s
Release Beta / build-windows-amd64 (push) Successful in 11m25s
Release Beta / build-linux-arm64 (push) Successful in 12m39s
Test / rust-fmt-check (push) Successful in 17m49s
Test / rust-clippy (push) Successful in 18m59s
Test / rust-tests (push) Successful in 21m5s
All checks were successful
Release Beta / autotag (push) Successful in 9s
Release Beta / changelog (push) Successful in 1m38s
Test / frontend-typecheck (push) Successful in 1m50s
Test / frontend-tests (push) Successful in 1m54s
Release Beta / build-macos-arm64 (push) Successful in 8m4s
Release Beta / build-linux-amd64 (push) Successful in 10m41s
Release Beta / build-windows-amd64 (push) Successful in 11m25s
Release Beta / build-linux-arm64 (push) Successful in 12m39s
Test / rust-fmt-check (push) Successful in 17m49s
Test / rust-clippy (push) Successful in 18m59s
Test / rust-tests (push) Successful in 21m5s
Reviewed-on: #130
This commit is contained in:
commit
37c497d9b6
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
|
// 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`: 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",
|
"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",
|
||||||
|
|||||||
@ -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,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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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" />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
298
src/components/Proxmox/CreateVmDialog.tsx
Normal file
298
src/components/Proxmox/CreateVmDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
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