Compare commits

..

3 Commits

Author SHA1 Message Date
Shaun Arman
afe9ac1a3a docs: update ticket to include VM listing and module-wide double-unwrap fixes
Some checks failed
Test / frontend-tests (pull_request) Successful in 2m0s
Test / frontend-typecheck (pull_request) Successful in 2m13s
PR Review Automation / review (pull_request) Has been cancelled
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
2026-06-20 19:27:12 -05:00
Shaun Arman
34ddae7eb2 fix(proxmox): remove double-unwrap of Proxmox data envelope across all modules
handle_response() in client.rs already strips the {"data":...} wrapper
before returning to callers. Every proxmox module was calling .get("data")
a second time on the already-unwrapped Value, which always returned None
and caused all API responses to silently yield empty results or errors.

vm.rs had an additional bug: list_vms used POST on cluster/resources (a
GET-only endpoint) and dropped VMs with no cpu field via filter_map ?
instead of unwrap_or(0.0). Both corrected.

Affected modules: vm, ceph, ceph_cluster, certificates, acme, firewall,
sdn, ha, apt, updates, updates_ext, tasks, migration, metrics, shell,
auth_realm, views, backup — 18 files, 19 functions.

426 Rust tests pass. clippy -D warnings clean. tsc --noEmit clean.
2026-06-20 19:26:39 -05:00
Shaun Arman
a72b69ec34 fix(proxmox): restore reliable connect/reconnect after app restart
Root cause: authenticate() tried to deserialize the Proxmox API response
directly into AuthResponse, but Proxmox wraps every response in
{"data": {...}}.  This caused every reconnect attempt after app restart
to fail silently.

Additional fixes bundled in this commit:
- add_proxmox_cluster now authenticates immediately so the in-memory pool
  always contains a live, ticketed client (not a bare unauthenticated stub)
- ProxmoxClient stores the CSRFPreventionToken and includes it in the
  CSRFPreventionToken header on POST/PUT/DELETE requests (Proxmox requires
  this for all mutating calls)
- accept-invalid-certs enabled on the reqwest Client so self-signed PVE
  certificates do not block connections
- Removed double-unwrap of the data field in 10 commands (list_acls,
  list_users, get_cluster_notes, search_proxmox_resources, get_node_status,
  get_syslog, list_network_interfaces, get_subscription_status,
  list_cluster_tasks, list_proxmox_containers) — handle_response already
  strips the envelope before returning to callers
- Added connect_proxmox_cluster and disconnect_proxmox_cluster Tauri
  commands so the UI can explicitly connect/disconnect sessions
- Wired RemotesPage Connect/Disconnect buttons to the real backend commands
- Updated and added tests covering envelope parsing, CSRF header logic,
  already-unwrapped response handling, and the new connect/disconnect paths
2026-06-20 19:05:00 -05:00
7 changed files with 349 additions and 521 deletions

View File

@ -351,11 +351,9 @@ pub async fn chat_message(
let agent_registry = create_agent_registry();
let devops_agent = agent_registry.get("devops-incident-responder");
// CRITICAL: Build messages array with ALL system messages FIRST, then history, then user message
// This ensures system messages are always at the beginning as required by most LLM APIs
let mut messages = Vec::new();
// 1. Inject devops-incident-responder as primary system prompt (always first)
// Inject devops-incident-responder as primary system prompt (always)
if let Some(agent) = devops_agent {
messages.push(Message {
role: "system".into(),
@ -365,7 +363,7 @@ pub async fn chat_message(
});
}
// 2. Inject domain system prompt if provided (second position)
// Inject domain system prompt if provided
if let Some(ref prompt) = system_prompt {
if !prompt.is_empty() {
messages.push(Message {
@ -377,6 +375,28 @@ pub async fn chat_message(
}
}
messages.extend(history);
// If we found integration content, add it to the conversation context
if !integration_context.is_empty() {
let context_message = Message {
role: "system".into(),
content: format!(
"INTERNAL DOCUMENTATION SOURCES:\n\n{integration_context}\n\n\
Instructions: The above content is from internal company documentation systems \
(Confluence, ServiceNow, Azure DevOps). \
\n\n**IMPORTANT**: First determine if this documentation is RELEVANT to the user's question:\
\n- If the documentation directly addresses the question Use it and cite sources with URLs\
\n- If the documentation is tangentially related but doesn't answer the question Briefly mention what internal docs exist, then provide a complete answer using general knowledge\
\n- If the documentation is completely unrelated Ignore it and answer using general knowledge\
\n\nDo NOT force irrelevant internal documentation into your answer. The user needs accurate information, not forced citations."
),
tool_call_id: None,
tool_calls: None,
};
messages.push(context_message);
}
// Tool execution configuration
const MAX_TOOL_ITERATIONS: usize = 20; // Allow sufficient iterations for complex diagnostics
@ -407,7 +427,6 @@ pub async fn chat_message(
!matches!(kind, "anthropic" | "gemini" | "mistral" | "ollama")
};
// 3. Tool-calling system messages — must come BEFORE history so all system messages are contiguous
if tools.is_some() && is_openai_compatible {
messages.push(Message {
role: "system".into(),
@ -416,6 +435,7 @@ pub async fn chat_message(
tool_calls: None,
});
// Add iteration budget awareness
messages.push(Message {
role: "system".into(),
content: format!(
@ -434,34 +454,6 @@ pub async fn chat_message(
});
}
// 4. Integration context as system message — still before history
if !integration_context.is_empty() {
messages.push(Message {
role: "system".into(),
content: format!(
"INTERNAL DOCUMENTATION SOURCES:\n\n{integration_context}\n\n\
Instructions: The above content is from internal company documentation systems \
(Confluence, ServiceNow, Azure DevOps). \
\n\n**IMPORTANT**: First determine if this documentation is RELEVANT to the user's question:\
\n- If the documentation directly addresses the question Use it and cite sources with URLs\
\n- If the documentation is tangentially related but doesn't answer the question Briefly mention what internal docs exist, then provide a complete answer using general knowledge\
\n- If the documentation is completely unrelated Ignore it and answer using general knowledge\
\n\nDo NOT force irrelevant internal documentation into your answer. The user needs accurate information, not forced citations."
),
tool_call_id: None,
tool_calls: None,
});
}
// 5. Filter out any system messages from history to avoid duplicates and maintain order
let filtered_history: Vec<Message> = history
.into_iter()
.filter(|msg| msg.role != "system")
.collect();
// 6. Add filtered history (user/assistant messages only) — all system messages are already above
messages.extend(filtered_history);
messages.push(Message {
role: "user".into(),
content: full_message.clone(),
@ -479,7 +471,7 @@ pub async fn chat_message(
// Warn AI when approaching limit
if iteration == MAX_TOOL_ITERATIONS - 2 {
messages.push(Message {
role: "user".into(),
role: "system".into(),
content: format!(
"WARNING: You are on iteration {iteration}/{MAX_TOOL_ITERATIONS} (2 rounds remaining). \
You MUST provide your final answer in the NEXT round. \
@ -498,7 +490,7 @@ pub async fn chat_message(
// Add final instruction
let mut final_messages = sanitized_messages;
final_messages.push(Message {
role: "user".into(),
role: "system".into(),
content: format!(
"CRITICAL: Tool iteration limit reached ({iteration}/{MAX_TOOL_ITERATIONS}). \
TOOLS ARE NOW DISABLED. \

View File

@ -237,181 +237,16 @@ pub async fn get_proxmox_cluster(
Ok(cluster)
}
/// Helper function to get or create a Proxmox client for a cluster
/// This will:
/// 1. Check if client exists in memory pool
/// 2. If not, load credentials from database and create/authenticate client
async fn get_proxmox_client_for_cluster(
cluster_id: &str,
state: &State<'_, AppState>,
) -> Result<Arc<Mutex<crate::proxmox::ProxmoxClient>>, String> {
// First, try to get from in-memory pool
{
let clusters = state.proxmox_clusters.lock().await;
if let Some(client) = clusters.get(cluster_id) {
return Ok(client.clone());
}
}
// Not in memory - load from database and create client
let (url, port, username, encrypted_credentials) = {
let db = state
.db
.lock()
.map_err(|e| format!("Failed to lock database: {}", e))?;
let mut stmt = db
.prepare(
"SELECT url, port, username, encrypted_credentials FROM proxmox_clusters WHERE id = ?1",
)
.map_err(|e| format!("Failed to prepare query: {}", e))?;
stmt.query_row([cluster_id], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, u16>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
))
})
.optional()
.map_err(|e| format!("Failed to query cluster: {}", e))?
.ok_or_else(|| format!("Cluster {} not found in database", cluster_id))?
};
// Decrypt credentials
let credentials_json = crate::integrations::auth::decrypt_token(&encrypted_credentials)
.map_err(|e| format!("Failed to decrypt credentials: {}", e))?;
let credentials: serde_json::Value = serde_json::from_str(&credentials_json)
.map_err(|e| format!("Failed to parse credentials: {}", e))?;
let password = credentials
.get("password")
.and_then(|v| v.as_str())
.ok_or_else(|| "Password not found in credentials".to_string())?;
// Create new client
let mut client = crate::proxmox::ProxmoxClient::new(&url, port, &username);
// Authenticate to get ticket
let ticket = client
.authenticate(password)
.await
.map_err(|e| format!("Failed to authenticate with Proxmox: {}", e))?;
client.set_ticket(&ticket);
let client_arc = Arc::new(Mutex::new(client));
{
let mut clusters = state.proxmox_clusters.lock().await;
// Re-check under write lock: a concurrent task may have already created a client
// for this cluster between our read-check and here, so prefer the existing one.
if let Some(existing) = clusters.get(cluster_id) {
return Ok(existing.clone());
}
clusters.insert(cluster_id.to_string(), client_arc.clone());
}
Ok(client_arc)
}
/// Ping a Proxmox cluster — authenticates and calls the version endpoint to verify
/// that the API is reachable and credentials are valid.
#[tauri::command]
pub async fn ping_proxmox_cluster(
cluster_id: String,
state: State<'_, AppState>,
) -> Result<serde_json::Value, String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let client_guard = client.lock().await;
client_guard
.get::<serde_json::Value>("version", client_guard.ticket.as_deref())
.await
.map_err(|e| format!("Connection test failed: {}", e))
}
/// Update an existing Proxmox cluster's metadata and credentials atomically.
/// Unlike the remove-then-add pattern this is a single SQL UPDATE so there is
/// no window where the record is missing.
#[tauri::command]
pub async fn update_proxmox_cluster(
id: String,
name: String,
cluster_type: ClusterType,
connection: ClusterConnection,
username: String,
password: &str,
state: State<'_, AppState>,
) -> Result<ClusterInfo, String> {
let credentials = serde_json::json!({ "password": password, "username": username });
let encrypted_credentials = crate::integrations::auth::encrypt_token(
&serde_json::to_string(&credentials).map_err(|e| e.to_string())?,
)
.map_err(|e| format!("Failed to encrypt credentials: {}", e))?;
let updated_at = Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
{
let db = state
.db
.lock()
.map_err(|e| format!("Failed to lock database: {}", e))?;
let rows = db
.execute(
"UPDATE proxmox_clusters \
SET name=?1, cluster_type=?2, url=?3, port=?4, username=?5, \
encrypted_credentials=?6, updated_at=?7 \
WHERE id=?8",
rusqlite::params![
name,
match cluster_type {
ClusterType::VE => "ve",
ClusterType::PBS => "pbs",
},
connection.url,
connection.port,
username,
encrypted_credentials,
updated_at,
id,
],
)
.map_err(|e| format!("Failed to update cluster: {}", e))?;
if rows == 0 {
return Err(format!("Cluster {} not found", id));
}
}
// Evict the stale authenticated client — it will re-authenticate with new credentials
// on the next API call.
{
let mut clusters = state.proxmox_clusters.lock().await;
clusters.remove(&id);
}
Ok(ClusterInfo {
id,
name,
cluster_type,
url: connection.url,
port: connection.port,
username,
created_at: String::new(),
updated_at,
})
}
/// List all Proxmox VMs
#[tauri::command]
pub async fn list_proxmox_vms(
cluster_id: String,
state: State<'_, AppState>,
) -> Result<Vec<serde_json::Value>, String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let clusters = state.proxmox_clusters.lock().await;
let client = clusters
.get(&cluster_id)
.ok_or_else(|| format!("Cluster {} not found", cluster_id))?;
let client_guard = client.lock().await;
let vms =
@ -436,7 +271,10 @@ pub async fn get_proxmox_vm(
vm_id: u32,
state: State<'_, AppState>,
) -> Result<serde_json::Value, String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let clusters = state.proxmox_clusters.lock().await;
let client = clusters
.get(&cluster_id)
.ok_or_else(|| format!("Cluster {} not found", cluster_id))?;
let client_guard = client.lock().await;
let vm = crate::proxmox::vm::get_vm(
@ -459,7 +297,10 @@ pub async fn start_proxmox_vm(
vm_id: u32,
state: State<'_, AppState>,
) -> Result<(), String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let clusters = state.proxmox_clusters.lock().await;
let client = clusters
.get(&cluster_id)
.ok_or_else(|| format!("Cluster {} not found", cluster_id))?;
let client_guard = client.lock().await;
crate::proxmox::vm::start_vm(
@ -480,7 +321,10 @@ pub async fn stop_proxmox_vm(
vm_id: u32,
state: State<'_, AppState>,
) -> Result<(), String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let clusters = state.proxmox_clusters.lock().await;
let client = clusters
.get(&cluster_id)
.ok_or_else(|| format!("Cluster {} not found", cluster_id))?;
let client_guard = client.lock().await;
crate::proxmox::vm::stop_vm(
@ -501,7 +345,10 @@ pub async fn reboot_proxmox_vm(
vm_id: u32,
state: State<'_, AppState>,
) -> Result<(), String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let clusters = state.proxmox_clusters.lock().await;
let client = clusters
.get(&cluster_id)
.ok_or_else(|| format!("Cluster {} not found", cluster_id))?;
let client_guard = client.lock().await;
crate::proxmox::vm::reboot_vm(
@ -522,7 +369,10 @@ pub async fn shutdown_proxmox_vm(
vm_id: u32,
state: State<'_, AppState>,
) -> Result<(), String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let clusters = state.proxmox_clusters.lock().await;
let client = clusters
.get(&cluster_id)
.ok_or_else(|| format!("Cluster {} not found", cluster_id))?;
let client_guard = client.lock().await;
crate::proxmox::vm::shutdown_vm(
@ -542,7 +392,10 @@ pub async fn list_proxmox_backup_jobs(
node: String,
state: State<'_, AppState>,
) -> Result<Vec<serde_json::Value>, String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let clusters = state.proxmox_clusters.lock().await;
let client = clusters
.get(&cluster_id)
.ok_or_else(|| format!("Cluster {} not found", cluster_id))?;
let client_guard = client.lock().await;
let jobs = crate::proxmox::backup::list_backup_jobs(
@ -569,7 +422,10 @@ pub async fn list_proxmox_datastores(
cluster_id: String,
state: State<'_, AppState>,
) -> Result<Vec<serde_json::Value>, String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let clusters = state.proxmox_clusters.lock().await;
let client = clusters
.get(&cluster_id)
.ok_or_else(|| format!("Cluster {} not found", cluster_id))?;
let client_guard = client.lock().await;
let datastores = crate::proxmox::backup::list_datastores(
@ -597,7 +453,10 @@ pub async fn trigger_proxmox_backup_job(
job_id: u32,
state: State<'_, AppState>,
) -> Result<(), String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let clusters = state.proxmox_clusters.lock().await;
let client = clusters
.get(&cluster_id)
.ok_or_else(|| format!("Cluster {} not found", cluster_id))?;
let client_guard = client.lock().await;
crate::proxmox::backup::trigger_backup_job(
@ -616,7 +475,10 @@ pub async fn list_ceph_pools(
cluster_id: String,
state: State<'_, AppState>,
) -> Result<Vec<serde_json::Value>, String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let clusters = state.proxmox_clusters.lock().await;
let client = clusters
.get(&cluster_id)
.ok_or_else(|| format!("Cluster {} not found", cluster_id))?;
let client_guard = client.lock().await;
let pools = crate::proxmox::ceph::list_pools(
@ -642,7 +504,10 @@ pub async fn list_ceph_osd(
cluster_id: String,
state: State<'_, AppState>,
) -> Result<Vec<serde_json::Value>, String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let clusters = state.proxmox_clusters.lock().await;
let client = clusters
.get(&cluster_id)
.ok_or_else(|| format!("Cluster {} not found", cluster_id))?;
let client_guard = client.lock().await;
let osds = crate::proxmox::ceph::list_osds(
@ -668,7 +533,10 @@ pub async fn get_ceph_health(
cluster_id: String,
state: State<'_, AppState>,
) -> Result<serde_json::Value, String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let clusters = state.proxmox_clusters.lock().await;
let client = clusters
.get(&cluster_id)
.ok_or_else(|| format!("Cluster {} not found", cluster_id))?;
let client_guard = client.lock().await;
let health = crate::proxmox::ceph::get_ceph_health(
@ -689,7 +557,10 @@ pub async fn list_auth_realms(
cluster_id: String,
state: State<'_, AppState>,
) -> Result<Vec<serde_json::Value>, String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let clusters = state.proxmox_clusters.lock().await;
let client = clusters
.get(&cluster_id)
.ok_or_else(|| format!("Cluster {} not found", cluster_id))?;
let client_guard = client.lock().await;
let realms = crate::proxmox::auth_realm::list_auth_realms(
@ -724,7 +595,10 @@ pub async fn add_ldap_realm(
certificate: String,
state: State<'_, AppState>,
) -> Result<(), String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let clusters = state.proxmox_clusters.lock().await;
let client = clusters
.get(&cluster_id)
.ok_or_else(|| format!("Cluster {} not found", cluster_id))?;
let client_guard = client.lock().await;
let config = crate::proxmox::auth_realm::LdapRealmConfig {
@ -766,7 +640,10 @@ pub async fn add_ad_realm(
certificate: String,
state: State<'_, AppState>,
) -> Result<(), String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let clusters = state.proxmox_clusters.lock().await;
let client = clusters
.get(&cluster_id)
.ok_or_else(|| format!("Cluster {} not found", cluster_id))?;
let client_guard = client.lock().await;
let config = crate::proxmox::auth_realm::AdRealmConfig {
@ -805,7 +682,10 @@ pub async fn add_openid_realm(
mapping: String,
state: State<'_, AppState>,
) -> Result<(), String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let clusters = state.proxmox_clusters.lock().await;
let client = clusters
.get(&cluster_id)
.ok_or_else(|| format!("Cluster {} not found", cluster_id))?;
let client_guard = client.lock().await;
let config = crate::proxmox::auth_realm::OpenidRealmConfig {
@ -833,7 +713,10 @@ pub async fn list_acme_accounts(
cluster_id: String,
state: State<'_, AppState>,
) -> Result<Vec<serde_json::Value>, String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let clusters = state.proxmox_clusters.lock().await;
let client = clusters
.get(&cluster_id)
.ok_or_else(|| format!("Cluster {} not found", cluster_id))?;
let client_guard = client.lock().await;
let accounts = crate::proxmox::acme::list_acme_accounts(
@ -859,7 +742,10 @@ pub async fn register_acme_account(
terms_of_service_agreed: bool,
state: State<'_, AppState>,
) -> Result<serde_json::Value, String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let clusters = state.proxmox_clusters.lock().await;
let client = clusters
.get(&cluster_id)
.ok_or_else(|| format!("Cluster {} not found", cluster_id))?;
let client_guard = client.lock().await;
let account = crate::proxmox::acme::register_acme_account(
@ -881,7 +767,10 @@ pub async fn get_acme_challenges(
domain: String,
state: State<'_, AppState>,
) -> Result<Vec<serde_json::Value>, String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let clusters = state.proxmox_clusters.lock().await;
let client = clusters
.get(&cluster_id)
.ok_or_else(|| format!("Cluster {} not found", cluster_id))?;
let client_guard = client.lock().await;
let challenges = crate::proxmox::acme::get_acme_challenges(
@ -909,7 +798,10 @@ pub async fn list_apt_updates(
node: String,
state: State<'_, AppState>,
) -> Result<Vec<serde_json::Value>, String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let clusters = state.proxmox_clusters.lock().await;
let client = clusters
.get(&cluster_id)
.ok_or_else(|| format!("Cluster {} not found", cluster_id))?;
let client_guard = client.lock().await;
let updates = crate::proxmox::apt::list_apt_updates(
@ -935,7 +827,10 @@ pub async fn update_apt_repos(
node: String,
state: State<'_, AppState>,
) -> Result<(), String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let clusters = state.proxmox_clusters.lock().await;
let client = clusters
.get(&cluster_id)
.ok_or_else(|| format!("Cluster {} not found", cluster_id))?;
let client_guard = client.lock().await;
crate::proxmox::apt::update_apt_repos(
@ -954,7 +849,10 @@ pub async fn list_apt_repositories(
node: String,
state: State<'_, AppState>,
) -> Result<Vec<serde_json::Value>, String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let clusters = state.proxmox_clusters.lock().await;
let client = clusters
.get(&cluster_id)
.ok_or_else(|| format!("Cluster {} not found", cluster_id))?;
let client_guard = client.lock().await;
let repos = crate::proxmox::apt::list_apt_repositories(
@ -980,7 +878,10 @@ pub async fn get_shell_ticket(
remote: String,
state: State<'_, AppState>,
) -> Result<serde_json::Value, String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let clusters = state.proxmox_clusters.lock().await;
let client = clusters
.get(&cluster_id)
.ok_or_else(|| format!("Cluster {} not found", cluster_id))?;
let client_guard = client.lock().await;
let ticket = crate::proxmox::shell::get_shell_ticket(
@ -1000,7 +901,10 @@ pub async fn list_views(
cluster_id: String,
state: State<'_, AppState>,
) -> Result<Vec<serde_json::Value>, String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let clusters = state.proxmox_clusters.lock().await;
let client = clusters
.get(&cluster_id)
.ok_or_else(|| format!("Cluster {} not found", cluster_id))?;
let client_guard = client.lock().await;
let views = crate::proxmox::views::list_views(
@ -1031,7 +935,10 @@ pub async fn add_view(
enabled: bool,
state: State<'_, AppState>,
) -> Result<(), String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let clusters = state.proxmox_clusters.lock().await;
let client = clusters
.get(&cluster_id)
.ok_or_else(|| format!("Cluster {} not found", cluster_id))?;
let client_guard = client.lock().await;
let widgets: Vec<crate::proxmox::views::Widget> = widgets
@ -1074,7 +981,10 @@ pub async fn update_view(
enabled: bool,
state: State<'_, AppState>,
) -> Result<(), String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let clusters = state.proxmox_clusters.lock().await;
let client = clusters
.get(&cluster_id)
.ok_or_else(|| format!("Cluster {} not found", cluster_id))?;
let client_guard = client.lock().await;
let widgets: Vec<crate::proxmox::views::Widget> = widgets
@ -1112,7 +1022,10 @@ pub async fn delete_view(
view_id: String,
state: State<'_, AppState>,
) -> Result<(), String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let clusters = state.proxmox_clusters.lock().await;
let client = clusters
.get(&cluster_id)
.ok_or_else(|| format!("Cluster {} not found", cluster_id))?;
let client_guard = client.lock().await;
crate::proxmox::views::delete_view(
@ -1130,7 +1043,10 @@ pub async fn list_certificates(
cluster_id: String,
state: State<'_, AppState>,
) -> Result<Vec<serde_json::Value>, String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let clusters = state.proxmox_clusters.lock().await;
let client = clusters
.get(&cluster_id)
.ok_or_else(|| format!("Cluster {} not found", cluster_id))?;
let client_guard = client.lock().await;
let certs = crate::proxmox::certificates::list_certificates(
@ -1157,7 +1073,10 @@ pub async fn upload_certificate(
name: Option<String>,
state: State<'_, AppState>,
) -> Result<serde_json::Value, String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let clusters = state.proxmox_clusters.lock().await;
let client = clusters
.get(&cluster_id)
.ok_or_else(|| format!("Cluster {} not found", cluster_id))?;
let client_guard = client.lock().await;
let cert = crate::proxmox::certificates::upload_certificate(
@ -1180,7 +1099,10 @@ pub async fn get_certificate(
cert_id: String,
state: State<'_, AppState>,
) -> Result<serde_json::Value, String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let clusters = state.proxmox_clusters.lock().await;
let client = clusters
.get(&cluster_id)
.ok_or_else(|| format!("Cluster {} not found", cluster_id))?;
let client_guard = client.lock().await;
let cert = crate::proxmox::certificates::get_certificate(
@ -1204,7 +1126,10 @@ pub async fn list_firewall_rules(
node: String,
state: State<'_, AppState>,
) -> Result<Vec<serde_json::Value>, String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let clusters = state.proxmox_clusters.lock().await;
let client = clusters
.get(&cluster_id)
.ok_or_else(|| format!("Cluster {} not found", cluster_id))?;
let client_guard = client.lock().await;
let rules = crate::proxmox::firewall::list_firewall_rules(
@ -1237,7 +1162,10 @@ pub async fn add_firewall_rule(
enabled: bool,
state: State<'_, AppState>,
) -> Result<(), String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let clusters = state.proxmox_clusters.lock().await;
let client = clusters
.get(&cluster_id)
.ok_or_else(|| format!("Cluster {} not found", cluster_id))?;
let client_guard = client.lock().await;
let rule = crate::proxmox::firewall::FirewallRule {
@ -1268,7 +1196,10 @@ pub async fn delete_firewall_rule(
rule_num: u32,
state: State<'_, AppState>,
) -> Result<(), String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let clusters = state.proxmox_clusters.lock().await;
let client = clusters
.get(&cluster_id)
.ok_or_else(|| format!("Cluster {} not found", cluster_id))?;
let client_guard = client.lock().await;
crate::proxmox::firewall::delete_rule(
@ -1288,7 +1219,10 @@ pub async fn list_sdn_controllers(
cluster_id: String,
state: State<'_, AppState>,
) -> Result<Vec<serde_json::Value>, String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let clusters = state.proxmox_clusters.lock().await;
let client = clusters
.get(&cluster_id)
.ok_or_else(|| format!("Cluster {} not found", cluster_id))?;
let client_guard = client.lock().await;
let controllers = crate::proxmox::sdn::list_evpn_zones(
@ -1314,7 +1248,10 @@ pub async fn list_sdn_vnets(
cluster_id: String,
state: State<'_, AppState>,
) -> Result<Vec<serde_json::Value>, String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let clusters = state.proxmox_clusters.lock().await;
let client = clusters
.get(&cluster_id)
.ok_or_else(|| format!("Cluster {} not found", cluster_id))?;
let client_guard = client.lock().await;
let vnets = crate::proxmox::sdn::list_vnets(
@ -1338,7 +1275,10 @@ pub async fn list_sdn_zones(
cluster_id: String,
state: State<'_, AppState>,
) -> Result<Vec<serde_json::Value>, String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let clusters = state.proxmox_clusters.lock().await;
let client = clusters
.get(&cluster_id)
.ok_or_else(|| format!("Cluster {} not found", cluster_id))?;
let client_guard = client.lock().await;
let zones = crate::proxmox::sdn::list_evpn_zones(
@ -1365,7 +1305,10 @@ pub async fn list_ceph_clusters(
cluster_id: String,
state: State<'_, AppState>,
) -> Result<Vec<serde_json::Value>, String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let clusters = state.proxmox_clusters.lock().await;
let client = clusters
.get(&cluster_id)
.ok_or_else(|| format!("Cluster {} not found", cluster_id))?;
let client_guard = client.lock().await;
let ceph_clusters = crate::proxmox::ceph_cluster::list_ceph_clusters(
@ -1390,7 +1333,10 @@ pub async fn get_ceph_cluster_status(
ceph_cluster_id: String,
state: State<'_, AppState>,
) -> Result<serde_json::Value, String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let clusters = state.proxmox_clusters.lock().await;
let client = clusters
.get(&cluster_id)
.ok_or_else(|| format!("Cluster {} not found", cluster_id))?;
let client_guard = client.lock().await;
let status = crate::proxmox::ceph_cluster::get_ceph_cluster_status(
@ -1417,7 +1363,10 @@ pub async fn migrate_vm(
target_cluster: String,
state: State<'_, AppState>,
) -> Result<serde_json::Value, String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let clusters = state.proxmox_clusters.lock().await;
let client = clusters
.get(&cluster_id)
.ok_or_else(|| format!("Cluster {} not found", cluster_id))?;
let client_guard = client.lock().await;
let task = crate::proxmox::migration::migrate_vm(
@ -1441,7 +1390,10 @@ pub async fn list_migration_status(
node: String,
state: State<'_, AppState>,
) -> Result<Vec<serde_json::Value>, String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let clusters = state.proxmox_clusters.lock().await;
let client = clusters
.get(&cluster_id)
.ok_or_else(|| format!("Cluster {} not found", cluster_id))?;
let client_guard = client.lock().await;
let tasks = crate::proxmox::migration::list_migration_status(
@ -1467,7 +1419,10 @@ pub async fn list_updates(
cluster_id: String,
state: State<'_, AppState>,
) -> Result<Vec<serde_json::Value>, String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let clusters = state.proxmox_clusters.lock().await;
let client = clusters
.get(&cluster_id)
.ok_or_else(|| format!("Cluster {} not found", cluster_id))?;
let client_guard = client.lock().await;
let updates = crate::proxmox::updates_ext::list_updates_all_remotes(
@ -1488,7 +1443,10 @@ pub async fn list_updates(
/// Refresh updates
#[tauri::command]
pub async fn refresh_updates(cluster_id: String, state: State<'_, AppState>) -> Result<(), String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let clusters = state.proxmox_clusters.lock().await;
let client = clusters
.get(&cluster_id)
.ok_or_else(|| format!("Cluster {} not found", cluster_id))?;
let client_guard = client.lock().await;
crate::proxmox::updates_ext::refresh_updates_all(
@ -1506,7 +1464,10 @@ pub async fn install_updates(
packages: Vec<String>,
state: State<'_, AppState>,
) -> Result<(), String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let clusters = state.proxmox_clusters.lock().await;
let client = clusters
.get(&cluster_id)
.ok_or_else(|| format!("Cluster {} not found", cluster_id))?;
let client_guard = client.lock().await;
let package_refs: Vec<&str> = packages.iter().map(|s| s.as_str()).collect();
@ -1527,7 +1488,10 @@ pub async fn list_tasks(
node: String,
state: State<'_, AppState>,
) -> Result<Vec<serde_json::Value>, String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let clusters = state.proxmox_clusters.lock().await;
let client = clusters
.get(&cluster_id)
.ok_or_else(|| format!("Cluster {} not found", cluster_id))?;
let client_guard = client.lock().await;
let tasks = crate::proxmox::tasks::list_tasks(
@ -1554,7 +1518,10 @@ pub async fn get_task_status(
task_id: String,
state: State<'_, AppState>,
) -> Result<serde_json::Value, String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let clusters = state.proxmox_clusters.lock().await;
let client = clusters
.get(&cluster_id)
.ok_or_else(|| format!("Cluster {} not found", cluster_id))?;
let client_guard = client.lock().await;
let task = crate::proxmox::tasks::get_task_status(
@ -1577,7 +1544,10 @@ pub async fn stop_task(
task_id: String,
state: State<'_, AppState>,
) -> Result<(), String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let clusters = state.proxmox_clusters.lock().await;
let client = clusters
.get(&cluster_id)
.ok_or_else(|| format!("Cluster {} not found", cluster_id))?;
let client_guard = client.lock().await;
crate::proxmox::tasks::stop_task(
@ -1599,7 +1569,10 @@ pub async fn get_metrics_summary(
cluster_id: String,
state: State<'_, AppState>,
) -> Result<serde_json::Value, String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let clusters = state.proxmox_clusters.lock().await;
let client = clusters
.get(&cluster_id)
.ok_or_else(|| format!("Cluster {} not found", cluster_id))?;
let client_guard = client.lock().await;
let nodes = crate::proxmox::metrics::list_nodes(
@ -1624,7 +1597,10 @@ pub async fn list_metric_collections(
cluster_id: String,
state: State<'_, AppState>,
) -> Result<Vec<serde_json::Value>, String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let clusters = state.proxmox_clusters.lock().await;
let client = clusters
.get(&cluster_id)
.ok_or_else(|| format!("Cluster {} not found", cluster_id))?;
let client_guard = client.lock().await;
let nodes = crate::proxmox::metrics::list_nodes(
@ -1650,7 +1626,10 @@ pub async fn list_ha_groups(
cluster_id: String,
state: State<'_, AppState>,
) -> Result<Vec<serde_json::Value>, String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let clusters = state.proxmox_clusters.lock().await;
let client = clusters
.get(&cluster_id)
.ok_or_else(|| format!("Cluster {} not found", cluster_id))?;
let client_guard = client.lock().await;
let groups = crate::proxmox::ha::list_ha_groups(
@ -1676,7 +1655,10 @@ pub async fn create_ha_group(
max_relocate: u32,
state: State<'_, AppState>,
) -> Result<(), String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let clusters = state.proxmox_clusters.lock().await;
let client = clusters
.get(&cluster_id)
.ok_or_else(|| format!("Cluster {} not found", cluster_id))?;
let client_guard = client.lock().await;
crate::proxmox::ha::create_ha_group(
@ -1701,7 +1683,10 @@ pub async fn update_ha_group(
max_relocate: u32,
state: State<'_, AppState>,
) -> Result<(), String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let clusters = state.proxmox_clusters.lock().await;
let client = clusters
.get(&cluster_id)
.ok_or_else(|| format!("Cluster {} not found", cluster_id))?;
let client_guard = client.lock().await;
crate::proxmox::ha::update_ha_group(
@ -1723,7 +1708,10 @@ pub async fn delete_ha_group(
group: String,
state: State<'_, AppState>,
) -> Result<(), String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let clusters = state.proxmox_clusters.lock().await;
let client = clusters
.get(&cluster_id)
.ok_or_else(|| format!("Cluster {} not found", cluster_id))?;
let client_guard = client.lock().await;
crate::proxmox::ha::delete_ha_group(
@ -1741,7 +1729,10 @@ pub async fn list_ha_resources(
cluster_id: String,
state: State<'_, AppState>,
) -> Result<Vec<serde_json::Value>, String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let clusters = state.proxmox_clusters.lock().await;
let client = clusters
.get(&cluster_id)
.ok_or_else(|| format!("Cluster {} not found", cluster_id))?;
let client_guard = client.lock().await;
let resources = crate::proxmox::ha::list_ha_resources(
@ -1764,7 +1755,10 @@ pub async fn enable_ha_resource(
resource: String,
state: State<'_, AppState>,
) -> Result<(), String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let clusters = state.proxmox_clusters.lock().await;
let client = clusters
.get(&cluster_id)
.ok_or_else(|| format!("Cluster {} not found", cluster_id))?;
let client_guard = client.lock().await;
crate::proxmox::ha::enable_ha_resource(
@ -1784,7 +1778,10 @@ pub async fn list_acls(
cluster_id: String,
state: State<'_, AppState>,
) -> Result<Vec<serde_json::Value>, String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let clusters = state.proxmox_clusters.lock().await;
let client = clusters
.get(&cluster_id)
.ok_or_else(|| format!("Cluster {} not found", cluster_id))?;
let client_guard = client.lock().await;
let path = "access/acl";
@ -1806,7 +1803,10 @@ pub async fn list_users(
cluster_id: String,
state: State<'_, AppState>,
) -> Result<Vec<serde_json::Value>, String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let clusters = state.proxmox_clusters.lock().await;
let client = clusters
.get(&cluster_id)
.ok_or_else(|| format!("Cluster {} not found", cluster_id))?;
let client_guard = client.lock().await;
let path = "access/users";
@ -1827,7 +1827,10 @@ pub async fn list_realms(
cluster_id: String,
state: State<'_, AppState>,
) -> Result<Vec<serde_json::Value>, String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let clusters = state.proxmox_clusters.lock().await;
let client = clusters
.get(&cluster_id)
.ok_or_else(|| format!("Cluster {} not found", cluster_id))?;
let client_guard = client.lock().await;
let realms = crate::proxmox::auth_realm::list_auth_realms(
@ -1851,7 +1854,10 @@ pub async fn get_cluster_notes(
cluster_id: String,
state: State<'_, AppState>,
) -> Result<String, String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let clusters = state.proxmox_clusters.lock().await;
let client = clusters
.get(&cluster_id)
.ok_or_else(|| format!("Cluster {} not found", cluster_id))?;
let client_guard = client.lock().await;
let path = "cluster/config";
@ -1874,7 +1880,10 @@ pub async fn update_cluster_notes(
notes: String,
state: State<'_, AppState>,
) -> Result<(), String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let clusters = state.proxmox_clusters.lock().await;
let client = clusters
.get(&cluster_id)
.ok_or_else(|| format!("Cluster {} not found", cluster_id))?;
let client_guard = client.lock().await;
let path = "cluster/config";
@ -1900,7 +1909,10 @@ pub async fn search_proxmox_resources(
query: String,
state: State<'_, AppState>,
) -> Result<Vec<serde_json::Value>, String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let clusters = state.proxmox_clusters.lock().await;
let client = clusters
.get(&cluster_id)
.ok_or_else(|| format!("Cluster {} not found", cluster_id))?;
let client_guard = client.lock().await;
let path = format!("cluster/resources?type=vm&search={}", query);
@ -1924,7 +1936,10 @@ pub async fn get_node_status(
node_id: String,
state: State<'_, AppState>,
) -> Result<serde_json::Value, String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let clusters = state.proxmox_clusters.lock().await;
let client = clusters
.get(&cluster_id)
.ok_or_else(|| format!("Cluster {} not found", cluster_id))?;
let client_guard = client.lock().await;
let path = format!("nodes/{}/status", node_id);
@ -1946,7 +1961,10 @@ pub async fn get_syslog(
limit: Option<u32>,
state: State<'_, AppState>,
) -> Result<Vec<serde_json::Value>, String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let clusters = state.proxmox_clusters.lock().await;
let client = clusters
.get(&cluster_id)
.ok_or_else(|| format!("Cluster {} not found", cluster_id))?;
let client_guard = client.lock().await;
let limit_val = limit.unwrap_or(500);
@ -1971,7 +1989,10 @@ pub async fn list_network_interfaces(
node_id: String,
state: State<'_, AppState>,
) -> Result<Vec<serde_json::Value>, String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let clusters = state.proxmox_clusters.lock().await;
let client = clusters
.get(&cluster_id)
.ok_or_else(|| format!("Cluster {} not found", cluster_id))?;
let client_guard = client.lock().await;
let path = format!("nodes/{}/network", node_id);
@ -1994,7 +2015,10 @@ pub async fn list_cluster_views(
cluster_id: String,
state: State<'_, AppState>,
) -> Result<Vec<serde_json::Value>, String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let clusters = state.proxmox_clusters.lock().await;
let client = clusters
.get(&cluster_id)
.ok_or_else(|| format!("Cluster {} not found", cluster_id))?;
let client_guard = client.lock().await;
let views = crate::proxmox::views::list_views(
@ -2018,7 +2042,10 @@ pub async fn create_cluster_view(
name: String,
state: State<'_, AppState>,
) -> Result<(), String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let clusters = state.proxmox_clusters.lock().await;
let client = clusters
.get(&cluster_id)
.ok_or_else(|| format!("Cluster {} not found", cluster_id))?;
let client_guard = client.lock().await;
let view = crate::proxmox::views::DashboardView {
@ -2048,7 +2075,10 @@ pub async fn delete_cluster_view(
view_id: String,
state: State<'_, AppState>,
) -> Result<(), String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let clusters = state.proxmox_clusters.lock().await;
let client = clusters
.get(&cluster_id)
.ok_or_else(|| format!("Cluster {} not found", cluster_id))?;
let client_guard = client.lock().await;
crate::proxmox::views::delete_view(
@ -2068,7 +2098,10 @@ pub async fn get_subscription_status(
cluster_id: String,
state: State<'_, AppState>,
) -> Result<serde_json::Value, String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let clusters = state.proxmox_clusters.lock().await;
let client = clusters
.get(&cluster_id)
.ok_or_else(|| format!("Cluster {} not found", cluster_id))?;
let client_guard = client.lock().await;
let path = "nodes/localhost/subscription";
@ -2089,7 +2122,10 @@ pub async fn list_cluster_tasks(
limit: Option<u32>,
state: State<'_, AppState>,
) -> Result<Vec<serde_json::Value>, String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let clusters = state.proxmox_clusters.lock().await;
let client = clusters
.get(&cluster_id)
.ok_or_else(|| format!("Cluster {} not found", cluster_id))?;
let client_guard = client.lock().await;
let limit_val = limit.unwrap_or(50);
@ -2111,7 +2147,10 @@ pub async fn list_proxmox_containers(
cluster_id: String,
state: State<'_, AppState>,
) -> Result<Vec<serde_json::Value>, String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let clusters = state.proxmox_clusters.lock().await;
let client = clusters
.get(&cluster_id)
.ok_or_else(|| format!("Cluster {} not found", cluster_id))?;
let client_guard = client.lock().await;
let path = "cluster/resources?type=lxc";
@ -2273,12 +2312,6 @@ mod tests {
assert_eq!(result.unwrap().len(), 2);
}
#[test]
fn test_update_proxmox_cluster_not_found_error() {
let err = format!("Cluster {} not found", "missing-id");
assert_eq!(err, "Cluster missing-id not found");
}
#[test]
fn test_cluster_notes_already_unwrapped_present() {
let response = serde_json::json!({"notes": "Important info", "name": "pve"});
@ -2330,12 +2363,4 @@ mod tests {
};
assert!(result.is_ok());
}
#[test]
fn test_ping_proxmox_cluster_error_message_format() {
let raw = anyhow::anyhow!("connection refused");
let msg = format!("Connection test failed: {}", raw);
assert!(msg.starts_with("Connection test failed:"));
assert!(msg.contains("connection refused"));
}
}

View File

@ -225,8 +225,6 @@ pub fn run() {
// Proxmox - Existing
commands::proxmox::add_proxmox_cluster,
commands::proxmox::remove_proxmox_cluster,
commands::proxmox::update_proxmox_cluster,
commands::proxmox::ping_proxmox_cluster,
commands::proxmox::connect_proxmox_cluster,
commands::proxmox::disconnect_proxmox_cluster,
commands::proxmox::list_proxmox_clusters,

View File

@ -1,8 +1,8 @@
import React, { useState, useRef, useEffect } from 'react';
import React from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/index';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/index';
import { Button } from '@/components/ui/index';
import { MoreHorizontal, Plug, PlugZap } from 'lucide-react';
import { MoreHorizontal } from 'lucide-react';
interface RemoteInfo {
id: string;
@ -25,77 +25,6 @@ interface RemotesListProps {
onDisconnect?: (remote: RemoteInfo) => void;
}
function ActionsMenu({
remote,
onEdit,
onDelete,
onConnect,
onDisconnect,
}: {
remote: RemoteInfo;
onEdit?: (remote: RemoteInfo) => void;
onDelete?: (remote: RemoteInfo) => void;
onConnect?: (remote: RemoteInfo) => void;
onDisconnect?: (remote: RemoteInfo) => void;
}) {
const [open, setOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
return (
<div className="relative" ref={menuRef}>
<button
className="rounded-md p-1 hover:bg-accent"
onClick={() => setOpen((v) => !v)}
title="More actions"
>
<MoreHorizontal className="h-4 w-4" />
</button>
{open && (
<div className="absolute right-0 z-50 mt-1 w-44 rounded-md border bg-background shadow-lg">
<div className="py-1">
<button
className="flex w-full items-center gap-2 px-3 py-2 text-sm hover:bg-accent"
onClick={() => { setOpen(false); onEdit?.(remote); }}
>
Edit
</button>
<button
className="flex w-full items-center gap-2 px-3 py-2 text-sm hover:bg-accent"
onClick={() => {
setOpen(false);
if (remote.status === 'connected') {
onDisconnect?.(remote);
} else {
onConnect?.(remote);
}
}}
>
Test Connection
</button>
<div className="my-1 h-px bg-border" />
<button
className="flex w-full items-center gap-2 px-3 py-2 text-sm text-destructive hover:bg-destructive/10"
onClick={() => { setOpen(false); onDelete?.(remote); }}
>
Delete
</button>
</div>
</div>
)}
</div>
);
}
export function RemotesList({
remotes,
onRefresh,
@ -171,31 +100,44 @@ export function RemotesList({
</TableCell>
<TableCell>{remote.lastConnected || '-'}</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end space-x-1">
<div className="flex items-center justify-end space-x-2">
<button
className="rounded-md p-1 hover:bg-accent"
onClick={() => onEdit?.(remote)}
title="Edit"
>
<span className="h-4 w-4 text-xs"></span>
</button>
{remote.status === 'connected' ? (
<button
className="rounded-md p-1 hover:bg-red-100 hover:text-red-600 text-green-600"
className="rounded-md p-1 hover:bg-red-100 hover:text-red-600"
onClick={() => onDisconnect?.(remote)}
title="Disconnect"
>
<PlugZap className="h-4 w-4" />
<span className="h-4 w-4 text-xs">🔌</span>
</button>
) : (
<button
className="rounded-md p-1 hover:bg-green-100 hover:text-green-600 text-muted-foreground"
className="rounded-md p-1 hover:bg-green-100 hover:text-green-600"
onClick={() => onConnect?.(remote)}
title="Test connection"
title="Connect"
>
<Plug className="h-4 w-4" />
<span className="h-4 w-4 text-xs">🔌</span>
</button>
)}
<ActionsMenu
remote={remote}
onEdit={onEdit}
onDelete={onDelete}
onConnect={onConnect}
onDisconnect={onDisconnect}
/>
<button
className="rounded-md p-1 hover:bg-red-100 hover:text-red-600"
onClick={() => onDelete?.(remote)}
title="Delete"
>
<span className="h-4 w-4 text-xs">🗑</span>
</button>
<button
className="rounded-md p-1 hover:bg-accent"
title="More"
>
<MoreHorizontal className="h-4 w-4" />
</button>
</div>
</TableCell>
</TableRow>

View File

@ -40,36 +40,6 @@ export async function removeProxmoxCluster(id: string): Promise<void> {
await invoke("remove_proxmox_cluster", { id });
}
/**
* Update an existing Proxmox cluster's metadata and credentials atomically.
* Uses a single SQL UPDATE so there is no window where the record is missing.
*/
export async function updateProxmoxCluster(
id: string,
name: string,
clusterType: ClusterType,
connection: { url: string; port: number },
username: string,
password: string
): Promise<ClusterInfo> {
return await invoke<ClusterInfo>("update_proxmox_cluster", {
id,
name,
clusterType,
connection,
username,
password,
});
}
/**
* Ping a Proxmox cluster authenticates and calls the version endpoint to verify
* the API is reachable and credentials are valid.
*/
export async function pingProxmoxCluster(clusterId: string): Promise<unknown> {
return await invoke("ping_proxmox_cluster", { clusterId });
}
/**
* Connect (or re-connect) to a cluster stored in the DB.
* Authenticates against the Proxmox API and populates the in-memory pool.

View File

@ -1,111 +1,10 @@
import React, { useState, useEffect, useCallback } from 'react';
import React from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/index';
import { Button } from '@/components/ui/index';
import { RefreshCw } from 'lucide-react';
import { PoolList, OSDList, CephHealthWidget, MonitorList } from '@/components/Proxmox';
import { listProxmoxClusters, listCephPools, listCephOsd, getCephHealth } from '@/lib/proxmoxClient';
import { toast } from 'sonner';
export function ProxmoxCephPage() {
const [clusterId, setClusterId] = useState<string>('');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [health, setHealth] = useState<any>(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [pools, setPools] = useState<any[]>([]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [osds, setOsds] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isCephEnabled, setIsCephEnabled] = useState<boolean | null>(null);
const loadData = useCallback(async (cId: string) => {
if (!cId) return;
setLoading(true);
setError(null);
// Check Ceph availability by fetching health first
let cephAvailable = false;
try {
const h = await getCephHealth(cId);
setHealth(h);
cephAvailable = true;
} catch {
setIsCephEnabled(false);
setLoading(false);
return;
}
if (cephAvailable) {
setIsCephEnabled(true);
const [poolsResult, osdsResult] = await Promise.allSettled([
listCephPools(cId),
listCephOsd(cId),
]);
if (poolsResult.status === 'fulfilled') {
setPools(poolsResult.value);
} else {
toast.error('Failed to load Ceph pools');
}
if (osdsResult.status === 'fulfilled') {
setOsds(osdsResult.value);
} else {
toast.error('Failed to load Ceph OSDs');
}
}
setLoading(false);
}, []);
useEffect(() => {
listProxmoxClusters()
.then((cls) => {
if (cls.length > 0) {
setClusterId(cls[0].id);
loadData(cls[0].id);
} else {
setIsCephEnabled(false);
}
})
.catch((err) => {
console.error('Failed to load clusters:', err);
setError('Failed to load clusters');
setIsCephEnabled(false);
});
}, [loadData]);
const handleRefresh = () => {
if (clusterId) loadData(clusterId);
};
if (isCephEnabled === false) {
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Ceph Storage</h1>
<p className="text-muted-foreground">Manage Ceph clusters and storage</p>
</div>
</div>
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
{error ? (
<p>{error}</p>
) : (
<>
<p className="text-base font-medium">Ceph is not configured on this cluster</p>
<p className="text-sm mt-1">
Ceph storage requires a dedicated Ceph cluster deployment on the Proxmox nodes.
</p>
</>
)}
</CardContent>
</Card>
</div>
);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
@ -114,8 +13,8 @@ export function ProxmoxCephPage() {
<p className="text-muted-foreground">Manage Ceph clusters and storage</p>
</div>
<div className="flex space-x-2">
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={loading}>
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
<Button variant="outline" size="sm">
<RefreshCw className="mr-2 h-4 w-4" />
Refresh
</Button>
</div>
@ -127,11 +26,9 @@ export function ProxmoxCephPage() {
<CardTitle>Ceph Health</CardTitle>
</CardHeader>
<CardContent>
{health ? (
<CephHealthWidget health={health} />
) : (
<p className="text-sm text-muted-foreground">Loading health data...</p>
)}
<CephHealthWidget
health={{ status: 'HEALTH_OK', summary: 'Cluster healthy', details: [] }}
/>
</CardContent>
</Card>
</div>
@ -143,8 +40,8 @@ export function ProxmoxCephPage() {
</CardHeader>
<CardContent>
<PoolList
pools={pools}
onRefresh={handleRefresh}
pools={[]}
onRefresh={() => {}}
/>
</CardContent>
</Card>
@ -155,8 +52,8 @@ export function ProxmoxCephPage() {
</CardHeader>
<CardContent>
<OSDList
osds={osds}
onRefresh={handleRefresh}
osds={[]}
onRefresh={() => {}}
/>
</CardContent>
</Card>
@ -169,7 +66,7 @@ export function ProxmoxCephPage() {
<CardContent>
<MonitorList
monitors={[]}
onRefresh={handleRefresh}
onRefresh={() => {}}
/>
</CardContent>
</Card>

View File

@ -6,7 +6,7 @@ import { AddRemoteForm } from '@/components/Proxmox';
import { EditRemoteForm } from '@/components/Proxmox';
import { RemoveRemoteDialog } from '@/components/Proxmox';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/index';
import { listProxmoxClusters, addProxmoxCluster, removeProxmoxCluster, updateProxmoxCluster, connectProxmoxCluster, disconnectProxmoxCluster } from '@/lib/proxmoxClient';
import { listProxmoxClusters, addProxmoxCluster, removeProxmoxCluster, connectProxmoxCluster, disconnectProxmoxCluster } from '@/lib/proxmoxClient';
import { ClusterType } from '@/lib/domain';
import { toast } from 'sonner';
@ -35,7 +35,7 @@ export function ProxmoxRemotesPage() {
url: c.url,
username: c.username,
type: c.clusterType === 've' ? 'pve' : 'pbs',
status: (c.connected ? 'connected' : 'disconnected') as RemoteInfo['status'],
status: 'connected' as const, // Placeholder - actual status requires connection test
}));
setRemotes(remotesList);
} catch (err) {
@ -102,7 +102,11 @@ export function ProxmoxRemotesPage() {
const clusterType = config.type === 'pve' ? 've' : 'pbs';
const { hostname, port } = parseRemoteUrl(config.url, config.type);
await updateProxmoxCluster(
// Edit operation requires remove-then-add since backend doesn't support update.
// If add fails after remove, the remote will be lost - this is a known limitation
// until backend supports atomic update operations.
await removeProxmoxCluster(config.id);
await addProxmoxCluster(
config.id,
config.name,
clusterType as ClusterType,