diff --git a/docs/wiki/IPC-Commands.md b/docs/wiki/IPC-Commands.md index c280c5e8..537f3fb1 100644 --- a/docs/wiki/IPC-Commands.md +++ b/docs/wiki/IPC-Commands.md @@ -669,6 +669,66 @@ suspendProxmoxVm(clusterId, nodeId, vmId) → void // POST .../status/suspend resumeProxmoxVm(clusterId, nodeId, vmId) → void // POST .../status/resume ``` +### `list_proxmox_snapshots` +```typescript +listProxmoxSnapshots(clusterId, nodeId, vmid) → ProxmoxSnapshot[] +``` +Lists snapshots for a VM via `GET nodes/{node}/qemu/{vmid}/snapshot`. Returns typed `ProxmoxSnapshot[]` with `snapname`, `vmid`, `ctime`, `parent?`, `description?`. + +### `create_proxmox_snapshot` +```typescript +createProxmoxSnapshot(clusterId, nodeId, vmid, snapshotName) → void +``` +Creates a VM snapshot via `POST nodes/{node}/qemu/{vmid}/snapshot`. + +### `delete_proxmox_snapshot` +```typescript +deleteProxmoxSnapshot(clusterId, nodeId, vmid, snapshotName) → void +``` +Deletes a VM snapshot via `DELETE nodes/{node}/qemu/{vmid}/snapshot/{snapname}`. + +### `rollback_proxmox_snapshot` +```typescript +rollbackProxmoxSnapshot(clusterId, nodeId, vmid, snapshotName) → void +``` +Rolls back a VM to a snapshot via `POST nodes/{node}/qemu/{vmid}/snapshot/{snapname}/rollback`. + +### `list_network_interfaces` +```typescript +listNetworkInterfaces(clusterId, nodeId) → NetworkInterface[] +``` +Lists network interfaces on a node via `GET nodes/{node}/network`. + +### `create_network_interface` +```typescript +createNetworkInterface(clusterId, nodeId, config: NetworkInterfaceConfig) → void +``` +Creates a network interface via `POST nodes/{node}/network`. + +### `update_network_interface` +```typescript +updateNetworkInterface(clusterId, nodeId, iface, config: NetworkInterfaceConfig) → void +``` +Updates a network interface via `PUT nodes/{node}/network/{iface}`. + +### `delete_network_interface` +```typescript +deleteNetworkInterface(clusterId, nodeId, iface) → void +``` +Deletes a network interface via `DELETE nodes/{node}/network/{iface}`. + +### `list_iso_images` +```typescript +listIsoImages(clusterId, nodeId, storageId) → Array<{ volid: string; name?: string; size?: number }> +``` +Lists ISO images in a storage pool via `GET nodes/{node}/storage/{storage}/content`, filtering for `content == "iso"`. Used by CreateVmDialog to populate the ISO dropdown. + +### `upload_iso_image` +```typescript +uploadIsoImage(clusterId, nodeId, storageId, filePath) → string +``` +Uploads a local `.iso` file to a Proxmox storage pool via multipart `POST nodes/{node}/storage/{storage}/upload`. `filePath` is the absolute local path from the OS file picker dialog. Returns the Proxmox task UPID. The `.iso` extension is enforced server-side before the file is read. + ### `migrate_vm` ```typescript invoke('migrate_vm', { clusterId, nodeId, vmId, targetNode, targetCluster }) → void diff --git a/src-tauri/src/commands/proxmox.rs b/src-tauri/src/commands/proxmox.rs index 7e0f3730..61df2e41 100644 --- a/src-tauri/src/commands/proxmox.rs +++ b/src-tauri/src/commands/proxmox.rs @@ -2364,6 +2364,7 @@ pub async fn list_network_interfaces( node_id: String, state: State<'_, AppState>, ) -> Result, String> { + validate_pve_identifier(&node_id, "node_id")?; let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?; let client_guard = client.lock().await; @@ -2387,6 +2388,8 @@ pub async fn create_network_interface( config: NetworkInterfaceConfig, state: State<'_, AppState>, ) -> Result<(), String> { + validate_pve_identifier(&node_id, "node_id")?; + validate_pve_identifier(&config.iface, "iface")?; let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?; let client_guard = client.lock().await; @@ -2436,6 +2439,8 @@ pub async fn update_network_interface( config: NetworkInterfaceConfig, state: State<'_, AppState>, ) -> Result<(), String> { + validate_pve_identifier(&node_id, "node_id")?; + validate_pve_identifier(&iface, "iface")?; let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?; let client_guard = client.lock().await; @@ -2484,6 +2489,8 @@ pub async fn delete_network_interface( iface: String, state: State<'_, AppState>, ) -> Result<(), String> { + validate_pve_identifier(&node_id, "node_id")?; + validate_pve_identifier(&iface, "iface")?; let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?; let client_guard = client.lock().await; @@ -2506,6 +2513,7 @@ pub async fn list_proxmox_snapshots( vmid: u32, state: State<'_, AppState>, ) -> Result, String> { + validate_pve_identifier(&node_id, "node_id")?; let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?; let client_guard = client.lock().await; @@ -2527,6 +2535,8 @@ pub async fn create_proxmox_snapshot( snapshot_name: String, state: State<'_, AppState>, ) -> Result<(), String> { + validate_pve_identifier(&node_id, "node_id")?; + validate_pve_identifier(&snapshot_name, "snapshot_name")?; let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?; let client_guard = client.lock().await; @@ -2549,6 +2559,8 @@ pub async fn delete_proxmox_snapshot( snapshot_name: String, state: State<'_, AppState>, ) -> Result<(), String> { + validate_pve_identifier(&node_id, "node_id")?; + validate_pve_identifier(&snapshot_name, "snapshot_name")?; let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?; let client_guard = client.lock().await; @@ -2571,6 +2583,8 @@ pub async fn rollback_proxmox_snapshot( snapshot_name: String, state: State<'_, AppState>, ) -> Result<(), String> { + validate_pve_identifier(&node_id, "node_id")?; + validate_pve_identifier(&snapshot_name, "snapshot_name")?; let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?; let client_guard = client.lock().await; @@ -2584,6 +2598,73 @@ pub async fn rollback_proxmox_snapshot( .await } +// ─── ISO Image Listing ──────────────────────────────────────────────────────── + +/// List ISO images available in a Proxmox storage +#[tauri::command] +pub async fn list_iso_images( + cluster_id: String, + node_id: String, + storage_id: String, + state: State<'_, AppState>, +) -> Result, String> { + validate_pve_identifier(&node_id, "node_id")?; + validate_pve_identifier(&storage_id, "storage_id")?; + let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?; + let client_guard = client.lock().await; + + crate::proxmox::storage::list_storage_content_iso( + &client_guard, + &node_id, + &storage_id, + client_guard.ticket.as_deref().unwrap_or(""), + ) + .await +} + +/// Upload an ISO image to a Proxmox storage pool. +/// `file_path` is the local filesystem path selected by the user via file dialog. +/// Returns the Proxmox task UPID which can be polled for completion. +#[tauri::command] +pub async fn upload_iso_image( + cluster_id: String, + node_id: String, + storage_id: String, + file_path: String, + state: State<'_, AppState>, +) -> Result { + validate_pve_identifier(&node_id, "node_id")?; + validate_pve_identifier(&storage_id, "storage_id")?; + + let filename = std::path::Path::new(&file_path) + .file_name() + .and_then(|n| n.to_str()) + .ok_or_else(|| "Invalid file path: cannot determine filename".to_string())? + .to_string(); + + // Enforce .iso extension + if !filename.to_lowercase().ends_with(".iso") { + return Err("Only .iso files are supported".to_string()); + } + + let file_bytes = tokio::fs::read(&file_path) + .await + .map_err(|e| format!("Failed to read file '{}': {}", file_path, e))?; + + let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?; + let client_guard = client.lock().await; + + crate::proxmox::storage::upload_iso( + &client_guard, + &node_id, + &storage_id, + &filename, + file_bytes, + client_guard.ticket.as_deref().unwrap_or(""), + ) + .await +} + // ─── Phase 13 - Cluster Views (typed aliases) ───────────────────────────────── /// List cluster views (typed) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8c326481..93aed433 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -222,6 +222,8 @@ pub fn run() { commands::proxmox::create_proxmox_snapshot, commands::proxmox::delete_proxmox_snapshot, commands::proxmox::rollback_proxmox_snapshot, + commands::proxmox::list_iso_images, + commands::proxmox::upload_iso_image, // Proxmox - Cluster Views typed (Phase 13) commands::proxmox::list_cluster_views, commands::proxmox::create_cluster_view, diff --git a/src-tauri/src/proxmox/client.rs b/src-tauri/src/proxmox/client.rs index bddd48df..00b35b4b 100644 --- a/src-tauri/src/proxmox/client.rs +++ b/src-tauri/src/proxmox/client.rs @@ -169,13 +169,6 @@ impl ProxmoxClient { } } - headers.insert( - reqwest::header::CONTENT_TYPE, - "application/x-www-form-urlencoded" - .parse() - .expect("Invalid content type"), - ); - headers } @@ -265,6 +258,28 @@ impl ProxmoxClient { self.handle_response(response).await } + /// POST multipart/form-data to Proxmox API (used for file uploads) + pub async fn post_multipart Deserialize<'de>>( + &self, + path: &str, + form: reqwest::multipart::Form, + ticket: Option<&str>, + ) -> Result { + let url = self.get_api_url(path); + let headers = self.build_headers(ticket, true); + + let response = self + .client + .post(&url) + .headers(headers) + .multipart(form) + .send() + .await + .map_err(|e| anyhow!("POST multipart request failed: {}", e))?; + + self.handle_response(response).await + } + /// DELETE request to Proxmox API pub async fn delete Deserialize<'de>>( &self, diff --git a/src-tauri/src/proxmox/migration.rs b/src-tauri/src/proxmox/migration.rs index 16073910..87032b4d 100644 --- a/src-tauri/src/proxmox/migration.rs +++ b/src-tauri/src/proxmox/migration.rs @@ -40,48 +40,34 @@ pub async fn migrate_vm( ticket: &str, ) -> Result { let path = format!("nodes/{}/qemu/{}/migrate", node, vm_id); - let params = vec![ - ("target", target_node), - ("targetcluster", target_cluster), - ("targetstorage", ""), - ("online", "1"), - ("force", "0"), - ]; + let body = serde_json::json!({ + "target": target_node, + "online": 1, + "force": 0, + }); let response: serde_json::Value = client - .post_form(&path, ¶ms, Some(ticket)) + .post::(&path, &body, Some(ticket)) .await .map_err(|e| format!("Failed to migrate VM {}: {}", vm_id, e))?; - { - let data = &response; - let task_id = data - .get("taskid") - .and_then(|t| t.as_str()) - .unwrap_or("") - .to_string(); - let status = data - .get("status") - .and_then(|s| s.as_str()) - .unwrap_or("running") - .to_string(); - let progress = data.get("progress").and_then(|p| p.as_u64()).unwrap_or(0) as u32; - let start_time = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(); + // handle_response unwraps the "data" envelope; migrate returns the task UPID as a string. + let task_id = response.as_str().unwrap_or("").to_string(); + let start_time = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(); - Ok(MigrationTask { - task_id, - vm_id, - source_node: node.to_string(), - target_node: target_node.to_string(), - source_cluster: client.base_url().to_string(), - target_cluster: target_cluster.to_string(), - status, - progress, - start_time, - end_time: None, - error: None, - }) - } + Ok(MigrationTask { + task_id, + vm_id, + source_node: node.to_string(), + target_node: target_node.to_string(), + source_cluster: client.base_url().to_string(), + target_cluster: target_cluster.to_string(), + status: "running".to_string(), + progress: 0, + start_time, + end_time: None, + error: None, + }) } /// List migration tasks diff --git a/src-tauri/src/proxmox/network.rs b/src-tauri/src/proxmox/network.rs index 48505dec..10378930 100644 --- a/src-tauri/src/proxmox/network.rs +++ b/src-tauri/src/proxmox/network.rs @@ -47,7 +47,7 @@ pub struct NetworkInterfaceConfig { /// Helper module for serde bool-as-int conversion (Proxmox API expects 0/1) mod serde_bool_as_int { - use serde::{Deserialize, Deserializer, Serializer}; + use serde::{Deserializer, Serializer}; pub fn serialize(value: &bool, serializer: S) -> Result where @@ -60,8 +60,29 @@ mod serde_bool_as_int { where D: Deserializer<'de>, { - let value = i8::deserialize(deserializer)?; - Ok(value != 0) + struct BoolOrInt; + + impl<'de> serde::de::Visitor<'de> for BoolOrInt { + type Value = bool; + + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("integer or boolean") + } + + fn visit_bool(self, v: bool) -> Result { + Ok(v) + } + + fn visit_i64(self, v: i64) -> Result { + Ok(v != 0) + } + + fn visit_u64(self, v: u64) -> Result { + Ok(v != 0) + } + } + + deserializer.deserialize_any(BoolOrInt) } } diff --git a/src-tauri/src/proxmox/storage.rs b/src-tauri/src/proxmox/storage.rs index 37636a24..87484281 100644 --- a/src-tauri/src/proxmox/storage.rs +++ b/src-tauri/src/proxmox/storage.rs @@ -35,6 +35,65 @@ pub async fn get_storage_status( Err("Not implemented yet".to_string()) } +/// List ISO images available in a storage (client-side filtered from storage content) +pub async fn list_storage_content_iso( + client: &crate::proxmox::client::ProxmoxClient, + node: &str, + storage: &str, + ticket: &str, +) -> Result, String> { + let path = format!("nodes/{}/storage/{}/content", node, storage); + let response: serde_json::Value = client + .get(&path, Some(ticket)) + .await + .map_err(|e| format!("Failed to list storage content for {}/{}: {}", node, storage, e))?; + + response + .as_array() + .map(|arr| { + arr.iter() + .filter(|item| { + item.get("content") + .and_then(|c| c.as_str()) + .map(|c| c == "iso") + .unwrap_or(false) + }) + .cloned() + .collect::>() + }) + .ok_or_else(|| "Invalid response format from storage content".to_string()) +} + +/// Upload an ISO file to a Proxmox storage pool. +/// Returns the task UPID string that can be polled for completion. +pub async fn upload_iso( + client: &crate::proxmox::client::ProxmoxClient, + node: &str, + storage: &str, + filename: &str, + file_bytes: Vec, + ticket: &str, +) -> Result { + let path = format!("nodes/{}/storage/{}/upload", node, storage); + + let file_part = reqwest::multipart::Part::bytes(file_bytes) + .file_name(filename.to_string()) + .mime_str("application/octet-stream") + .map_err(|e| format!("Failed to build multipart part: {}", e))?; + + let form = reqwest::multipart::Form::new() + .text("content", "iso") + .text("filename", filename.to_string()) + .part("file", file_part); + + let task_id: String = client + .post_multipart(&path, form, Some(ticket)) + .await + .map_err(|e| format!("Failed to upload ISO to {}/{}: {}", node, storage, e))?; + + Ok(task_id) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/components/Proxmox/CreateVmDialog.tsx b/src/components/Proxmox/CreateVmDialog.tsx index d644365d..3ff7f447 100644 --- a/src/components/Proxmox/CreateVmDialog.tsx +++ b/src/components/Proxmox/CreateVmDialog.tsx @@ -4,7 +4,17 @@ 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 { Upload } from 'lucide-react'; +import { open as openFileDialog } from '@tauri-apps/plugin-dialog'; +import { + listProxmoxClusters, + listProxmoxNodes, + listProxmoxStorages, + listIsoImages, + uploadIsoImage, + createProxmoxVm, +} from '@/lib/proxmoxClient'; +import type { ClusterInfo } from '@/lib/domain'; import { toast } from 'sonner'; interface CreateVmDialogProps { @@ -25,9 +35,15 @@ const OS_TYPES = [ ]; export function CreateVmDialog({ isOpen, clusterId, onClose, onCreated }: CreateVmDialogProps) { + const [clusters, setClusters] = useState([]); + const [selectedClusterId, setSelectedClusterId] = useState(clusterId); const [nodes, setNodes] = useState([]); const [storages, setStorages] = useState([]); + const [isoStorages, setIsoStorages] = useState([]); + const [isoImages, setIsoImages] = useState<{ volid: string; name?: string }[]>([]); + const [isoStorage, setIsoStorage] = useState(''); const [isSubmitting, setIsSubmitting] = useState(false); + const [isUploading, setIsUploading] = useState(false); const [nodeId, setNodeId] = useState(''); const [vmid, setVmid] = useState(100); @@ -40,12 +56,22 @@ export function CreateVmDialog({ isOpen, clusterId, onClose, onCreated }: Create const [diskSize, setDiskSize] = useState(20); const [netBridge, setNetBridge] = useState('vmbr0'); const [iso, setIso] = useState(''); - const [isoError, setIsoError] = useState(''); useEffect(() => { - if (!isOpen || !clusterId) return; + if (!isOpen) return; + listProxmoxClusters() + .then((cls) => { + setClusters(cls); + const target = cls.find((c) => c.id === clusterId) ? clusterId : cls[0]?.id ?? clusterId; + setSelectedClusterId(target); + }) + .catch(console.error); + }, [isOpen, clusterId]); - listProxmoxNodes(clusterId) + useEffect(() => { + if (!isOpen || !selectedClusterId) return; + + listProxmoxNodes(selectedClusterId) .then((data) => { const nodeNames = (data as Array<{ node?: string; status?: string }>) .filter((n) => n.status === 'online' || n.node) @@ -55,42 +81,77 @@ export function CreateVmDialog({ isOpen, clusterId, onClose, onCreated }: Create setNodeId(nodeNames[0] ?? ''); }) .catch(() => toast.error('Failed to load cluster nodes')); + }, [isOpen, selectedClusterId]); - listProxmoxDatastores(clusterId) + useEffect(() => { + if (!isOpen || !selectedClusterId || !nodeId) return; + + listProxmoxStorages(selectedClusterId, nodeId) .then((data) => { - const storageIds = (data as Array<{ storage?: string }>) - .map((s) => s.storage ?? '') - .filter(Boolean); + const storageIds = data.map((s) => s.storage).filter(Boolean); setStorages(storageIds); setStorage(storageIds[0] ?? 'local-lvm'); + + const isoCapable = data + .filter((s) => !s.content || s.content.includes('iso')) + .map((s) => s.storage) + .filter(Boolean); + setIsoStorages(isoCapable); + setIsoStorage(isoCapable[0] ?? ''); }) .catch(() => { setStorages(['local-lvm', 'local']); setStorage('local-lvm'); }); - }, [isOpen, clusterId]); + }, [isOpen, selectedClusterId, nodeId]); - const ISO_RE = /^[a-zA-Z0-9_-]+:iso\/[^,]+$/; + useEffect(() => { + if (!isOpen || !selectedClusterId || !nodeId || !isoStorage) { + setIsoImages([]); + return; + } - const validateIso = (value: string): string => { - if (!value) return ''; - return ISO_RE.test(value) ? '' : "Must be in the format 'storage:iso/filename'"; - }; + listIsoImages(selectedClusterId, nodeId, isoStorage) + .then((imgs) => { + setIsoImages(imgs); + }) + .catch(() => setIsoImages([])); + }, [isOpen, selectedClusterId, nodeId, isoStorage]); - const handleIsoChange = (value: string) => { - setIso(value); - setIsoError(validateIso(value)); + const handleUploadIso = async () => { + if (!selectedClusterId || !nodeId || !isoStorage) { + toast.error('Select a cluster, node, and ISO storage before uploading'); + return; + } + const selected = await openFileDialog({ + title: 'Select ISO file', + filters: [{ name: 'ISO Images', extensions: ['iso'] }], + multiple: false, + }); + if (!selected) return; + + const filePath = selected as string; + setIsUploading(true); + try { + await uploadIsoImage(selectedClusterId, nodeId, isoStorage, filePath); + toast.success('ISO upload started — refreshing image list'); + const imgs = await listIsoImages(selectedClusterId, nodeId, isoStorage); + setIsoImages(imgs); + } catch (e) { + toast.error(`Upload failed: ${e}`); + } finally { + setIsUploading(false); + } }; 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, { + await createProxmoxVm(selectedClusterId, { nodeId, vmid, name: name.trim(), @@ -101,7 +162,7 @@ export function CreateVmDialog({ isOpen, clusterId, onClose, onCreated }: Create storage, diskSize, netBridge, - iso: iso.trim() || undefined, + iso: iso || undefined, }); toast.success(`VM "${name}" created successfully (VMID: ${vmid})`); onCreated(); @@ -123,10 +184,14 @@ export function CreateVmDialog({ isOpen, clusterId, onClose, onCreated }: Create setDiskSize(20); setNetBridge('vmbr0'); setIso(''); - setIsoError(''); onClose(); }; + const isoLabel = (volid: string, imgName?: string) => { + const filename = imgName ?? volid.split('/').pop() ?? volid; + return filename; + }; + return ( @@ -135,9 +200,25 @@ export function CreateVmDialog({ isOpen, clusterId, onClose, onCreated }: Create
+ {clusters.length > 1 && ( +
+ + +
+ )} +
- + @@ -224,7 +305,7 @@ export function CreateVmDialog({ isOpen, clusterId, onClose, onCreated }: Create
- + {storages.length > 0 ? ( ) : ( setStorage(e.target.value)} placeholder="local-lvm" @@ -267,20 +347,64 @@ export function CreateVmDialog({ isOpen, clusterId, onClose, onCreated }: Create />
-
- - handleIsoChange(e.target.value)} - placeholder="local:iso/ubuntu-24.04.iso" - className={isoError ? 'border-red-500' : ''} - /> - {isoError ? ( -

{isoError}

- ) : ( -

Format: storage:iso/filename

+
+
+ + {isoStorage && ( + + )} +
+ {isoStorages.length > 0 && ( +
+ + +
)} + {isoImages.length > 0 ? ( + + ) : ( + setIso(e.target.value)} + placeholder="local:iso/ubuntu-24.04.iso" + /> + )} +

+ {isoImages.length > 0 + ? `${isoImages.length} ISO(s) available` + : 'Format: storage:iso/filename'} +

@@ -288,7 +412,10 @@ export function CreateVmDialog({ isOpen, clusterId, onClose, onCreated }: Create - diff --git a/src/components/Proxmox/VMList.tsx b/src/components/Proxmox/VMList.tsx index 9dea304e..f7ce6242 100644 --- a/src/components/Proxmox/VMList.tsx +++ b/src/components/Proxmox/VMList.tsx @@ -16,6 +16,7 @@ import { Input } from '@/components/ui/index'; import { AlertCircle } from 'lucide-react'; import { Alert, AlertDescription } from '@/components/ui/index'; import type { ClusterInfo } from '@/lib/domain'; +import type { ProxmoxSnapshot } from '@/lib/proxmoxClient'; interface VMInfo { id: string; @@ -30,15 +31,6 @@ interface VMInfo { tags?: string[]; } -interface ProxmoxSnapshot { - snapname: string; - vmid: number; - name?: string; - ctime: number; - parent?: string; - description?: string; -} - interface RawVMInfo { id: number; vmid?: number; diff --git a/src/lib/proxmoxClient.ts b/src/lib/proxmoxClient.ts index 318db477..c49b29b3 100644 --- a/src/lib/proxmoxClient.ts +++ b/src/lib/proxmoxClient.ts @@ -1041,6 +1041,15 @@ export const deleteNetworkInterface = async ( // ─── VM Snapshots ───────────────────────────────────────────────────────────── +export interface ProxmoxSnapshot { + snapname: string; + vmid: number; + name?: string; + ctime: number; + parent?: string; + description?: string; +} + /** * List snapshots for a VM * @param clusterId - Cluster identifier @@ -1051,8 +1060,8 @@ export const listProxmoxSnapshots = async ( clusterId: string, nodeId: string, vmid: number -): Promise => - invoke("list_proxmox_snapshots", { clusterId, nodeId, vmid }); +): Promise => + invoke("list_proxmox_snapshots", { clusterId, nodeId, vmid }); /** * Create a snapshot for a VM @@ -1187,3 +1196,63 @@ export const listClusterTasks = async ( clusterId, limit: limit ?? 50, }); + +// ─── Storage Per-Node ───────────────────────────────────────────────────────── + +/** + * List storage pools visible on a specific node (filtered from cluster resources) + */ +export const listProxmoxStorages = async ( + clusterId: string, + nodeId: string +): Promise<{ storage: string; type: string; content?: string }[]> => { + const all = await listProxmoxDatastores(clusterId); + return (all as Array<{ storage?: string; node?: string; type?: string; content?: string }>) + .filter((s) => s.node === nodeId || !s.node) + .map((s) => ({ + storage: s.storage ?? '', + type: s.type ?? '', + content: s.content, + })) + .filter((s) => s.storage !== ''); +}; + +// ─── ISO Images ─────────────────────────────────────────────────────────────── + +/** + * List ISO images available in a Proxmox storage + * @param clusterId - Cluster identifier + * @param nodeId - Node identifier + * @param storageId - Storage pool identifier + */ +export const listIsoImages = async ( + clusterId: string, + nodeId: string, + storageId: string +): Promise<{ volid: string; name?: string; size?: number }[]> => + invoke<{ volid: string; name?: string; size?: number }[]>("list_iso_images", { + clusterId, + nodeId, + storageId, + }); + +/** + * Upload an ISO file to a Proxmox storage pool. + * @param clusterId - Cluster identifier + * @param nodeId - Node identifier + * @param storageId - Storage pool identifier + * @param filePath - Absolute local path to the .iso file (from file dialog) + * @returns Proxmox task UPID + */ +export const uploadIsoImage = async ( + clusterId: string, + nodeId: string, + storageId: string, + filePath: string +): Promise => + invoke("upload_iso_image", { + clusterId, + nodeId, + storageId, + filePath, + }); diff --git a/src/pages/Proxmox/NetworkPage.tsx b/src/pages/Proxmox/NetworkPage.tsx index 5a835f25..75b274f1 100644 --- a/src/pages/Proxmox/NetworkPage.tsx +++ b/src/pages/Proxmox/NetworkPage.tsx @@ -2,30 +2,55 @@ import React, { useState, useEffect, useCallback } from 'react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/index'; import { Button } from '@/components/ui/index'; import { Badge } from '@/components/ui/index'; -import { RefreshCw, Network, Plus, Edit, Trash2 } from 'lucide-react'; -import { listNetworkInterfaces, listProxmoxClusters, NetworkInterface } from '@/lib/proxmoxClient'; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/index'; import { Input } from '@/components/ui/index'; import { Label } from '@/components/ui/index'; +import { Checkbox } from '@/components/ui/index'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/index'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/index'; +import { RefreshCw, Network, Plus, Pencil, Trash2 } from 'lucide-react'; +import { + listNetworkInterfaces, + createNetworkInterface, + updateNetworkInterface, + deleteNetworkInterface, + listProxmoxClusters, + NetworkInterface, + NetworkInterfaceConfig, +} from '@/lib/proxmoxClient'; import { toast } from 'sonner'; +interface FormState { + ifaceName: string; + ifaceType: string; + address: string; + netmask: string; + gateway: string; + autostart: boolean; + active: boolean; +} + +const defaultForm: FormState = { + ifaceName: '', + ifaceType: 'eth', + address: '', + netmask: '', + gateway: '', + autostart: false, + active: false, +}; + export function ProxmoxNetworkPage() { const [interfaces, setInterfaces] = useState([]); const [clusterId, setClusterId] = useState(''); const [nodeId] = useState('localhost'); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const [showAddDialog, setShowAddDialog] = useState(false); - const [editingInterface] = useState(null); - - // Form state - const [ifaceName, setIfaceName] = useState(''); - const [ifaceType, setIfaceType] = useState('eth'); - const [address, setAddress] = useState(''); - const [netmask, setNetmask] = useState(''); - const [gateway, setGateway] = useState(''); - const [active, setActive] = useState(true); + + const [showDialog, setShowDialog] = useState(false); + const [isEditing, setIsEditing] = useState(false); + const [editingInterface, setEditingInterface] = useState(null); + const [form, setForm] = useState(defaultForm); + const [submitting, setSubmitting] = useState(false); const loadInterfaces = useCallback(async (cId: string, nId: string) => { if (!cId) return; @@ -52,24 +77,69 @@ export function ProxmoxNetworkPage() { .catch(console.error); }, [loadInterfaces, nodeId]); - const NOT_IMPLEMENTED_MSG = - 'Network interface management requires additional backend implementation (POST/PUT/DELETE nodes/{node}/network) and is not yet available.'; - const handleAddInterface = () => { - toast.warning(NOT_IMPLEMENTED_MSG); + setIsEditing(false); + setEditingInterface(null); + setForm(defaultForm); + setShowDialog(true); }; - const handleEditInterface = (_iface: NetworkInterface) => { - toast.warning(NOT_IMPLEMENTED_MSG); + const handleEditInterface = (iface: NetworkInterface) => { + setIsEditing(true); + setEditingInterface(iface); + setForm({ + ifaceName: iface.iface, + ifaceType: iface.type, + address: iface.address ?? '', + netmask: iface.netmask ?? '', + gateway: iface.gateway ?? '', + autostart: iface.autostart, + active: iface.active, + }); + setShowDialog(true); }; - const handleSubmit = async () => { - toast.warning(NOT_IMPLEMENTED_MSG); - setShowAddDialog(false); + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!clusterId) return; + + const config: NetworkInterfaceConfig = { + iface: form.ifaceName, + type: form.ifaceType, + address: form.address || undefined, + netmask: form.netmask || undefined, + gateway: form.gateway || undefined, + active: form.active, + autostart: form.autostart, + }; + + setSubmitting(true); + try { + if (isEditing && editingInterface) { + await updateNetworkInterface(clusterId, nodeId, editingInterface.iface, config); + toast.success(`Interface "${editingInterface.iface}" updated`); + } else { + await createNetworkInterface(clusterId, nodeId, config); + toast.success(`Interface "${config.iface}" created`); + } + setShowDialog(false); + await loadInterfaces(clusterId, nodeId); + } catch (e) { + toast.error(String(e)); + } finally { + setSubmitting(false); + } }; - const handleDeleteInterface = async (_iface: NetworkInterface) => { - toast.warning(NOT_IMPLEMENTED_MSG); + const handleDeleteInterface = async (iface: NetworkInterface) => { + if (!window.confirm(`Delete interface "${iface.iface}"? This cannot be undone.`)) return; + try { + await deleteNetworkInterface(clusterId, nodeId, iface.iface); + toast.success(`Interface "${iface.iface}" deleted`); + await loadInterfaces(clusterId, nodeId); + } catch (e) { + toast.error(String(e)); + } }; return ( @@ -79,15 +149,25 @@ export function ProxmoxNetworkPage() {

Network

Network interfaces and bridges

-
- - +
@@ -143,21 +223,24 @@ export function ProxmoxNetworkPage() {
)}
-
- - + +
))} @@ -166,85 +249,113 @@ export function ProxmoxNetworkPage() { - - + + - {editingInterface ? 'Edit Network Interface' : 'Add Network Interface'} + {isEditing ? 'Edit Interface' : 'Add Interface'} -
+
void handleSubmit(e)} className="space-y-4">
- + setIfaceName(e.target.value)} - placeholder="eth0" + id="ifaceName" + value={form.ifaceName} + onChange={(e) => setForm((f) => ({ ...f, ifaceName: e.target.value }))} + placeholder="e.g. vmbr0" + disabled={isEditing || submitting} + required />
+
- - setForm((f) => ({ ...f, ifaceType: v }))} + > - + - eth — Ethernet - bond — Network Bond - bridge — Linux Bridge - vlan — VLAN - OVSBridge — Open vSwitch Bridge - OVSBond — Open vSwitch Bond - OVSIntPort — OVS Internal Port - OVSPort — OVS Port + eth + bridge + bond + vlan + OVS Bridge + OVS Bond + OVS Port
+
setAddress(e.target.value)} - placeholder="192.168.1.100" + value={form.address} + onChange={(e) => setForm((f) => ({ ...f, address: e.target.value }))} + placeholder="e.g. 192.168.1.100" + disabled={submitting} />
+
setNetmask(e.target.value)} - placeholder="24" + value={form.netmask} + onChange={(e) => setForm((f) => ({ ...f, netmask: e.target.value }))} + placeholder="e.g. 255.255.255.0" + disabled={submitting} />
+
setGateway(e.target.value)} - placeholder="192.168.1.1" + value={form.gateway} + onChange={(e) => setForm((f) => ({ ...f, gateway: e.target.value }))} + placeholder="e.g. 192.168.1.1" + disabled={submitting} />
-
- setActive(e.target.checked)} - className="rounded" - /> - + +
+
+ setForm((f) => ({ ...f, autostart: v as boolean }))} + disabled={submitting} + /> + +
+
+ setForm((f) => ({ ...f, active: v as boolean }))} + disabled={submitting} + /> + +
-
- - - - + + + + + +