feat: add cluster discovery, failover, VM management, and console support

This commit is contained in:
Matt
2026-08-09 23:01:51 +00:00
parent 7bb3ba48f0
commit 190fdb2573
100 changed files with 11351 additions and 1604 deletions
+235 -54
View File
@@ -1,4 +1,5 @@
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::Arc;
use tauri::menu::{MenuBuilder, MenuItemBuilder};
use tauri::tray::TrayIconBuilder;
@@ -6,12 +7,19 @@ use tauri::Manager;
use tokio::sync::RwLock;
mod connection;
mod proxmox;
mod error;
mod proxmox;
pub mod tls;
mod websocket;
use connection::ConnectionManager;
use error::Error;
pub use connection::{
api_request, derive_node_url, AuthContext, AuthMode, ConnectionManager, LoadResult,
};
pub use error::Error;
pub use proxmox::{
AddDiskConfig, AddNICConfig, BackupJobConfig, CreateSnapshotConfig, EditNICConfig,
RestoreConfig,
};
use websocket::WebSocketManager;
pub type Result<T> = std::result::Result<T, Error>;
@@ -23,13 +31,36 @@ pub struct ConnectionConfig {
pub name: String,
pub primary: EndpointConfig,
pub fallbacks: Vec<EndpointConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cert_fingerprint: Option<String>,
pub trusted: bool,
#[serde(default)]
pub accept_untrusted: bool,
pub status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub cluster_name: Option<String>,
pub is_cluster: bool,
pub auth_mode: String,
pub username: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub nodes: Vec<DiscoveredNode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cluster_id: Option<String>,
}
/// A node discovered in the cluster connected through a
/// [`ConnectionConfig`]. `url` is the endpoint URL derived from the
/// connection's primary endpoint (same scheme and port, host replaced with
/// the node's cluster IP or name); `is_primary` marks the node that the
/// connection is anchored on.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct DiscoveredNode {
pub name: String,
pub url: String,
pub status: String,
pub is_primary: bool,
pub local: bool,
}
#[derive(Clone, Serialize, Deserialize)]
@@ -37,6 +68,7 @@ pub struct ConnectionConfig {
pub struct EndpointConfig {
pub url: String,
pub node: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub token: Option<String>,
}
@@ -48,7 +80,38 @@ pub struct LoginResult {
pub csrf_token: String,
}
#[derive(Clone, Serialize, Deserialize)]
/// The outcome of a `connect` attempt. A standalone success reports
/// `merged_into: None`; connecting to a cluster already represented by another
/// connection folds the new endpoint into it and reports
/// `merged_into: Some(target)`. When no endpoint is reachable the connection
/// is still tracked and reported with `status: "failed"` instead of an error.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConnectResult {
/// The effective connection id to use after the connect (the merge target
/// when this connect merged into an existing same-cluster connection).
pub connection_id: String,
/// `Some(target)` when this connect merged into an existing connection.
pub merged_into: Option<String>,
/// `"connected"` | `"failover"` | `"failed"`.
pub status: String,
}
/// A snapshot of a connection's runtime state for the status bar: the
/// effective status, the primary and currently-serving endpoints, and the last
/// discovered node list.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ConnectionStatusInfo {
pub connection_id: String,
/// `"connected"` | `"failover"` | `"failed"` | `"disconnected"`.
pub status: String,
pub primary_url: String,
pub current_endpoint_url: String,
pub nodes: Vec<DiscoveredNode>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CertificateInfo {
pub fingerprint: String,
@@ -64,38 +127,91 @@ struct AppState {
ws_manager: Arc<RwLock<WebSocketManager>>,
}
/// Returns the path of the persisted connections file.
///
/// Lives at `{app_config_dir}/proxmoxdesktop/connections.json`; the parent
/// directory is created lazily when the file is first written.
fn connections_file(app: &tauri::AppHandle) -> Result<PathBuf> {
let dir = app.path().app_config_dir()?;
Ok(dir.join("proxmoxdesktop").join("connections.json"))
}
#[tauri::command]
async fn load_connections(
state: tauri::State<'_, AppState>,
app: tauri::AppHandle,
) -> Result<LoadResult> {
let mut manager = state.connection_manager.write().await;
let path = connections_file(&app)?;
manager.load_connections(&path).await
}
#[tauri::command]
async fn add_connection(
state: tauri::State<'_, AppState>,
config: ConnectionConfig,
app: tauri::AppHandle,
) -> Result<()> {
let mut manager = state.connection_manager.write().await;
manager.add_connection(config).await
let path = connections_file(&app)?;
manager.add_connection(config, &path).await
}
#[tauri::command]
async fn remove_connection(
state: tauri::State<'_, AppState>,
id: String,
app: tauri::AppHandle,
) -> Result<()> {
let mut manager = state.connection_manager.write().await;
manager.remove_connection(&id).await
let path = connections_file(&app)?;
manager.remove_connection(&id, &path).await
}
#[tauri::command]
async fn update_connection(
state: tauri::State<'_, AppState>,
config: ConnectionConfig,
app: tauri::AppHandle,
) -> Result<()> {
let mut manager = state.connection_manager.write().await;
let path = connections_file(&app)?;
manager.update_connection(config, &path).await
}
#[tauri::command]
async fn connect_to_server(
state: tauri::State<'_, AppState>,
id: String,
) -> Result<()> {
app: tauri::AppHandle,
) -> Result<ConnectResult> {
let mut manager = state.connection_manager.write().await;
manager.connect(&id).await
let path = connections_file(&app)?;
manager.connect(&id, &path).await
}
#[tauri::command]
async fn disconnect_from_server(
async fn get_connection_status(
state: tauri::State<'_, AppState>,
connection_id: String,
) -> Result<ConnectionStatusInfo> {
let mut manager = state.connection_manager.write().await;
manager.status_info(&connection_id).await
}
#[tauri::command]
async fn set_active_connection(
state: tauri::State<'_, AppState>,
id: String,
app: tauri::AppHandle,
) -> Result<()> {
let mut manager = state.connection_manager.write().await;
let path = connections_file(&app)?;
manager.set_active_connection(id, &path).await
}
#[tauri::command]
async fn disconnect_from_server(state: tauri::State<'_, AppState>, id: String) -> Result<()> {
let mut manager = state.connection_manager.write().await;
manager.disconnect(&id).await
}
@@ -114,9 +230,11 @@ async fn trust_certificate(
state: tauri::State<'_, AppState>,
id: String,
fingerprint: String,
app: tauri::AppHandle,
) -> Result<()> {
let manager = state.connection_manager.read().await;
manager.trust_certificate(&id, &fingerprint).await
let mut manager = state.connection_manager.write().await;
let path = connections_file(&app)?;
manager.trust_certificate(&id, &fingerprint, &path).await
}
#[tauri::command]
@@ -164,7 +282,9 @@ async fn get_storage_detail(
storage: String,
) -> Result<proxmox::StorageDetail> {
let manager = state.connection_manager.read().await;
manager.get_storage_detail(&connection_id, &node, &storage).await
manager
.get_storage_detail(&connection_id, &node, &storage)
.await
}
#[tauri::command]
@@ -191,9 +311,12 @@ async fn start_vm(
connection_id: String,
node: String,
vmid: u32,
vm_type: String,
) -> Result<()> {
let manager = state.connection_manager.read().await;
manager.start_vm(&connection_id, &node, vmid).await
manager
.start_vm(&connection_id, &node, vmid, &vm_type)
.await
}
#[tauri::command]
@@ -202,9 +325,10 @@ async fn stop_vm(
connection_id: String,
node: String,
vmid: u32,
vm_type: String,
) -> Result<()> {
let manager = state.connection_manager.read().await;
manager.stop_vm(&connection_id, &node, vmid).await
manager.stop_vm(&connection_id, &node, vmid, &vm_type).await
}
#[tauri::command]
@@ -213,9 +337,12 @@ async fn shutdown_vm(
connection_id: String,
node: String,
vmid: u32,
vm_type: String,
) -> Result<()> {
let manager = state.connection_manager.read().await;
manager.shutdown_vm(&connection_id, &node, vmid).await
manager
.shutdown_vm(&connection_id, &node, vmid, &vm_type)
.await
}
#[tauri::command]
@@ -224,9 +351,12 @@ async fn reboot_vm(
connection_id: String,
node: String,
vmid: u32,
vm_type: String,
) -> Result<()> {
let manager = state.connection_manager.read().await;
manager.reboot_vm(&connection_id, &node, vmid).await
manager
.reboot_vm(&connection_id, &node, vmid, &vm_type)
.await
}
#[tauri::command]
@@ -235,9 +365,12 @@ async fn suspend_vm(
connection_id: String,
node: String,
vmid: u32,
vm_type: String,
) -> Result<()> {
let manager = state.connection_manager.read().await;
manager.suspend_vm(&connection_id, &node, vmid).await
manager
.suspend_vm(&connection_id, &node, vmid, &vm_type)
.await
}
#[tauri::command]
@@ -246,9 +379,12 @@ async fn resume_vm(
connection_id: String,
node: String,
vmid: u32,
vm_type: String,
) -> Result<()> {
let manager = state.connection_manager.read().await;
manager.resume_vm(&connection_id, &node, vmid).await
manager
.resume_vm(&connection_id, &node, vmid, &vm_type)
.await
}
#[tauri::command]
@@ -257,9 +393,12 @@ async fn get_disks(
connection_id: String,
node: String,
vmid: u32,
vm_type: String,
) -> Result<Vec<proxmox::Disk>> {
let manager = state.connection_manager.read().await;
manager.get_disks(&connection_id, &node, vmid).await
manager
.get_disks(&connection_id, &node, vmid, &vm_type)
.await
}
#[tauri::command]
@@ -268,10 +407,13 @@ async fn add_disk(
connection_id: String,
node: String,
vmid: u32,
vm_type: String,
config: proxmox::AddDiskConfig,
) -> Result<()> {
let manager = state.connection_manager.read().await;
manager.add_disk(&connection_id, &node, vmid, config).await
manager
.add_disk(&connection_id, &node, vmid, &vm_type, config)
.await
}
#[tauri::command]
@@ -280,11 +422,14 @@ async fn resize_disk(
connection_id: String,
node: String,
vmid: u32,
vm_type: String,
disk: String,
size: u64,
) -> Result<()> {
let manager = state.connection_manager.read().await;
manager.resize_disk(&connection_id, &node, vmid, &disk, size).await
manager
.resize_disk(&connection_id, &node, vmid, &vm_type, &disk, size)
.await
}
#[tauri::command]
@@ -293,10 +438,13 @@ async fn remove_disk(
connection_id: String,
node: String,
vmid: u32,
vm_type: String,
disk: String,
) -> Result<()> {
let manager = state.connection_manager.read().await;
manager.remove_disk(&connection_id, &node, vmid, &disk).await
manager
.remove_disk(&connection_id, &node, vmid, &vm_type, &disk)
.await
}
#[tauri::command]
@@ -305,11 +453,14 @@ async fn move_disk(
connection_id: String,
node: String,
vmid: u32,
vm_type: String,
disk: String,
storage: String,
) -> Result<()> {
let manager = state.connection_manager.read().await;
manager.move_disk(&connection_id, &node, vmid, &disk, &storage).await
manager
.move_disk(&connection_id, &node, vmid, &vm_type, &disk, &storage)
.await
}
#[tauri::command]
@@ -318,9 +469,12 @@ async fn get_network_interfaces(
connection_id: String,
node: String,
vmid: u32,
vm_type: String,
) -> Result<Vec<proxmox::NetworkInterface>> {
let manager = state.connection_manager.read().await;
manager.get_network_interfaces(&connection_id, &node, vmid).await
manager
.get_network_interfaces(&connection_id, &node, vmid, &vm_type)
.await
}
#[tauri::command]
@@ -329,10 +483,13 @@ async fn add_nic(
connection_id: String,
node: String,
vmid: u32,
vm_type: String,
config: proxmox::AddNICConfig,
) -> Result<()> {
let manager = state.connection_manager.read().await;
manager.add_nic(&connection_id, &node, vmid, config).await
manager
.add_nic(&connection_id, &node, vmid, &vm_type, config)
.await
}
#[tauri::command]
@@ -341,11 +498,14 @@ async fn edit_nic(
connection_id: String,
node: String,
vmid: u32,
vm_type: String,
nic: String,
config: proxmox::EditNICConfig,
) -> Result<()> {
let manager = state.connection_manager.read().await;
manager.edit_nic(&connection_id, &node, vmid, &nic, config).await
manager
.edit_nic(&connection_id, &node, vmid, &vm_type, &nic, config)
.await
}
#[tauri::command]
@@ -354,10 +514,13 @@ async fn remove_nic(
connection_id: String,
node: String,
vmid: u32,
vm_type: String,
nic: String,
) -> Result<()> {
let manager = state.connection_manager.read().await;
manager.remove_nic(&connection_id, &node, vmid, &nic).await
manager
.remove_nic(&connection_id, &node, vmid, &vm_type, &nic)
.await
}
#[tauri::command]
@@ -366,9 +529,12 @@ async fn get_snapshots(
connection_id: String,
node: String,
vmid: u32,
vm_type: String,
) -> Result<Vec<proxmox::Snapshot>> {
let manager = state.connection_manager.read().await;
manager.get_snapshots(&connection_id, &node, vmid).await
manager
.get_snapshots(&connection_id, &node, vmid, &vm_type)
.await
}
#[tauri::command]
@@ -377,10 +543,13 @@ async fn create_snapshot(
connection_id: String,
node: String,
vmid: u32,
vm_type: String,
config: proxmox::CreateSnapshotConfig,
) -> Result<()> {
let manager = state.connection_manager.read().await;
manager.create_snapshot(&connection_id, &node, vmid, config).await
manager
.create_snapshot(&connection_id, &node, vmid, &vm_type, config)
.await
}
#[tauri::command]
@@ -389,10 +558,13 @@ async fn delete_snapshot(
connection_id: String,
node: String,
vmid: u32,
vm_type: String,
name: String,
) -> Result<()> {
let manager = state.connection_manager.read().await;
manager.delete_snapshot(&connection_id, &node, vmid, &name).await
manager
.delete_snapshot(&connection_id, &node, vmid, &vm_type, &name)
.await
}
#[tauri::command]
@@ -401,10 +573,13 @@ async fn rollback_snapshot(
connection_id: String,
node: String,
vmid: u32,
vm_type: String,
name: String,
) -> Result<()> {
let manager = state.connection_manager.read().await;
manager.rollback_snapshot(&connection_id, &node, vmid, &name).await
manager
.rollback_snapshot(&connection_id, &node, vmid, &vm_type, &name)
.await
}
#[tauri::command]
@@ -413,11 +588,14 @@ async fn migrate_vm(
connection_id: String,
node: String,
vmid: u32,
vm_type: String,
target_node: String,
online: bool,
) -> Result<()> {
let manager = state.connection_manager.read().await;
manager.migrate_vm(&connection_id, &node, vmid, &target_node, online).await
manager
.migrate_vm(&connection_id, &node, vmid, &vm_type, &target_node, online)
.await
}
// Authentication commands
@@ -429,7 +607,9 @@ async fn login_with_password(
password: String,
) -> Result<LoginResult> {
let manager = state.connection_manager.read().await;
manager.login_with_password(&url, &username, &password).await
manager
.login_with_password(&url, &username, &password)
.await
}
#[tauri::command]
@@ -443,10 +623,7 @@ async fn login_with_token(
}
#[tauri::command]
async fn logout(
state: tauri::State<'_, AppState>,
connection_id: String,
) -> Result<()> {
async fn logout(state: tauri::State<'_, AppState>, connection_id: String) -> Result<()> {
let manager = state.connection_manager.read().await;
manager.logout(&connection_id).await
}
@@ -461,7 +638,7 @@ async fn get_stored_credentials(
}
// Console proxy types
#[derive(Clone, Serialize, Deserialize)]
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VNCProxyResponse {
pub ticket: String,
@@ -469,7 +646,7 @@ pub struct VNCProxyResponse {
pub cert: String,
}
#[derive(Clone, Serialize, Deserialize)]
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TermProxyResponse {
pub ticket: String,
@@ -554,7 +731,9 @@ async fn get_backups(
storage: Option<String>,
) -> Result<Vec<proxmox::Backup>> {
let manager = state.connection_manager.read().await;
manager.get_backups(&connection_id, storage.as_deref()).await
manager
.get_backups(&connection_id, storage.as_deref())
.await
}
#[tauri::command]
@@ -689,10 +868,14 @@ pub fn run() {
ws_manager: Arc::new(RwLock::new(WebSocketManager::new())),
})
.invoke_handler(tauri::generate_handler![
load_connections,
add_connection,
remove_connection,
update_connection,
set_active_connection,
connect_to_server,
disconnect_from_server,
get_connection_status,
login_with_password,
login_with_token,
logout,
@@ -759,23 +942,21 @@ pub fn run() {
.tooltip("ProxmoxDesktop")
.icon(app.default_window_icon().cloned().expect("no default icon"))
.menu(&menu)
.on_menu_event(move |app, event| {
match event.id.as_ref() {
"show_hide" => {
if let Some(window) = app.get_webview_window("main") {
if window.is_visible().unwrap_or(false) {
let _ = window.hide();
} else {
let _ = window.show();
let _ = window.set_focus();
}
.on_menu_event(move |app, event| match event.id.as_ref() {
"show_hide" => {
if let Some(window) = app.get_webview_window("main") {
if window.is_visible().unwrap_or(false) {
let _ = window.hide();
} else {
let _ = window.show();
let _ = window.set_focus();
}
}
"quit" => {
app.exit(0);
}
_ => {}
}
"quit" => {
app.exit(0);
}
_ => {}
})
.build(app)?;