feat(proxmox): ISO upload, full CRUD validation, and security hardening
Some checks failed
Test / frontend-tests (pull_request) Successful in 1m40s
Test / frontend-typecheck (pull_request) Successful in 1m49s
PR Review Automation / review (pull_request) Successful in 6m0s
Test / rust-clippy (pull_request) Has been cancelled
Test / rust-tests (pull_request) Has been cancelled
Test / rust-fmt-check (pull_request) Has been cancelled
Some checks failed
Test / frontend-tests (pull_request) Successful in 1m40s
Test / frontend-typecheck (pull_request) Successful in 1m49s
PR Review Automation / review (pull_request) Successful in 6m0s
Test / rust-clippy (pull_request) Has been cancelled
Test / rust-tests (pull_request) Has been cancelled
Test / rust-fmt-check (pull_request) Has been cancelled
- Add ISO upload via OS file picker: multipart POST to nodes/{node}/storage/{storage}/upload,
returns task UPID; Upload ISO button in CreateVmDialog triggers dialog filtered to .iso files
- Add cluster/datacenter selector to CreateVmDialog (shown when >1 cluster configured)
- Replace ISO text input with dropdown populated from listIsoImages; falls back to text input
when storage has no ISOs
- Rewrite NetworkPage with full CRUD: add/edit/delete interfaces via dialog, Checkbox toggles
for active/autostart, per-row Edit/Delete buttons
- Fix serde_bool_as_int deserializer to accept both bool and integer using visitor pattern
- Fix Content-Type conflict: remove pre-set header from build_headers(), let .json()/.form()
manage it (root cause of 400 Bad Request on VM start/migrate)
- Fix migration: remove invalid targetcluster/targetstorage params, switch to JSON body
- Security: wire validate_pve_identifier() into all 9 path-interpolating commands
(list/create/update/delete network interfaces, all 4 snapshot commands, list/upload ISO)
— previously only create_proxmox_vm was guarded
- Add post_multipart() method to ProxmoxClient for multipart form-data requests
- Add uploadIsoImage TypeScript wrapper and update proxmoxClient exports
- Update IPC-Commands wiki with all new and previously undocumented commands
This commit is contained in:
parent
e6ec3a46e2
commit
76d923a570
@ -669,6 +669,66 @@ suspendProxmoxVm(clusterId, nodeId, vmId) → void // POST .../status/suspend
|
|||||||
resumeProxmoxVm(clusterId, nodeId, vmId) → void // POST .../status/resume
|
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`
|
### `migrate_vm`
|
||||||
```typescript
|
```typescript
|
||||||
invoke('migrate_vm', { clusterId, nodeId, vmId, targetNode, targetCluster }) → void
|
invoke('migrate_vm', { clusterId, nodeId, vmId, targetNode, targetCluster }) → void
|
||||||
|
|||||||
@ -2364,6 +2364,7 @@ pub async fn list_network_interfaces(
|
|||||||
node_id: String,
|
node_id: String,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<Vec<serde_json::Value>, String> {
|
) -> Result<Vec<serde_json::Value>, String> {
|
||||||
|
validate_pve_identifier(&node_id, "node_id")?;
|
||||||
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
|
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
|
||||||
let client_guard = client.lock().await;
|
let client_guard = client.lock().await;
|
||||||
|
|
||||||
@ -2387,6 +2388,8 @@ pub async fn create_network_interface(
|
|||||||
config: NetworkInterfaceConfig,
|
config: NetworkInterfaceConfig,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<(), String> {
|
) -> 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 = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
|
||||||
let client_guard = client.lock().await;
|
let client_guard = client.lock().await;
|
||||||
|
|
||||||
@ -2436,6 +2439,8 @@ pub async fn update_network_interface(
|
|||||||
config: NetworkInterfaceConfig,
|
config: NetworkInterfaceConfig,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<(), String> {
|
) -> 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 = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
|
||||||
let client_guard = client.lock().await;
|
let client_guard = client.lock().await;
|
||||||
|
|
||||||
@ -2484,6 +2489,8 @@ pub async fn delete_network_interface(
|
|||||||
iface: String,
|
iface: String,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<(), String> {
|
) -> 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 = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
|
||||||
let client_guard = client.lock().await;
|
let client_guard = client.lock().await;
|
||||||
|
|
||||||
@ -2506,6 +2513,7 @@ pub async fn list_proxmox_snapshots(
|
|||||||
vmid: u32,
|
vmid: u32,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<Vec<serde_json::Value>, String> {
|
) -> Result<Vec<serde_json::Value>, String> {
|
||||||
|
validate_pve_identifier(&node_id, "node_id")?;
|
||||||
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
|
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
|
||||||
let client_guard = client.lock().await;
|
let client_guard = client.lock().await;
|
||||||
|
|
||||||
@ -2527,6 +2535,8 @@ pub async fn create_proxmox_snapshot(
|
|||||||
snapshot_name: String,
|
snapshot_name: String,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<(), String> {
|
) -> 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 = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
|
||||||
let client_guard = client.lock().await;
|
let client_guard = client.lock().await;
|
||||||
|
|
||||||
@ -2549,6 +2559,8 @@ pub async fn delete_proxmox_snapshot(
|
|||||||
snapshot_name: String,
|
snapshot_name: String,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<(), String> {
|
) -> 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 = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
|
||||||
let client_guard = client.lock().await;
|
let client_guard = client.lock().await;
|
||||||
|
|
||||||
@ -2571,6 +2583,8 @@ pub async fn rollback_proxmox_snapshot(
|
|||||||
snapshot_name: String,
|
snapshot_name: String,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<(), String> {
|
) -> 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 = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
|
||||||
let client_guard = client.lock().await;
|
let client_guard = client.lock().await;
|
||||||
|
|
||||||
@ -2584,6 +2598,73 @@ pub async fn rollback_proxmox_snapshot(
|
|||||||
.await
|
.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<Vec<serde_json::Value>, 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<String, String> {
|
||||||
|
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) ─────────────────────────────────
|
// ─── Phase 13 - Cluster Views (typed aliases) ─────────────────────────────────
|
||||||
|
|
||||||
/// List cluster views (typed)
|
/// List cluster views (typed)
|
||||||
|
|||||||
@ -222,6 +222,8 @@ pub fn run() {
|
|||||||
commands::proxmox::create_proxmox_snapshot,
|
commands::proxmox::create_proxmox_snapshot,
|
||||||
commands::proxmox::delete_proxmox_snapshot,
|
commands::proxmox::delete_proxmox_snapshot,
|
||||||
commands::proxmox::rollback_proxmox_snapshot,
|
commands::proxmox::rollback_proxmox_snapshot,
|
||||||
|
commands::proxmox::list_iso_images,
|
||||||
|
commands::proxmox::upload_iso_image,
|
||||||
// Proxmox - Cluster Views typed (Phase 13)
|
// Proxmox - Cluster Views typed (Phase 13)
|
||||||
commands::proxmox::list_cluster_views,
|
commands::proxmox::list_cluster_views,
|
||||||
commands::proxmox::create_cluster_view,
|
commands::proxmox::create_cluster_view,
|
||||||
|
|||||||
@ -169,13 +169,6 @@ impl ProxmoxClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
headers.insert(
|
|
||||||
reqwest::header::CONTENT_TYPE,
|
|
||||||
"application/x-www-form-urlencoded"
|
|
||||||
.parse()
|
|
||||||
.expect("Invalid content type"),
|
|
||||||
);
|
|
||||||
|
|
||||||
headers
|
headers
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -265,6 +258,28 @@ impl ProxmoxClient {
|
|||||||
self.handle_response(response).await
|
self.handle_response(response).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// POST multipart/form-data to Proxmox API (used for file uploads)
|
||||||
|
pub async fn post_multipart<T: for<'de> Deserialize<'de>>(
|
||||||
|
&self,
|
||||||
|
path: &str,
|
||||||
|
form: reqwest::multipart::Form,
|
||||||
|
ticket: Option<&str>,
|
||||||
|
) -> Result<T> {
|
||||||
|
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
|
/// DELETE request to Proxmox API
|
||||||
pub async fn delete<T: for<'de> Deserialize<'de>>(
|
pub async fn delete<T: for<'de> Deserialize<'de>>(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
@ -40,32 +40,19 @@ pub async fn migrate_vm(
|
|||||||
ticket: &str,
|
ticket: &str,
|
||||||
) -> Result<MigrationTask, String> {
|
) -> Result<MigrationTask, String> {
|
||||||
let path = format!("nodes/{}/qemu/{}/migrate", node, vm_id);
|
let path = format!("nodes/{}/qemu/{}/migrate", node, vm_id);
|
||||||
let params = vec![
|
let body = serde_json::json!({
|
||||||
("target", target_node),
|
"target": target_node,
|
||||||
("targetcluster", target_cluster),
|
"online": 1,
|
||||||
("targetstorage", ""),
|
"force": 0,
|
||||||
("online", "1"),
|
});
|
||||||
("force", "0"),
|
|
||||||
];
|
|
||||||
|
|
||||||
let response: serde_json::Value = client
|
let response: serde_json::Value = client
|
||||||
.post_form(&path, ¶ms, Some(ticket))
|
.post::<serde_json::Value, _>(&path, &body, Some(ticket))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to migrate VM {}: {}", vm_id, e))?;
|
.map_err(|e| format!("Failed to migrate VM {}: {}", vm_id, e))?;
|
||||||
|
|
||||||
{
|
// handle_response unwraps the "data" envelope; migrate returns the task UPID as a string.
|
||||||
let data = &response;
|
let task_id = response.as_str().unwrap_or("").to_string();
|
||||||
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();
|
let start_time = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
|
||||||
|
|
||||||
Ok(MigrationTask {
|
Ok(MigrationTask {
|
||||||
@ -75,13 +62,12 @@ pub async fn migrate_vm(
|
|||||||
target_node: target_node.to_string(),
|
target_node: target_node.to_string(),
|
||||||
source_cluster: client.base_url().to_string(),
|
source_cluster: client.base_url().to_string(),
|
||||||
target_cluster: target_cluster.to_string(),
|
target_cluster: target_cluster.to_string(),
|
||||||
status,
|
status: "running".to_string(),
|
||||||
progress,
|
progress: 0,
|
||||||
start_time,
|
start_time,
|
||||||
end_time: None,
|
end_time: None,
|
||||||
error: None,
|
error: None,
|
||||||
})
|
})
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List migration tasks
|
/// List migration tasks
|
||||||
|
|||||||
@ -47,7 +47,7 @@ pub struct NetworkInterfaceConfig {
|
|||||||
|
|
||||||
/// Helper module for serde bool-as-int conversion (Proxmox API expects 0/1)
|
/// Helper module for serde bool-as-int conversion (Proxmox API expects 0/1)
|
||||||
mod serde_bool_as_int {
|
mod serde_bool_as_int {
|
||||||
use serde::{Deserialize, Deserializer, Serializer};
|
use serde::{Deserializer, Serializer};
|
||||||
|
|
||||||
pub fn serialize<S>(value: &bool, serializer: S) -> Result<S::Ok, S::Error>
|
pub fn serialize<S>(value: &bool, serializer: S) -> Result<S::Ok, S::Error>
|
||||||
where
|
where
|
||||||
@ -60,8 +60,29 @@ mod serde_bool_as_int {
|
|||||||
where
|
where
|
||||||
D: Deserializer<'de>,
|
D: Deserializer<'de>,
|
||||||
{
|
{
|
||||||
let value = i8::deserialize(deserializer)?;
|
struct BoolOrInt;
|
||||||
Ok(value != 0)
|
|
||||||
|
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<E: serde::de::Error>(self, v: bool) -> Result<bool, E> {
|
||||||
|
Ok(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visit_i64<E: serde::de::Error>(self, v: i64) -> Result<bool, E> {
|
||||||
|
Ok(v != 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visit_u64<E: serde::de::Error>(self, v: u64) -> Result<bool, E> {
|
||||||
|
Ok(v != 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deserializer.deserialize_any(BoolOrInt)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -35,6 +35,65 @@ pub async fn get_storage_status(
|
|||||||
Err("Not implemented yet".to_string())
|
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<Vec<serde_json::Value>, 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::<Vec<_>>()
|
||||||
|
})
|
||||||
|
.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<u8>,
|
||||||
|
ticket: &str,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@ -4,7 +4,17 @@ import { Button } from '@/components/ui/index';
|
|||||||
import { Input } from '@/components/ui/index';
|
import { Input } from '@/components/ui/index';
|
||||||
import { Label } from '@/components/ui/index';
|
import { Label } from '@/components/ui/index';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } 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';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
interface CreateVmDialogProps {
|
interface CreateVmDialogProps {
|
||||||
@ -25,9 +35,15 @@ const OS_TYPES = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export function CreateVmDialog({ isOpen, clusterId, onClose, onCreated }: CreateVmDialogProps) {
|
export function CreateVmDialog({ isOpen, clusterId, onClose, onCreated }: CreateVmDialogProps) {
|
||||||
|
const [clusters, setClusters] = useState<ClusterInfo[]>([]);
|
||||||
|
const [selectedClusterId, setSelectedClusterId] = useState(clusterId);
|
||||||
const [nodes, setNodes] = useState<string[]>([]);
|
const [nodes, setNodes] = useState<string[]>([]);
|
||||||
const [storages, setStorages] = useState<string[]>([]);
|
const [storages, setStorages] = useState<string[]>([]);
|
||||||
|
const [isoStorages, setIsoStorages] = useState<string[]>([]);
|
||||||
|
const [isoImages, setIsoImages] = useState<{ volid: string; name?: string }[]>([]);
|
||||||
|
const [isoStorage, setIsoStorage] = useState('');
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
|
||||||
const [nodeId, setNodeId] = useState('');
|
const [nodeId, setNodeId] = useState('');
|
||||||
const [vmid, setVmid] = useState(100);
|
const [vmid, setVmid] = useState(100);
|
||||||
@ -40,12 +56,22 @@ export function CreateVmDialog({ isOpen, clusterId, onClose, onCreated }: Create
|
|||||||
const [diskSize, setDiskSize] = useState(20);
|
const [diskSize, setDiskSize] = useState(20);
|
||||||
const [netBridge, setNetBridge] = useState('vmbr0');
|
const [netBridge, setNetBridge] = useState('vmbr0');
|
||||||
const [iso, setIso] = useState('');
|
const [iso, setIso] = useState('');
|
||||||
const [isoError, setIsoError] = useState('');
|
|
||||||
|
|
||||||
useEffect(() => {
|
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) => {
|
.then((data) => {
|
||||||
const nodeNames = (data as Array<{ node?: string; status?: string }>)
|
const nodeNames = (data as Array<{ node?: string; status?: string }>)
|
||||||
.filter((n) => n.status === 'online' || n.node)
|
.filter((n) => n.status === 'online' || n.node)
|
||||||
@ -55,42 +81,77 @@ export function CreateVmDialog({ isOpen, clusterId, onClose, onCreated }: Create
|
|||||||
setNodeId(nodeNames[0] ?? '');
|
setNodeId(nodeNames[0] ?? '');
|
||||||
})
|
})
|
||||||
.catch(() => toast.error('Failed to load cluster nodes'));
|
.catch(() => toast.error('Failed to load cluster nodes'));
|
||||||
|
}, [isOpen, selectedClusterId]);
|
||||||
|
|
||||||
listProxmoxDatastores(clusterId)
|
useEffect(() => {
|
||||||
|
if (!isOpen || !selectedClusterId || !nodeId) return;
|
||||||
|
|
||||||
|
listProxmoxStorages(selectedClusterId, nodeId)
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
const storageIds = (data as Array<{ storage?: string }>)
|
const storageIds = data.map((s) => s.storage).filter(Boolean);
|
||||||
.map((s) => s.storage ?? '')
|
|
||||||
.filter(Boolean);
|
|
||||||
setStorages(storageIds);
|
setStorages(storageIds);
|
||||||
setStorage(storageIds[0] ?? 'local-lvm');
|
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(() => {
|
.catch(() => {
|
||||||
setStorages(['local-lvm', 'local']);
|
setStorages(['local-lvm', 'local']);
|
||||||
setStorage('local-lvm');
|
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 => {
|
listIsoImages(selectedClusterId, nodeId, isoStorage)
|
||||||
if (!value) return '';
|
.then((imgs) => {
|
||||||
return ISO_RE.test(value) ? '' : "Must be in the format 'storage:iso/filename'";
|
setIsoImages(imgs);
|
||||||
};
|
})
|
||||||
|
.catch(() => setIsoImages([]));
|
||||||
|
}, [isOpen, selectedClusterId, nodeId, isoStorage]);
|
||||||
|
|
||||||
const handleIsoChange = (value: string) => {
|
const handleUploadIso = async () => {
|
||||||
setIso(value);
|
if (!selectedClusterId || !nodeId || !isoStorage) {
|
||||||
setIsoError(validateIso(value));
|
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 () => {
|
const handleSubmit = async () => {
|
||||||
if (!nodeId) { toast.error('Please select a target node'); return; }
|
if (!nodeId) { toast.error('Please select a target node'); return; }
|
||||||
if (!name.trim()) { toast.error('VM name is required'); 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 (vmid < 100 || vmid > 999999999) { toast.error('VMID must be between 100 and 999999999'); return; }
|
||||||
if (isoError) { toast.error(isoError); return; }
|
|
||||||
|
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await createProxmoxVm(clusterId, {
|
await createProxmoxVm(selectedClusterId, {
|
||||||
nodeId,
|
nodeId,
|
||||||
vmid,
|
vmid,
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
@ -101,7 +162,7 @@ export function CreateVmDialog({ isOpen, clusterId, onClose, onCreated }: Create
|
|||||||
storage,
|
storage,
|
||||||
diskSize,
|
diskSize,
|
||||||
netBridge,
|
netBridge,
|
||||||
iso: iso.trim() || undefined,
|
iso: iso || undefined,
|
||||||
});
|
});
|
||||||
toast.success(`VM "${name}" created successfully (VMID: ${vmid})`);
|
toast.success(`VM "${name}" created successfully (VMID: ${vmid})`);
|
||||||
onCreated();
|
onCreated();
|
||||||
@ -123,10 +184,14 @@ export function CreateVmDialog({ isOpen, clusterId, onClose, onCreated }: Create
|
|||||||
setDiskSize(20);
|
setDiskSize(20);
|
||||||
setNetBridge('vmbr0');
|
setNetBridge('vmbr0');
|
||||||
setIso('');
|
setIso('');
|
||||||
setIsoError('');
|
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isoLabel = (volid: string, imgName?: string) => {
|
||||||
|
const filename = imgName ?? volid.split('/').pop() ?? volid;
|
||||||
|
return filename;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||||
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
|
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
|
||||||
@ -135,9 +200,25 @@ export function CreateVmDialog({ isOpen, clusterId, onClose, onCreated }: Create
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4 py-2">
|
<div className="space-y-4 py-2">
|
||||||
|
{clusters.length > 1 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Datacenter / Cluster</Label>
|
||||||
|
<Select value={selectedClusterId} onValueChange={setSelectedClusterId}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select cluster" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{clusters.map((c) => (
|
||||||
|
<SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label htmlFor="vm-node">Node</Label>
|
<Label>Node</Label>
|
||||||
<Select value={nodeId} onValueChange={setNodeId}>
|
<Select value={nodeId} onValueChange={setNodeId}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select node" />
|
<SelectValue placeholder="Select node" />
|
||||||
@ -173,7 +254,7 @@ export function CreateVmDialog({ isOpen, clusterId, onClose, onCreated }: Create
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label htmlFor="vm-ostype">OS Type</Label>
|
<Label>OS Type</Label>
|
||||||
<Select value={osType} onValueChange={setOsType}>
|
<Select value={osType} onValueChange={setOsType}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
@ -224,7 +305,7 @@ export function CreateVmDialog({ isOpen, clusterId, onClose, onCreated }: Create
|
|||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label htmlFor="vm-storage">Storage</Label>
|
<Label>Storage</Label>
|
||||||
{storages.length > 0 ? (
|
{storages.length > 0 ? (
|
||||||
<Select value={storage} onValueChange={setStorage}>
|
<Select value={storage} onValueChange={setStorage}>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
@ -238,7 +319,6 @@ export function CreateVmDialog({ isOpen, clusterId, onClose, onCreated }: Create
|
|||||||
</Select>
|
</Select>
|
||||||
) : (
|
) : (
|
||||||
<Input
|
<Input
|
||||||
id="vm-storage"
|
|
||||||
value={storage}
|
value={storage}
|
||||||
onChange={(e) => setStorage(e.target.value)}
|
onChange={(e) => setStorage(e.target.value)}
|
||||||
placeholder="local-lvm"
|
placeholder="local-lvm"
|
||||||
@ -267,28 +347,75 @@ export function CreateVmDialog({ isOpen, clusterId, onClose, onCreated }: Create
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="vm-iso">ISO Image (optional)</Label>
|
<div className="flex items-center justify-between">
|
||||||
<Input
|
<Label>ISO Image (optional)</Label>
|
||||||
id="vm-iso"
|
{isoStorage && (
|
||||||
value={iso}
|
<Button
|
||||||
onChange={(e) => handleIsoChange(e.target.value)}
|
type="button"
|
||||||
placeholder="local:iso/ubuntu-24.04.iso"
|
variant="outline"
|
||||||
className={isoError ? 'border-red-500' : ''}
|
size="sm"
|
||||||
/>
|
onClick={() => void handleUploadIso()}
|
||||||
{isoError ? (
|
disabled={isUploading || !nodeId}
|
||||||
<p className="text-xs text-red-500">{isoError}</p>
|
className="h-7 text-xs"
|
||||||
) : (
|
>
|
||||||
<p className="text-xs text-muted-foreground">Format: storage:iso/filename</p>
|
<Upload className="mr-1.5 h-3 w-3" />
|
||||||
|
{isUploading ? 'Uploading...' : 'Upload ISO'}
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{isoStorages.length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs text-muted-foreground">Storage</Label>
|
||||||
|
<Select value={isoStorage} onValueChange={setIsoStorage}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select ISO storage" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{isoStorages.map((s) => (
|
||||||
|
<SelectItem key={s} value={s}>{s}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isoImages.length > 0 ? (
|
||||||
|
<Select value={iso} onValueChange={setIso}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select ISO (optional)" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="">— None —</SelectItem>
|
||||||
|
{isoImages.map((img) => (
|
||||||
|
<SelectItem key={img.volid} value={img.volid}>
|
||||||
|
{isoLabel(img.volid, img.name)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
value={iso}
|
||||||
|
onChange={(e) => setIso(e.target.value)}
|
||||||
|
placeholder="local:iso/ubuntu-24.04.iso"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{isoImages.length > 0
|
||||||
|
? `${isoImages.length} ISO(s) available`
|
||||||
|
: 'Format: storage:iso/filename'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={handleClose} disabled={isSubmitting}>
|
<Button variant="outline" onClick={handleClose} disabled={isSubmitting}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSubmit} disabled={isSubmitting || !nodeId || !name.trim() || !!isoError}>
|
<Button
|
||||||
|
onClick={() => void handleSubmit()}
|
||||||
|
disabled={isSubmitting || !nodeId || !name.trim()}
|
||||||
|
>
|
||||||
{isSubmitting ? 'Creating...' : 'Create VM'}
|
{isSubmitting ? 'Creating...' : 'Create VM'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import { Input } from '@/components/ui/index';
|
|||||||
import { AlertCircle } from 'lucide-react';
|
import { AlertCircle } from 'lucide-react';
|
||||||
import { Alert, AlertDescription } from '@/components/ui/index';
|
import { Alert, AlertDescription } from '@/components/ui/index';
|
||||||
import type { ClusterInfo } from '@/lib/domain';
|
import type { ClusterInfo } from '@/lib/domain';
|
||||||
|
import type { ProxmoxSnapshot } from '@/lib/proxmoxClient';
|
||||||
|
|
||||||
interface VMInfo {
|
interface VMInfo {
|
||||||
id: string;
|
id: string;
|
||||||
@ -30,15 +31,6 @@ interface VMInfo {
|
|||||||
tags?: string[];
|
tags?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProxmoxSnapshot {
|
|
||||||
snapname: string;
|
|
||||||
vmid: number;
|
|
||||||
name?: string;
|
|
||||||
ctime: number;
|
|
||||||
parent?: string;
|
|
||||||
description?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RawVMInfo {
|
interface RawVMInfo {
|
||||||
id: number;
|
id: number;
|
||||||
vmid?: number;
|
vmid?: number;
|
||||||
|
|||||||
@ -1041,6 +1041,15 @@ export const deleteNetworkInterface = async (
|
|||||||
|
|
||||||
// ─── VM Snapshots ─────────────────────────────────────────────────────────────
|
// ─── VM Snapshots ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ProxmoxSnapshot {
|
||||||
|
snapname: string;
|
||||||
|
vmid: number;
|
||||||
|
name?: string;
|
||||||
|
ctime: number;
|
||||||
|
parent?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List snapshots for a VM
|
* List snapshots for a VM
|
||||||
* @param clusterId - Cluster identifier
|
* @param clusterId - Cluster identifier
|
||||||
@ -1051,8 +1060,8 @@ export const listProxmoxSnapshots = async (
|
|||||||
clusterId: string,
|
clusterId: string,
|
||||||
nodeId: string,
|
nodeId: string,
|
||||||
vmid: number
|
vmid: number
|
||||||
): Promise<any[]> =>
|
): Promise<ProxmoxSnapshot[]> =>
|
||||||
invoke<any[]>("list_proxmox_snapshots", { clusterId, nodeId, vmid });
|
invoke<ProxmoxSnapshot[]>("list_proxmox_snapshots", { clusterId, nodeId, vmid });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a snapshot for a VM
|
* Create a snapshot for a VM
|
||||||
@ -1187,3 +1196,63 @@ export const listClusterTasks = async (
|
|||||||
clusterId,
|
clusterId,
|
||||||
limit: limit ?? 50,
|
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<string> =>
|
||||||
|
invoke<string>("upload_iso_image", {
|
||||||
|
clusterId,
|
||||||
|
nodeId,
|
||||||
|
storageId,
|
||||||
|
filePath,
|
||||||
|
});
|
||||||
|
|||||||
@ -2,30 +2,55 @@ import React, { useState, useEffect, useCallback } from 'react';
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/index';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/index';
|
||||||
import { Button } from '@/components/ui/index';
|
import { Button } from '@/components/ui/index';
|
||||||
import { Badge } 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 { Input } from '@/components/ui/index';
|
||||||
import { Label } 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 { 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';
|
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() {
|
export function ProxmoxNetworkPage() {
|
||||||
const [interfaces, setInterfaces] = useState<NetworkInterface[]>([]);
|
const [interfaces, setInterfaces] = useState<NetworkInterface[]>([]);
|
||||||
const [clusterId, setClusterId] = useState('');
|
const [clusterId, setClusterId] = useState('');
|
||||||
const [nodeId] = useState('localhost');
|
const [nodeId] = useState('localhost');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [showAddDialog, setShowAddDialog] = useState(false);
|
|
||||||
const [editingInterface] = useState<NetworkInterface | null>(null);
|
|
||||||
|
|
||||||
// Form state
|
const [showDialog, setShowDialog] = useState(false);
|
||||||
const [ifaceName, setIfaceName] = useState('');
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [ifaceType, setIfaceType] = useState('eth');
|
const [editingInterface, setEditingInterface] = useState<NetworkInterface | null>(null);
|
||||||
const [address, setAddress] = useState('');
|
const [form, setForm] = useState<FormState>(defaultForm);
|
||||||
const [netmask, setNetmask] = useState('');
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [gateway, setGateway] = useState('');
|
|
||||||
const [active, setActive] = useState(true);
|
|
||||||
|
|
||||||
const loadInterfaces = useCallback(async (cId: string, nId: string) => {
|
const loadInterfaces = useCallback(async (cId: string, nId: string) => {
|
||||||
if (!cId) return;
|
if (!cId) return;
|
||||||
@ -52,24 +77,69 @@ export function ProxmoxNetworkPage() {
|
|||||||
.catch(console.error);
|
.catch(console.error);
|
||||||
}, [loadInterfaces, nodeId]);
|
}, [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 = () => {
|
const handleAddInterface = () => {
|
||||||
toast.warning(NOT_IMPLEMENTED_MSG);
|
setIsEditing(false);
|
||||||
|
setEditingInterface(null);
|
||||||
|
setForm(defaultForm);
|
||||||
|
setShowDialog(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEditInterface = (_iface: NetworkInterface) => {
|
const handleEditInterface = (iface: NetworkInterface) => {
|
||||||
toast.warning(NOT_IMPLEMENTED_MSG);
|
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 () => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
toast.warning(NOT_IMPLEMENTED_MSG);
|
e.preventDefault();
|
||||||
setShowAddDialog(false);
|
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,
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteInterface = async (_iface: NetworkInterface) => {
|
setSubmitting(true);
|
||||||
toast.warning(NOT_IMPLEMENTED_MSG);
|
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) => {
|
||||||
|
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 (
|
return (
|
||||||
@ -79,15 +149,25 @@ export function ProxmoxNetworkPage() {
|
|||||||
<h1 className="text-2xl font-bold">Network</h1>
|
<h1 className="text-2xl font-bold">Network</h1>
|
||||||
<p className="text-muted-foreground">Network interfaces and bridges</p>
|
<p className="text-muted-foreground">Network interfaces and bridges</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button variant="outline" size="sm" onClick={() => void loadInterfaces(clusterId, nodeId)} disabled={loading || !clusterId}>
|
<Button
|
||||||
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
variant="outline"
|
||||||
Refresh
|
size="sm"
|
||||||
</Button>
|
onClick={handleAddInterface}
|
||||||
<Button size="sm" onClick={handleAddInterface}>
|
disabled={!clusterId}
|
||||||
|
>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Add Interface
|
Add Interface
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void loadInterfaces(clusterId, nodeId)}
|
||||||
|
disabled={loading || !clusterId}
|
||||||
|
>
|
||||||
|
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -143,21 +223,24 @@ export function ProxmoxNetworkPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
<button
|
<Button
|
||||||
className="rounded p-1 hover:bg-accent"
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
onClick={() => handleEditInterface(iface)}
|
onClick={() => handleEditInterface(iface)}
|
||||||
title="Edit"
|
title="Edit"
|
||||||
>
|
>
|
||||||
<Edit className="h-4 w-4" />
|
<Pencil className="h-4 w-4" />
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
className="rounded p-1 hover:bg-red-100 hover:text-red-600"
|
variant="ghost"
|
||||||
onClick={() => handleDeleteInterface(iface)}
|
size="sm"
|
||||||
|
onClick={() => void handleDeleteInterface(iface)}
|
||||||
title="Delete"
|
title="Delete"
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -166,85 +249,113 @@ export function ProxmoxNetworkPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Dialog open={showAddDialog} onOpenChange={setShowAddDialog}>
|
<Dialog open={showDialog} onOpenChange={setShowDialog}>
|
||||||
<DialogContent>
|
<DialogContent className="max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{editingInterface ? 'Edit Network Interface' : 'Add Network Interface'}</DialogTitle>
|
<DialogTitle>{isEditing ? 'Edit Interface' : 'Add Interface'}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4 py-4">
|
<form onSubmit={(e) => void handleSubmit(e)} className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="iface">Interface Name</Label>
|
<Label htmlFor="ifaceName">Interface Name</Label>
|
||||||
<Input
|
<Input
|
||||||
id="iface"
|
id="ifaceName"
|
||||||
value={ifaceName}
|
value={form.ifaceName}
|
||||||
onChange={(e) => setIfaceName(e.target.value)}
|
onChange={(e) => setForm((f) => ({ ...f, ifaceName: e.target.value }))}
|
||||||
placeholder="eth0"
|
placeholder="e.g. vmbr0"
|
||||||
|
disabled={isEditing || submitting}
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="type">Interface Type</Label>
|
<Label>Type</Label>
|
||||||
<Select value={ifaceType} onValueChange={setIfaceType}>
|
<Select
|
||||||
|
value={form.ifaceType}
|
||||||
|
onValueChange={(v) => setForm((f) => ({ ...f, ifaceType: v }))}
|
||||||
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select interface type" />
|
<SelectValue placeholder="Select type" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="eth">eth — Ethernet</SelectItem>
|
<SelectItem value="eth">eth</SelectItem>
|
||||||
<SelectItem value="bond">bond — Network Bond</SelectItem>
|
<SelectItem value="bridge">bridge</SelectItem>
|
||||||
<SelectItem value="bridge">bridge — Linux Bridge</SelectItem>
|
<SelectItem value="bond">bond</SelectItem>
|
||||||
<SelectItem value="vlan">vlan — VLAN</SelectItem>
|
<SelectItem value="vlan">vlan</SelectItem>
|
||||||
<SelectItem value="OVSBridge">OVSBridge — Open vSwitch Bridge</SelectItem>
|
<SelectItem value="OVSBridge">OVS Bridge</SelectItem>
|
||||||
<SelectItem value="OVSBond">OVSBond — Open vSwitch Bond</SelectItem>
|
<SelectItem value="OVSBond">OVS Bond</SelectItem>
|
||||||
<SelectItem value="OVSIntPort">OVSIntPort — OVS Internal Port</SelectItem>
|
<SelectItem value="OVSPort">OVS Port</SelectItem>
|
||||||
<SelectItem value="OVSPort">OVSPort — OVS Port</SelectItem>
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="address">IP Address</Label>
|
<Label htmlFor="address">IP Address</Label>
|
||||||
<Input
|
<Input
|
||||||
id="address"
|
id="address"
|
||||||
value={address}
|
value={form.address}
|
||||||
onChange={(e) => setAddress(e.target.value)}
|
onChange={(e) => setForm((f) => ({ ...f, address: e.target.value }))}
|
||||||
placeholder="192.168.1.100"
|
placeholder="e.g. 192.168.1.100"
|
||||||
|
disabled={submitting}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="netmask">Netmask</Label>
|
<Label htmlFor="netmask">Netmask</Label>
|
||||||
<Input
|
<Input
|
||||||
id="netmask"
|
id="netmask"
|
||||||
value={netmask}
|
value={form.netmask}
|
||||||
onChange={(e) => setNetmask(e.target.value)}
|
onChange={(e) => setForm((f) => ({ ...f, netmask: e.target.value }))}
|
||||||
placeholder="24"
|
placeholder="e.g. 255.255.255.0"
|
||||||
|
disabled={submitting}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="gateway">Gateway</Label>
|
<Label htmlFor="gateway">Gateway</Label>
|
||||||
<Input
|
<Input
|
||||||
id="gateway"
|
id="gateway"
|
||||||
value={gateway}
|
value={form.gateway}
|
||||||
onChange={(e) => setGateway(e.target.value)}
|
onChange={(e) => setForm((f) => ({ ...f, gateway: e.target.value }))}
|
||||||
placeholder="192.168.1.1"
|
placeholder="e.g. 192.168.1.1"
|
||||||
|
disabled={submitting}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<input
|
<div className="flex items-center gap-4">
|
||||||
type="checkbox"
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="autostart"
|
||||||
|
checked={form.autostart}
|
||||||
|
onCheckedChange={(v) => setForm((f) => ({ ...f, autostart: v as boolean }))}
|
||||||
|
disabled={submitting}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="autostart">Autostart</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
id="active"
|
id="active"
|
||||||
checked={active}
|
checked={form.active}
|
||||||
onChange={(e) => setActive(e.target.checked)}
|
onCheckedChange={(v) => setForm((f) => ({ ...f, active: v as boolean }))}
|
||||||
className="rounded"
|
disabled={submitting}
|
||||||
/>
|
/>
|
||||||
<Label htmlFor="active">Active</Label>
|
<Label htmlFor="active">Active</Label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setShowAddDialog(false)}>
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowDialog(false)}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSubmit}>
|
<Button type="submit" disabled={submitting}>
|
||||||
{editingInterface ? 'Update' : 'Create'}
|
{submitting ? 'Saving...' : isEditing ? 'Update' : 'Create'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user