diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6c46074..9cbc747 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -659,10 +659,12 @@ async fn get_stored_credentials( } // Console proxy types + #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct VNCProxyResponse { pub ticket: String, + #[serde(deserialize_with = "proxmox::de_u32_lenient")] pub port: u32, pub cert: String, } @@ -671,6 +673,7 @@ pub struct VNCProxyResponse { #[serde(rename_all = "camelCase")] pub struct TermProxyResponse { pub ticket: String, + #[serde(deserialize_with = "proxmox::de_u32_lenient")] pub port: u32, } diff --git a/src-tauri/src/proxmox.rs b/src-tauri/src/proxmox.rs index b7f6a1e..90c8d65 100644 --- a/src-tauri/src/proxmox.rs +++ b/src-tauri/src/proxmox.rs @@ -1,5 +1,122 @@ use serde::{Deserialize, Serialize}; +/// Deserializes an integer that Proxmox reports either as a JSON number +/// (integer or integral float) or as a numeric string, inconsistently across +/// versions and endpoints. +pub(crate) fn de_u32_lenient<'de, D>(deserializer: D) -> std::result::Result +where + D: serde::Deserializer<'de>, +{ + struct U32Visitor; + + impl<'de> serde::de::Visitor<'de> for U32Visitor { + type Value = u32; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("an integer, an integral number, or a numeric string") + } + + fn visit_u64(self, value: u64) -> std::result::Result { + u32::try_from(value).map_err(|_| E::custom(format!("value {} out of range", value))) + } + + fn visit_i64(self, value: i64) -> std::result::Result { + u32::try_from(value).map_err(|_| E::custom(format!("value {} out of range", value))) + } + + fn visit_f64(self, value: f64) -> std::result::Result { + if value.fract() == 0.0 && value >= 0.0 && value <= u32::MAX as f64 { + Ok(value as u32) + } else { + Err(E::custom(format!("value {} is not a valid integer", value))) + } + } + + fn visit_str(self, value: &str) -> std::result::Result { + value + .parse::() + .map_err(|_| E::custom(format!("invalid integer string '{}'", value))) + } + } + + deserializer.deserialize_any(U32Visitor) +} + +/// Deserializes a `u64` that Proxmox reports either as a JSON number (integer +/// or integral float) or as a numeric string. +pub(crate) fn de_u64_lenient<'de, D>(deserializer: D) -> std::result::Result +where + D: serde::Deserializer<'de>, +{ + struct U64Visitor; + + impl<'de> serde::de::Visitor<'de> for U64Visitor { + type Value = u64; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("an integer, an integral number, or a numeric string") + } + + fn visit_u64(self, value: u64) -> std::result::Result { + Ok(value) + } + + fn visit_i64(self, value: i64) -> std::result::Result { + u64::try_from(value).map_err(|_| E::custom(format!("value {} out of range", value))) + } + + fn visit_f64(self, value: f64) -> std::result::Result { + if value.fract() == 0.0 && value >= 0.0 && value <= u64::MAX as f64 { + Ok(value as u64) + } else { + Err(E::custom(format!("value {} is not a valid integer", value))) + } + } + + fn visit_str(self, value: &str) -> std::result::Result { + value + .parse::() + .map_err(|_| E::custom(format!("invalid integer string '{}'", value))) + } + } + + deserializer.deserialize_any(U64Visitor) +} + +/// Deserializes an optional `u64` that may be absent, null, a JSON number +/// (integer or integral float), or a numeric string. +pub(crate) fn de_u64_opt_lenient<'de, D>(deserializer: D) -> std::result::Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + struct OptU64Visitor; + + impl<'de> serde::de::Visitor<'de> for OptU64Visitor { + type Value = Option; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("an integer, an integral number, a numeric string, or null") + } + + fn visit_none(self) -> std::result::Result, E> { + Ok(None) + } + + fn visit_unit(self) -> std::result::Result, E> { + Ok(None) + } + + fn visit_some(self, inner: D) -> std::result::Result, D::Error> + where + D: serde::Deserializer<'de>, + { + de_u64_lenient(inner).map(Some) + } + } + + deserializer.deserialize_option(OptU64Visitor) +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Disk { @@ -28,7 +145,7 @@ pub struct Node { pub status: String, #[serde(default)] pub cpu: f64, - #[serde(default)] + #[serde(default, deserialize_with = "de_u32_lenient")] pub maxcpu: u32, #[serde(default)] pub mem: u64, @@ -63,7 +180,7 @@ pub struct VM { pub node: String, #[serde(default)] pub cpu: f64, - #[serde(default)] + #[serde(default, deserialize_with = "de_u32_lenient")] pub cpus: u32, #[serde(default)] pub mem: u64, @@ -315,11 +432,11 @@ pub struct RestoreConfig { pub struct StorageContent { #[serde(default)] pub content: String, - #[serde(default)] + #[serde(default, deserialize_with = "de_u64_lenient")] pub ctime: u64, #[serde(default)] pub format: Option, - #[serde(default)] + #[serde(default, deserialize_with = "de_u64_opt_lenient")] pub size: Option, #[serde(default)] pub subtype: Option, @@ -334,17 +451,17 @@ pub struct StorageDetail { pub storage: String, pub r#type: String, pub content: String, - #[serde(default)] + #[serde(default, deserialize_with = "de_u32_lenient")] pub active: u32, - #[serde(default)] + #[serde(default, deserialize_with = "de_u32_lenient")] pub enabled: u32, - #[serde(default)] + #[serde(default, deserialize_with = "de_u32_lenient")] pub shared: u32, - #[serde(default)] + #[serde(default, deserialize_with = "de_u64_lenient")] pub used: u64, - #[serde(default)] + #[serde(default, deserialize_with = "de_u64_lenient")] pub total: u64, - #[serde(default)] + #[serde(default, deserialize_with = "de_u64_lenient")] pub avail: u64, #[serde(default)] pub node: String, diff --git a/src-tauri/tests/api_commands.rs b/src-tauri/tests/api_commands.rs index ad6dbe8..90debd1 100644 --- a/src-tauri/tests/api_commands.rs +++ b/src-tauri/tests/api_commands.rs @@ -155,6 +155,128 @@ async fn get_vms_uses_cluster_resources() { mock.assert(); } +#[tokio::test] +async fn get_nodes_accepts_string_and_float_maxcpu() { + // Some Proxmox versions report maxcpu as a numeric string or an integral + // float instead of an integer. + let server = MockServer::start(); + let token = "root@pam!nodes-token"; + let mock = server.mock(|when, then| { + when.method(GET) + .path("/api2/json/nodes") + .header("Authorization", format!("PVEAPIToken={}", token)); + then.status(200) + .header("content-type", "application/json") + .body( + serde_json::json!({ + "data": [ + {"node": "pve1", "status": "online", "cpu": 0.12, "maxcpu": "16", + "mem": 8589934592u64, "maxmem": 68719476736u64, "disk": 214748364800u64, + "maxdisk": 858993459200u64, "uptime": 123456, "level": "", + "id": "node/pve1", "type": "node"}, + {"node": "pve2", "status": "online", "cpu": 0.0, "maxcpu": 8.0, + "mem": 0, "maxmem": 34359738368u64, "disk": 0, + "maxdisk": 536870912000u64, "uptime": 0, "level": "", + "id": "node/pve2", "type": "node"} + ] + }) + .to_string(), + ); + }); + + let (manager, _dir) = setup_manager(&server.base_url(), token, None).await; + let nodes = manager + .get_nodes("conn") + .await + .expect("nodes should be fetched"); + + assert_eq!(nodes[0].maxcpu, 16); + assert_eq!(nodes[1].maxcpu, 8); + mock.assert(); +} + +#[tokio::test] +async fn get_vms_accepts_string_and_float_cpus() { + // Some Proxmox versions report cpus as a numeric string or an integral + // float instead of an integer. + let server = MockServer::start(); + let token = "root@pam!vms-token"; + let mock = server.mock(|when, then| { + when.method(GET) + .path("/api2/json/cluster/resources") + .query_param("type", "vm") + .header("Authorization", format!("PVEAPIToken={}", token)); + then.status(200) + .header("content-type", "application/json") + .body( + serde_json::json!({ + "data": [ + {"vmid": 100, "name": "web01", "type": "qemu", "status": "running", + "node": "pve1", "cpu": 0.03, "cpus": "2", "maxcpu": "2", + "mem": 2147483648u64, "maxmem": 4294967296u64, "disk": 107374182400u64, + "maxdisk": 34359738368u64, "uptime": 99999, "netin": 123, "netout": 456, + "diskread": 789, "diskwrite": 101112, "template": 0, "tags": "prod", + "pid": 1234}, + {"vmid": 201, "name": "ct01", "type": "lxc", "status": "running", + "node": "pve2", "cpu": 0.01, "cpus": 4.0, "maxcpu": 4.0, + "mem": 1073741824u64, "maxmem": 2147483648u64, "disk": 107374182400u64, + "maxdisk": 34359738368u64, "uptime": 123, "netin": 1, "netout": 2, + "diskread": 3, "diskwrite": 4, "template": 0, "tags": "", "pid": 5678} + ] + }) + .to_string(), + ); + }); + + let (manager, _dir) = setup_manager(&server.base_url(), token, None).await; + let vms = manager + .get_vms("conn") + .await + .expect("VMs should be fetched"); + + assert_eq!(vms[0].cpus, 2); + assert_eq!(vms[1].cpus, 4); + mock.assert(); +} + +#[tokio::test] +async fn get_vms_rejects_non_numeric_cpus() { + let server = MockServer::start(); + let token = "root@pam!vms-token"; + let mock = server.mock(|when, then| { + when.method(GET) + .path("/api2/json/cluster/resources") + .query_param("type", "vm") + .header("Authorization", format!("PVEAPIToken={}", token)); + then.status(200) + .header("content-type", "application/json") + .body( + serde_json::json!({ + "data": [ + {"vmid": 100, "name": "web01", "type": "qemu", "status": "running", + "node": "pve1", "cpu": 0.03, "cpus": "many", "maxcpu": 2, + "mem": 2147483648u64, "maxmem": 4294967296u64, "disk": 107374182400u64, + "maxdisk": 34359738368u64, "uptime": 99999, "netin": 0, "netout": 0, + "diskread": 0, "diskwrite": 0, "template": 0, "tags": "prod"} + ] + }) + .to_string(), + ); + }); + + let (manager, _dir) = setup_manager(&server.base_url(), token, None).await; + let error = manager + .get_vms("conn") + .await + .expect_err("non-numeric cpus must be rejected"); + assert!( + matches!(error, Error::SerializationError(ref message) if message.contains("many")), + "expected SerializationError mentioning the cpus value, got: {}", + error + ); + mock.assert(); +} + #[tokio::test] async fn get_storage_maps_cluster_resources() { let server = MockServer::start(); @@ -253,6 +375,45 @@ async fn get_storage_content_uses_configured_node() { mock.assert(); } +#[tokio::test] +async fn get_storage_content_accepts_string_metadata() { + // Some Proxmox versions report ctime/size as numeric strings instead of + // integers; the list must still parse. + let server = MockServer::start(); + let token = "root@pam!content-token"; + let mock = server.mock(|when, then| { + when.method(GET) + .path("/api2/json/nodes/pve1/storage/local/content"); + then.status(200) + .header("content-type", "application/json") + .body( + serde_json::json!({ + "data": [ + {"volid": "local:iso/debian-12.iso", "content": "iso", + "ctime": "1700000000", "format": "iso", "size": "68719476736"}, + {"volid": "local:vztmpl/ubuntu-22.tar.xz", "content": "vztmpl", + "ctime": 1700000001.0, "format": "tgz"} + ] + }) + .to_string(), + ); + }); + + let (manager, _dir) = setup_manager(&server.base_url(), token, Some("pve1")).await; + let contents = manager + .get_storage_content("conn", "local", Some("pve1")) + .await + .expect("content should be fetched"); + + assert_eq!(contents.len(), 2); + assert_eq!(contents[0].ctime, 1700000000); + assert_eq!(contents[0].size, Some(68719476736u64)); + // Integral floats are accepted too; missing size stays None. + assert_eq!(contents[1].ctime, 1700000001); + assert_eq!(contents[1].size, None); + mock.assert(); +} + #[tokio::test] async fn get_storage_content_falls_back_to_online_node() { let server = MockServer::start(); diff --git a/src-tauri/tests/api_console.rs b/src-tauri/tests/api_console.rs index 14cae04..2263b27 100644 --- a/src-tauri/tests/api_console.rs +++ b/src-tauri/tests/api_console.rs @@ -110,6 +110,108 @@ async fn create_term_proxy_maps_response() { mock.assert(); } +#[tokio::test] +async fn create_vnc_proxy_accepts_string_port() { + // Some Proxmox versions report the proxy port as a JSON string. + let server = MockServer::start(); + let token = "root@pam!vnc-token"; + let mock = server.mock(|when, then| { + when.method(POST) + .path("/api2/json/nodes/tatooine/qemu/106/vncproxy") + .header("Authorization", format!("PVEAPIToken={}", token)); + then.status(200) + .header("content-type", "application/json") + .body( + serde_json::json!({ + "data": { + "ticket": "PVE:vnc:abc", + "port": "5901", + "cert": "MIIB..." + } + }) + .to_string(), + ); + }); + + let (manager, _dir) = setup_manager(&server.base_url(), token).await; + let proxy = manager + .create_vnc_proxy("conn", "tatooine", 106) + .await + .expect("vnc proxy should be created"); + + assert_eq!(proxy.ticket, "PVE:vnc:abc"); + assert_eq!(proxy.port, 5901); + assert_eq!(proxy.cert, "MIIB..."); + mock.assert(); +} + +#[tokio::test] +async fn create_term_proxy_accepts_string_port() { + let server = MockServer::start(); + let token = "root@pam!term-token"; + let mock = server.mock(|when, then| { + when.method(POST) + .path("/api2/json/nodes/pve1/lxc/201/termproxy") + .header("Authorization", format!("PVEAPIToken={}", token)); + then.status(200) + .header("content-type", "application/json") + .body( + serde_json::json!({ + "data": { + "ticket": "PVE:term:xyz", + "port": "6100" + } + }) + .to_string(), + ); + }); + + let (manager, _dir) = setup_manager(&server.base_url(), token).await; + let proxy = manager + .create_term_proxy("conn", "pve1", 201) + .await + .expect("term proxy should be created"); + + assert_eq!(proxy.ticket, "PVE:term:xyz"); + assert_eq!(proxy.port, 6100); + mock.assert(); +} + +#[tokio::test] +async fn create_vnc_proxy_rejects_invalid_port_string() { + let server = MockServer::start(); + let token = "root@pam!vnc-token"; + let mock = server.mock(|when, then| { + when.method(POST) + .path("/api2/json/nodes/pve1/qemu/100/vncproxy") + .header("Authorization", format!("PVEAPIToken={}", token)); + then.status(200) + .header("content-type", "application/json") + .body( + serde_json::json!({ + "data": { + "ticket": "PVE:vnc:abc", + "port": "not-a-port", + "cert": "MIIB..." + } + }) + .to_string(), + ); + }); + + let (manager, _dir) = setup_manager(&server.base_url(), token).await; + let error = manager + .create_vnc_proxy("conn", "pve1", 100) + .await + .expect_err("non-numeric port string must be rejected"); + assert!( + matches!(error, Error::SerializationError(ref message) if message.contains("not-a-port")), + "expected Serialization mentioning the port string, got: {}", + error + ); + mock.assert(); +} + #[tokio::test] async fn get_websocket_url_converts_https_to_wss() { // Explicit port on https. diff --git a/src/components/storage/StorageDetail.tsx b/src/components/storage/StorageDetail.tsx index 4b326c0..c43424a 100644 --- a/src/components/storage/StorageDetail.tsx +++ b/src/components/storage/StorageDetail.tsx @@ -70,7 +70,7 @@ export function StorageDetail({ connectionId, storage, node, onBack }: StorageDe node, storage ) - const { data: contentList, isLoading: contentLoading } = useStorageContent( + const { data: contentList, isLoading: contentLoading, error: contentError } = useStorageContent( connectionId, node, storage @@ -237,7 +237,13 @@ export function StorageDetail({ connectionId, storage, node, onBack }: StorageDe - {!contentList || contentList.length === 0 ? ( + {contentError ? ( + + ) : !contentList || contentList.length === 0 ? ( ) : (
diff --git a/src/components/vms/VMList.tsx b/src/components/vms/VMList.tsx index dbd3a5a..24a0194 100644 --- a/src/components/vms/VMList.tsx +++ b/src/components/vms/VMList.tsx @@ -14,22 +14,22 @@ interface VMListProps { onVMClick?: (vm: ProxmoxVM) => void } -type VMTypeFilter = 'all' | 'qemu' | 'lxc' type VMStatusFilter = 'all' | ProxmoxVM['status'] export function VMList({ connectionId, onVMClick }: VMListProps) { const { data: vms, isLoading: vmsLoading, error: vmsError } = useVMs(connectionId) const [search, setSearch] = useState('') - const [typeFilter, setTypeFilter] = useState('all') const [statusFilter, setStatusFilter] = useState('all') const [nodeFilter, setNodeFilter] = useState('all') + // Filter to only QEMU VMs — LXC containers have their own view. + const vmsOnly = useMemo(() => { + return vms?.filter((vm) => vm.type === 'qemu') ?? [] + }, [vms]) + const filteredVMs = useMemo(() => { - if (!vms) return [] - return vms.filter((vm) => { - // Type filter - if (typeFilter !== 'all' && vm.type !== typeFilter) return false + return vmsOnly.filter((vm) => { // Status filter if (statusFilter !== 'all' && vm.status !== statusFilter) return false // Node filter @@ -43,12 +43,11 @@ export function VMList({ connectionId, onVMClick }: VMListProps) { } return true }) - }, [vms, typeFilter, statusFilter, nodeFilter, search]) + }, [vmsOnly, statusFilter, nodeFilter, search]) const uniqueNodes = useMemo(() => { - if (!vms) return [] - return [...new Set(vms.map((vm) => vm.node))].sort() - }, [vms]) + return [...new Set(vmsOnly.map((vm) => vm.node))].sort() + }, [vmsOnly]) if (vmsLoading) { return ( @@ -73,7 +72,7 @@ export function VMList({ connectionId, onVMClick }: VMListProps) {

Virtual Machines

- {vms?.length ?? 0} total VMs across the cluster + {vmsOnly.length} total VMs across the cluster

@@ -89,16 +88,6 @@ export function VMList({ connectionId, onVMClick }: VMListProps) { />
- -