Fix 21 bugs: build failures, broken pages, IPC mismatches, and missing error handling

Critical fixes:
- Add esbuild dependency and fix Vite build target (safari13 → es2022)
- Fix StorageDetail always failing (null node parameter)
- Fix VNC/Terminal console stuck on 'Connecting...' (add onConnected callback)
- Fix Ctrl+Alt+Del not working (add data-vnc attribute and event listener)
- Add serde(rename_all='camelCase') to all 27 Rust structs crossing IPC
- Fix login methods returning empty connection_id (generate from URL hash)
- Fix add_connection not storing connections in HashMap
- Fix keyring_entry panicking on failure (replace expect with map_err)

High-severity fixes:
- Fix refresh_ticket reading wrong keyring entry for username
- Add onError handlers to all 23 mutations (toast notifications)
- Fix stale closure on activeConnectionId (use getState() instead)
- Fix WebSocket listener teardown race condition
- Add WebSocket reconnection retry limit (max 10 attempts)

Medium-severity fixes:
- Add Settings navigation to CommandPalette (Cmd+K)
- Disable dead Nodes/Containers sidebar items
- Wire up HardwareTab save button with warning toast
- Wire up QuickActions navigation buttons
- Add Dashboard error state (was stuck on 'Loading...')
- Add default case to App.tsx view switch
- Extract formatBytes/formatUptime/formatNetworkRate to shared utility
- Fix formatBytes negative input bug

32 files changed, 1073 insertions(+), 356 deletions(-)
This commit is contained in:
Matt
2026-07-29 19:35:37 +00:00
parent 535ba5e189
commit 9ad7fc85bd
32 changed files with 1080 additions and 363 deletions
+117 -41
View File
@@ -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<keyring::Entry> {
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<Option<String>> {
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<LoginResult> {
// 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
+11 -4
View File
@@ -16,6 +16,7 @@ use websocket::WebSocketManager;
pub type Result<T> = std::result::Result<T, Error>;
#[serde(rename_all = "camelCase")]
#[derive(Clone, Serialize, Deserialize)]
pub struct ConnectionConfig {
pub id: String,
@@ -31,6 +32,7 @@ pub struct ConnectionConfig {
pub username: Option<String>,
}
#[serde(rename_all = "camelCase")]
#[derive(Clone, Serialize, Deserialize)]
pub struct EndpointConfig {
pub url: String,
@@ -38,6 +40,7 @@ pub struct EndpointConfig {
pub token: Option<String>,
}
#[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,
+20
View File
@@ -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<String>,
}
#[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<String>,
}
#[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<String>,
}
#[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<Vec<ClusterNode>>,
}
#[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<String>,
}
#[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<String>,
}
#[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<bool>,
}
#[serde(rename_all = "camelCase")]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApiResponse<T> {
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<u32>,
}
#[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<bool>,
}
#[serde(rename_all = "camelCase")]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EditNICConfig {
pub bridge: Option<String>,
@@ -151,6 +165,7 @@ pub struct EditNICConfig {
pub firewall: Option<bool>,
}
#[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<u32>,
}
#[serde(rename_all = "camelCase")]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BackupJobConfig {
pub id: Option<String>,
@@ -193,6 +210,7 @@ pub struct BackupJobConfig {
pub node: Option<String>,
}
#[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<u32>,
}
#[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,
+12 -2
View File
@@ -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);
}
}
}