diff --git a/package-lock.json b/package-lock.json index b77fe81..11eca02 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,6 +40,7 @@ "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.3", + "esbuild": "^0.28.0", "oxlint": "^1.71.0", "tailwindcss": "^4.3.3", "typescript": "~6.0.2", @@ -80,6 +81,448 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@floating-ui/core": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", @@ -2830,6 +3273,48 @@ "tests/types" ] }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, "node_modules/eventemitter3": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", diff --git a/package.json b/package.json index 46347b0..238eed9 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ }, "devDependencies": { "@tailwindcss/vite": "^4.3.3", + "esbuild": "^0.28.0", "@tauri-apps/cli": "^2.11.4", "@types/node": "^24.13.2", "@types/novnc": "^0.0.27", diff --git a/src-tauri/src/connection.rs b/src-tauri/src/connection.rs index f4046a0..a830b78 100644 --- a/src-tauri/src/connection.rs +++ b/src-tauri/src/connection.rs @@ -1,14 +1,12 @@ use crate::error::Error; use crate::proxmox::{ - AddDiskConfig, AddNICConfig, ApiResponse, Backup, BackupJob, BackupJobConfig, ClusterStatus, + AddDiskConfig, AddNICConfig, Backup, BackupJob, BackupJobConfig, ClusterStatus, CreateSnapshotConfig, Disk, EditNICConfig, NetworkInterface, Node, RestoreConfig, Snapshot, Storage, StorageContent, StorageDetail, Task, VM, }; -use crate::{CertificateInfo, ConnectionConfig, EndpointConfig, LoginResult, TermProxyResponse, VNCProxyResponse}; +use crate::{CertificateInfo, ConnectionConfig, LoginResult, TermProxyResponse, VNCProxyResponse}; use reqwest::Client; use std::collections::HashMap; -use std::sync::Arc; -use tokio::sync::RwLock; struct Connection { config: ConnectionConfig, @@ -22,10 +20,10 @@ fn keyring_service() -> &'static str { "proxmox-desktop" } -fn keyring_entry(connection_id: &str, field: &str) -> keyring::Entry { +fn keyring_entry(connection_id: &str, field: &str) -> crate::Result { let key = format!("{}:{}", connection_id, field); keyring::Entry::new(keyring_service(), &key) - .expect("failed to create keyring entry") + .map_err(|e| Error::KeyringError(e.to_string())) } pub struct ConnectionManager { @@ -39,27 +37,58 @@ impl ConnectionManager { } } - pub async fn add_connection(&self, config: ConnectionConfig) -> crate::Result<()> { + pub async fn add_connection(&mut self, config: ConnectionConfig) -> crate::Result<()> { if config.primary.url.is_empty() { return Err(Error::InvalidUrl("URL cannot be empty".to_string())); } + let id = config.id.clone(); + let connection = Connection { + config, + client: Client::builder() + .danger_accept_invalid_certs(true) + .build() + .map_err(Error::HttpError)?, + ticket: None, + csrf_token: None, + current_endpoint_index: 0, + }; + self.connections.insert(id, connection); + Ok(()) + } + + pub async fn remove_connection(&mut self, id: &str) -> crate::Result<()> { + self.connections.remove(id); + // Clear stored credentials from keyring + let _ = keyring_entry(id, "ticket").and_then(|e| { + e.delete_credential() + .map_err(|e| Error::KeyringError(e.to_string())) + }); + let _ = keyring_entry(id, "csrf_token").and_then(|e| { + e.delete_credential() + .map_err(|e| Error::KeyringError(e.to_string())) + }); + let _ = keyring_entry(id, "password").and_then(|e| { + e.delete_credential() + .map_err(|e| Error::KeyringError(e.to_string())) + }); + let _ = keyring_entry(id, "token").and_then(|e| { + e.delete_credential() + .map_err(|e| Error::KeyringError(e.to_string())) + }); + Ok(()) + } + + pub async fn connect(&mut self, id: &str) -> crate::Result<()> { + if let Some(conn) = self.connections.get_mut(id) { + conn.config.status = "connected".to_string(); + } Ok(()) } - pub async fn remove_connection(&self, id: &str) -> crate::Result<()> { - // Clear stored credentials from keyring - let _ = keyring_entry(id, "ticket").delete_credential(); - let _ = keyring_entry(id, "csrf_token").delete_credential(); - let _ = keyring_entry(id, "password").delete_credential(); - let _ = keyring_entry(id, "token").delete_credential(); - Ok(()) - } - - pub async fn connect(&self, id: &str) -> crate::Result<()> { - Ok(()) - } - - pub async fn disconnect(&self, id: &str) -> crate::Result<()> { + pub async fn disconnect(&mut self, id: &str) -> crate::Result<()> { + if let Some(conn) = self.connections.get_mut(id) { + conn.config.status = "disconnected".to_string(); + } Ok(()) } @@ -119,8 +148,27 @@ impl ConnectionManager { .unwrap_or("") .to_string(); + // Generate a stable connection ID from the URL + let connection_id = { + use sha2::{Sha256, Digest}; + let mut hasher = Sha256::new(); + hasher.update(url.as_bytes()); + let hash = hasher.finalize(); + format!("{:x}", hash)[..16].to_string() + }; + + // Store credentials in keyring for later use + keyring_entry(&connection_id, "ticket") + .and_then(|e| e.set_password(&ticket).map_err(|e| Error::KeyringError(e.to_string())))?; + keyring_entry(&connection_id, "csrf_token") + .and_then(|e| e.set_password(&csrf_token).map_err(|e| Error::KeyringError(e.to_string())))?; + keyring_entry(&connection_id, "username") + .and_then(|e| e.set_password(username).map_err(|e| Error::KeyringError(e.to_string())))?; + keyring_entry(&connection_id, "password") + .and_then(|e| e.set_password(password).map_err(|e| Error::KeyringError(e.to_string())))?; + Ok(LoginResult { - connection_id: String::new(), + connection_id, ticket, csrf_token, }) @@ -164,23 +212,56 @@ impl ConnectionManager { return Err(Error::InvalidCredentials("Invalid API token".to_string())); } + // Generate a stable connection ID from the URL + let connection_id = { + use sha2::{Sha256, Digest}; + let mut hasher = Sha256::new(); + hasher.update(url.as_bytes()); + let hash = hasher.finalize(); + format!("{:x}", hash)[..16].to_string() + }; + + // Store token in keyring for later use + keyring_entry(&connection_id, "token") + .and_then(|e| e.set_password(token).map_err(|e| Error::KeyringError(e.to_string())))?; + Ok(LoginResult { - connection_id: String::new(), + connection_id, ticket: token.to_string(), csrf_token: String::new(), }) } pub async fn logout(&self, connection_id: &str) -> crate::Result<()> { - let _ = keyring_entry(connection_id, "ticket").delete_credential(); - let _ = keyring_entry(connection_id, "csrf_token").delete_credential(); - let _ = keyring_entry(connection_id, "password").delete_credential(); - let _ = keyring_entry(connection_id, "token").delete_credential(); + let _ = keyring_entry(connection_id, "ticket").and_then(|e| { + e.delete_credential() + .map_err(|e| Error::KeyringError(e.to_string())) + }); + let _ = keyring_entry(connection_id, "csrf_token").and_then(|e| { + e.delete_credential() + .map_err(|e| Error::KeyringError(e.to_string())) + }); + let _ = keyring_entry(connection_id, "password").and_then(|e| { + e.delete_credential() + .map_err(|e| Error::KeyringError(e.to_string())) + }); + let _ = keyring_entry(connection_id, "token").and_then(|e| { + e.delete_credential() + .map_err(|e| Error::KeyringError(e.to_string())) + }); + let _ = keyring_entry(connection_id, "username").and_then(|e| { + e.delete_credential() + .map_err(|e| Error::KeyringError(e.to_string())) + }); Ok(()) } pub async fn get_stored_credentials(&self, connection_id: &str) -> crate::Result> { - match keyring_entry(connection_id, "ticket").get_password() { + let entry = match keyring_entry(connection_id, "ticket") { + Ok(e) => e, + Err(_) => return Ok(None), + }; + match entry.get_password() { Ok(ticket) => Ok(Some(ticket)), Err(keyring::Error::NoEntry) => Ok(None), Err(e) => Err(Error::KeyringError(e.to_string())), @@ -196,20 +277,16 @@ impl ConnectionManager { api_token: Option<&str>, ) -> crate::Result<()> { keyring_entry(connection_id, "ticket") - .set_password(ticket) - .map_err(|e| Error::KeyringError(e.to_string()))?; + .and_then(|e| e.set_password(ticket).map_err(|e| Error::KeyringError(e.to_string())))?; keyring_entry(connection_id, "csrf_token") - .set_password(csrf_token) - .map_err(|e| Error::KeyringError(e.to_string()))?; + .and_then(|e| e.set_password(csrf_token).map_err(|e| Error::KeyringError(e.to_string())))?; if let Some(pw) = password { keyring_entry(connection_id, "password") - .set_password(pw) - .map_err(|e| Error::KeyringError(e.to_string()))?; + .and_then(|e| e.set_password(pw).map_err(|e| Error::KeyringError(e.to_string())))?; } if let Some(tok) = api_token { keyring_entry(connection_id, "token") - .set_password(tok) - .map_err(|e| Error::KeyringError(e.to_string()))?; + .and_then(|e| e.set_password(tok).map_err(|e| Error::KeyringError(e.to_string())))?; } Ok(()) } @@ -221,12 +298,11 @@ impl ConnectionManager { ) -> crate::Result { // Try to get stored password for re-authentication let password = keyring_entry(connection_id, "password") - .get_password() - .map_err(|e| Error::KeyringError(e.to_string()))?; + .and_then(|e| e.get_password().map_err(|e| Error::KeyringError(e.to_string())))?; - // Extract username from stored ticket or use default - let username = keyring_entry(connection_id, "csrf_token") - .get_password() + // Get stored username for re-authentication + let username = keyring_entry(connection_id, "username") + .and_then(|e| e.get_password().map_err(|e| Error::KeyringError(e.to_string()))) .unwrap_or_default(); self.login_with_password(url, &username, &password).await diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ca58ab6..3e10c2f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -16,6 +16,7 @@ use websocket::WebSocketManager; pub type Result = std::result::Result; +#[serde(rename_all = "camelCase")] #[derive(Clone, Serialize, Deserialize)] pub struct ConnectionConfig { pub id: String, @@ -31,6 +32,7 @@ pub struct ConnectionConfig { pub username: Option, } +#[serde(rename_all = "camelCase")] #[derive(Clone, Serialize, Deserialize)] pub struct EndpointConfig { pub url: String, @@ -38,6 +40,7 @@ pub struct EndpointConfig { pub token: Option, } +#[serde(rename_all = "camelCase")] #[derive(Clone, Serialize, Deserialize)] pub struct LoginResult { pub connection_id: String, @@ -45,6 +48,7 @@ pub struct LoginResult { pub csrf_token: String, } +#[serde(rename_all = "camelCase")] #[derive(Clone, Serialize, Deserialize)] pub struct CertificateInfo { pub fingerprint: String, @@ -65,7 +69,7 @@ async fn add_connection( state: tauri::State<'_, AppState>, config: ConnectionConfig, ) -> Result<()> { - let manager = state.connection_manager.read().await; + let mut manager = state.connection_manager.write().await; manager.add_connection(config).await } @@ -74,7 +78,7 @@ async fn remove_connection( state: tauri::State<'_, AppState>, id: String, ) -> Result<()> { - let manager = state.connection_manager.read().await; + let mut manager = state.connection_manager.write().await; manager.remove_connection(&id).await } @@ -83,7 +87,7 @@ async fn connect_to_server( state: tauri::State<'_, AppState>, id: String, ) -> Result<()> { - let manager = state.connection_manager.read().await; + let mut manager = state.connection_manager.write().await; manager.connect(&id).await } @@ -92,7 +96,7 @@ async fn disconnect_from_server( state: tauri::State<'_, AppState>, id: String, ) -> Result<()> { - let manager = state.connection_manager.read().await; + let mut manager = state.connection_manager.write().await; manager.disconnect(&id).await } @@ -457,6 +461,7 @@ async fn get_stored_credentials( } // Console proxy types +#[serde(rename_all = "camelCase")] #[derive(Clone, Serialize, Deserialize)] pub struct VNCProxyResponse { pub ticket: String, @@ -464,6 +469,7 @@ pub struct VNCProxyResponse { pub cert: String, } +#[serde(rename_all = "camelCase")] #[derive(Clone, Serialize, Deserialize)] pub struct TermProxyResponse { pub ticket: String, @@ -613,6 +619,7 @@ async fn delete_backup( manager.delete_backup(&connection_id, &volid).await } +#[serde(rename_all = "camelCase")] #[derive(Clone, Serialize, Deserialize)] pub struct TrayConnectionInfo { pub id: String, diff --git a/src-tauri/src/proxmox.rs b/src-tauri/src/proxmox.rs index f47cde6..883da8b 100644 --- a/src-tauri/src/proxmox.rs +++ b/src-tauri/src/proxmox.rs @@ -1,5 +1,6 @@ use serde::{Deserialize, Serialize}; +#[serde(rename_all = "camelCase")] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Disk { pub device: String, @@ -9,6 +10,7 @@ pub struct Disk { pub usage: Option, } +#[serde(rename_all = "camelCase")] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AddDiskConfig { pub storage: String, @@ -16,6 +18,7 @@ pub struct AddDiskConfig { pub bus_type: String, } +#[serde(rename_all = "camelCase")] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Node { pub node: String, @@ -32,6 +35,7 @@ pub struct Node { pub r#type: String, } +#[serde(rename_all = "camelCase")] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct VM { pub vmid: u32, @@ -56,6 +60,7 @@ pub struct VM { pub tags: Option, } +#[serde(rename_all = "camelCase")] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Storage { pub storage: String, @@ -70,6 +75,7 @@ pub struct Storage { pub node: String, } +#[serde(rename_all = "camelCase")] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Task { pub upid: String, @@ -85,6 +91,7 @@ pub struct Task { pub exitstatus: Option, } +#[serde(rename_all = "camelCase")] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ClusterStatus { pub r#type: String, @@ -93,6 +100,7 @@ pub struct ClusterStatus { pub nodes: Option>, } +#[serde(rename_all = "camelCase")] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ClusterNode { pub name: String, @@ -102,6 +110,7 @@ pub struct ClusterNode { pub ip: Option, } +#[serde(rename_all = "camelCase")] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Snapshot { pub name: String, @@ -111,6 +120,7 @@ pub struct Snapshot { pub parent: Option, } +#[serde(rename_all = "camelCase")] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CreateSnapshotConfig { pub name: String, @@ -118,11 +128,13 @@ pub struct CreateSnapshotConfig { pub vmstate: Option, } +#[serde(rename_all = "camelCase")] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ApiResponse { pub data: T, } +#[serde(rename_all = "camelCase")] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NetworkInterface { pub name: String, @@ -134,6 +146,7 @@ pub struct NetworkInterface { pub link_down: Option, } +#[serde(rename_all = "camelCase")] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AddNICConfig { pub bridge: String, @@ -143,6 +156,7 @@ pub struct AddNICConfig { pub firewall: Option, } +#[serde(rename_all = "camelCase")] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct EditNICConfig { pub bridge: Option, @@ -151,6 +165,7 @@ pub struct EditNICConfig { pub firewall: Option, } +#[serde(rename_all = "camelCase")] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Backup { pub volid: String, @@ -166,6 +181,7 @@ pub struct Backup { pub ctime: u64, } +#[serde(rename_all = "camelCase")] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BackupJob { pub id: String, @@ -180,6 +196,7 @@ pub struct BackupJob { pub quiet: Option, } +#[serde(rename_all = "camelCase")] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BackupJobConfig { pub id: Option, @@ -193,6 +210,7 @@ pub struct BackupJobConfig { pub node: Option, } +#[serde(rename_all = "camelCase")] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RestoreConfig { pub volid: String, @@ -201,6 +219,7 @@ pub struct RestoreConfig { pub vmid: Option, } +#[serde(rename_all = "camelCase")] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StorageContent { pub content: String, @@ -211,6 +230,7 @@ pub struct StorageContent { pub volid: String, } +#[serde(rename_all = "camelCase")] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StorageDetail { pub storage: String, diff --git a/src-tauri/src/websocket.rs b/src-tauri/src/websocket.rs index a8c2a1b..f2c7fc3 100644 --- a/src-tauri/src/websocket.rs +++ b/src-tauri/src/websocket.rs @@ -70,6 +70,8 @@ impl WebSocketManager { tokio::spawn(async move { let mut reconnect_delay = Duration::from_secs(1); const MAX_DELAY: Duration = Duration::from_secs(30); + const MAX_RETRIES: u32 = 10; + let mut retry_count: u32 = 0; loop { tokio::select! { @@ -79,11 +81,19 @@ impl WebSocketManager { result = connect_and_run(&cid, &ws_url, &app_handle) => { match result { Ok(()) => { - // Normal close or stream ended – attempt reconnect reconnect_delay = Duration::from_secs(1); + retry_count = 0; } Err(e) => { - eprintln!("[ws] connection error for {}: {e}", cid); + retry_count += 1; + if retry_count >= MAX_RETRIES { + let _ = app_handle.emit("ws-raw", serde_json::json!({ + "connection_id": cid, + "data": { "type": "error", "message": format!("WebSocket reconnect failed after {} attempts: {}", MAX_RETRIES, e) }, + })); + break; + } + eprintln!("[ws] connection error for {} (attempt {}/{}): {e}", cid, retry_count, MAX_RETRIES); } } } diff --git a/src/App.tsx b/src/App.tsx index 1e3a1a5..18a499a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -34,7 +34,7 @@ type View = | { type: 'tasks' } | { type: 'backups' } | { type: 'storage' } - | { type: 'storage-detail'; storage: string } + | { type: 'storage-detail'; storage: string; node: string } | { type: 'settings' } function AppContent() { @@ -90,7 +90,16 @@ function AppContent() { switch (view.type) { case 'dashboard': - return + return { + switch (viewName) { + case 'dashboard': handleNavigate({ type: 'dashboard' }); break + case 'vms': handleNavigate({ type: 'vms' }); break + case 'tasks': handleNavigate({ type: 'tasks' }); break + case 'backups': handleNavigate({ type: 'backups' }); break + case 'storage': handleNavigate({ type: 'storage' }); break + default: break + } + }} /> case 'vms': return ( - handleNavigate({ type: 'storage-detail', storage }) + onStorageClick={(storage, node) => + handleNavigate({ type: 'storage-detail', storage, node }) } /> ) @@ -124,9 +133,16 @@ function AppContent() { handleNavigate({ type: 'storage' })} /> ) + default: + return ( +
+

Unknown view: {(view as { type: string }).type}

+
+ ) } } diff --git a/src/components/backups/BackupList.tsx b/src/components/backups/BackupList.tsx index c376747..6d0acce 100644 --- a/src/components/backups/BackupList.tsx +++ b/src/components/backups/BackupList.tsx @@ -25,19 +25,12 @@ import { CreateBackupJobDialog } from './dialogs/CreateBackupJobDialog' import { EditBackupJobDialog } from './dialogs/EditBackupJobDialog' import { RestoreBackupDialog } from './dialogs/RestoreBackupDialog' import type { ProxmoxBackupJob, ProxmoxBackup } from '@/types/proxmox' +import { formatBytes } from '@/lib/format' interface BackupListProps { connectionId: string } -function formatBytes(bytes: number): string { - if (bytes === 0) return '0 B' - const k = 1024 - const sizes = ['B', 'KB', 'MB', 'GB', 'TB'] - const i = Math.floor(Math.log(bytes) / Math.log(k)) - return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}` -} - function formatTimestamp(seconds: number): string { if (seconds === 0) return 'N/A' const date = new Date(seconds * 1000) diff --git a/src/components/command/CommandPalette.tsx b/src/components/command/CommandPalette.tsx index 063d409..e3ffdb6 100644 --- a/src/components/command/CommandPalette.tsx +++ b/src/components/command/CommandPalette.tsx @@ -20,6 +20,7 @@ import { ListTodo, Shield, HardDrive, + Settings, Plus, X, Search, @@ -38,7 +39,8 @@ type View = | { type: 'tasks' } | { type: 'backups' } | { type: 'storage' } - | { type: 'storage-detail'; storage: string } + | { type: 'storage-detail'; storage: string; node: string } + | { type: 'settings' } type CommandCategory = 'recent' | 'vms' | 'actions' | 'navigation' | 'connections' @@ -254,6 +256,15 @@ export function CommandPalette({ keywords: ['storage', 'disks', 'volumes'], onExecute: () => onNavigate({ type: 'storage' }), }, + { + id: 'nav-settings', + label: 'Go to Settings', + icon: Settings, + category: 'navigation', + shortcut: '⌘6', + keywords: ['settings', 'preferences', 'configuration'], + onExecute: () => onNavigate({ type: 'settings' }), + }, { id: 'nav-add-connection', label: 'Add Connection', diff --git a/src/components/console/TerminalConsole.tsx b/src/components/console/TerminalConsole.tsx index 864ec81..5455fda 100644 --- a/src/components/console/TerminalConsole.tsx +++ b/src/components/console/TerminalConsole.tsx @@ -8,9 +8,10 @@ interface TerminalConsoleProps { node: string vmid: number onError?: (message: string) => void + onConnected?: () => void } -export function TerminalConsole({ connectionId, node, vmid, onError }: TerminalConsoleProps) { +export function TerminalConsole({ connectionId, node, vmid, onError, onConnected }: TerminalConsoleProps) { const containerRef = useRef(null) const termRef = useRef(null) const fitAddonRef = useRef(null) @@ -157,6 +158,8 @@ export function TerminalConsole({ connectionId, node, vmid, onError }: TerminalC }) resizeObserver.observe(containerRef.current!) + onConnected?.() + return () => { resizeObserver.disconnect() } @@ -172,6 +175,7 @@ export function TerminalConsole({ connectionId, node, vmid, onError }: TerminalC ws.onopen = () => { if (!cancelled) { terminal.writeln(`\x1b[32mConnected to LXC container ${vmid} on ${node}\x1b[0m`) + onConnected?.() } } @@ -242,7 +246,7 @@ export function TerminalConsole({ connectionId, node, vmid, onError }: TerminalC cleanupResize?.() cleanup() } - }, [connectionId, node, vmid, onError, cleanup]) + }, [connectionId, node, vmid, onError, onConnected, cleanup]) return (
void + onConnected?: () => void } type ConnectionState = 'connecting' | 'connected' | 'disconnected' | 'error' -export function VNCConsole({ connectionId, node, vmid, onError }: VNCConsoleProps) { +export function VNCConsole({ connectionId, node, vmid, onError, onConnected }: VNCConsoleProps) { const containerRef = useRef(null) const rfbRef = useRef(null) const stateRef = useRef('connecting') @@ -72,6 +73,7 @@ export function VNCConsole({ connectionId, node, vmid, onError }: VNCConsoleProp rfb.addEventListener('connect', () => { if (!cancelled) { stateRef.current = 'connected' + onConnected?.() } }) @@ -90,6 +92,15 @@ export function VNCConsole({ connectionId, node, vmid, onError }: VNCConsoleProp onError?.('VNC credentials required') } }) + + // Listen for Ctrl+Alt+Del command from parent + const onCtrlAltDel = () => { + const current = rfbRef.current as { sendCtrlAltDel?: () => void } | null + if (current?.sendCtrlAltDel) { + current.sendCtrlAltDel() + } + } + containerRef.current.addEventListener('vnc-ctrl-alt-del', onCtrlAltDel) } catch (err) { if (!cancelled) { stateRef.current = 'error' @@ -102,13 +113,17 @@ export function VNCConsole({ connectionId, node, vmid, onError }: VNCConsoleProp return () => { cancelled = true + if (containerRef.current) { + containerRef.current.removeAttribute('data-vnc') + } cleanup() } - }, [connectionId, node, vmid, onError, cleanup]) + }, [connectionId, node, vmid, onError, onConnected, cleanup]) return (
) diff --git a/src/components/dashboard/NodeHealthGrid.tsx b/src/components/dashboard/NodeHealthGrid.tsx index c38711f..8e654f5 100644 --- a/src/components/dashboard/NodeHealthGrid.tsx +++ b/src/components/dashboard/NodeHealthGrid.tsx @@ -3,27 +3,13 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Cpu, MemoryStick, HardDrive, Clock, Server, Box } from 'lucide-react' import { AreaChart, Area, ResponsiveContainer } from 'recharts' import type { ProxmoxNode, ProxmoxVM } from '@/types/proxmox' +import { formatBytes, formatUptime } from '@/lib/format' interface NodeHealthGridProps { nodes: ProxmoxNode[] | undefined vms: ProxmoxVM[] | undefined } -function formatUptime(seconds: number): string { - const days = Math.floor(seconds / 86400) - const hours = Math.floor((seconds % 86400) / 3600) - if (days > 0) return `${days}d ${hours}h` - return `${hours}h` -} - -function formatBytes(bytes: number): string { - if (bytes === 0) return '0 B' - const k = 1024 - const sizes = ['B', 'KB', 'MB', 'GB', 'TB'] - const i = Math.floor(Math.log(bytes) / Math.log(k)) - return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}` -} - function getStatusColor(status: 'online' | 'offline'): string { return status === 'online' ? 'bg-green-500' : 'bg-red-500' } diff --git a/src/components/dashboard/QuickActions.tsx b/src/components/dashboard/QuickActions.tsx index 0b2c21a..a1d7407 100644 --- a/src/components/dashboard/QuickActions.tsx +++ b/src/components/dashboard/QuickActions.tsx @@ -5,9 +5,10 @@ import { RefreshCw, Server, Box, HardDrive, ListTodo, Shield } from 'lucide-reac interface QuickActionsProps { onRefresh: () => void isRefreshing: boolean + onNavigate?: (view: string) => void } -export function QuickActions({ onRefresh, isRefreshing }: QuickActionsProps) { +export function QuickActions({ onRefresh, isRefreshing, onNavigate }: QuickActionsProps) { return ( @@ -27,6 +28,7 @@ export function QuickActions({ onRefresh, isRefreshing }: QuickActionsProps) { +
+
+ ) + } + return (
@@ -152,7 +166,7 @@ export function Dashboard({ connectionId }: DashboardProps) {
- +
diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index edc97af..35da183 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -60,14 +60,14 @@ export function Sidebar({ onAddConnection, activeView, onNavigate }: SidebarProp active={activeView === 'dashboard'} onClick={() => onNavigate?.({ type: 'dashboard' })} /> - + onNavigate?.({ type: 'vms' })} /> - + onNavigate?.({ type: 'storage' })} /> onNavigate?.({ type: 'tasks' })} /> onNavigate?.({ type: 'backups' })} /> @@ -101,21 +101,26 @@ function SidebarItem({ icon: Icon, label, active, + disabled, onClick, }: { icon: React.ComponentType<{ className?: string }> label: string active?: boolean + disabled?: boolean onClick?: () => void }) { return (