From 577512562b8388088e127ce70ec08111bfc43163 Mon Sep 17 00:00:00 2001 From: Shaun Arman Date: Sun, 21 Jun 2026 15:08:56 -0500 Subject: [PATCH 1/6] fix(proxmox): resolve 7 dashboard and AI chat issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. VM Actions: pass clusterId/clusters props from VMsPage to VMList; rename node→node_id in 14 Rust Tauri command handlers to match Tauri 2.x camelCase→snake_case mapping; wire action menu items through handleAction so menu closes on click. 2. Migration: add Target Remote dropdown in MigrationDialog showing available clusters for cross-datacenter migration; targetCluster passed through to migrate_vm invoke. 3. Storage: switch list_proxmox_datastores to cluster/resources?type=storage (single API call, cluster-wide); normalize plugintype→type, disk/maxdisk→used/size, compute available via saturating_sub. 4. Network: replace free-text Interface Type Input with a Select dropdown listing all PVE network interface types. 5. Firewall New Rule: add onNewRule prop to FirewallRuleList, wire button; add full dialog in FirewallPage with action/protocol/ source/dest/port fields that calls add_firewall_rule; rewrite Rust command to accept rule as serde_json::Value instead of flat params (matches frontend invoke signature). 6. Backup: normalize raw PVE cluster/backup fields (id, storage, node, schedule, enabled, next-run timestamp) to BackupJobInfo shape; update BackupJobList columns to show storage, vmid, mode. 7. AI chat: merge all system prompt sections into a single system message (fixes Qwen 3.5 / LiteLLM rejection of multiple system messages); push assistant message with tool_calls before tool result messages to satisfy OpenAI API contract. --- src-tauri/src/commands/ai.rs | 130 +++++------ src-tauri/src/commands/proxmox.rs | 238 ++++++++++++-------- src/components/Proxmox/BackupJobList.tsx | 35 +-- src/components/Proxmox/FirewallRuleList.tsx | 4 +- src/components/Proxmox/VMList.tsx | 233 ++++++++++--------- src/pages/Proxmox/BackupPage.tsx | 31 ++- src/pages/Proxmox/FirewallPage.tsx | 131 ++++++++++- src/pages/Proxmox/NetworkPage.tsx | 22 +- src/pages/Proxmox/VMsPage.tsx | 2 + 9 files changed, 528 insertions(+), 298 deletions(-) diff --git a/src-tauri/src/commands/ai.rs b/src-tauri/src/commands/ai.rs index 6782f5c8..faff5c58 100644 --- a/src-tauri/src/commands/ai.rs +++ b/src-tauri/src/commands/ai.rs @@ -351,37 +351,10 @@ 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) - if let Some(agent) = devops_agent { - messages.push(Message { - role: "system".into(), - content: agent.system_prompt.clone(), - tool_call_id: None, - tool_calls: None, - }); - } - - // 2. Inject domain system prompt if provided (second position) - if let Some(ref prompt) = system_prompt { - if !prompt.is_empty() { - messages.push(Message { - role: "system".into(), - content: prompt.clone(), - tool_call_id: None, - tool_calls: None, - }); - } - } - // Tool execution configuration - const MAX_TOOL_ITERATIONS: usize = 20; // Allow sufficient iterations for complex diagnostics + const MAX_TOOL_ITERATIONS: usize = 20; // Get available tools — static + MCP - // Only enable tools if the provider explicitly supports tool calling let tools = if provider_config.supports_tool_calling.unwrap_or(false) { let mut all_tools = crate::ai::tools::get_available_tools(); let mcp_tools = crate::ai::tools::get_enabled_mcp_tools(&state).await; @@ -395,9 +368,6 @@ pub async fn chat_message( None }; - // If tools are available AND using OpenAI-compatible provider, add explicit JSON format instruction - // Only OpenAI-compatible providers (default case in create_provider) actually support tool calling. - // Others (anthropic, gemini, mistral, ollama) either ignore tools or use provider-specific formats. let is_openai_compatible = { let kind = if provider_config.provider_type.is_empty() { provider_config.name.as_str() @@ -407,59 +377,71 @@ 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 + // Collect all system prompt parts and merge into ONE message to satisfy providers + // like Qwen 3.5 that reject multiple consecutive system messages. + let mut system_parts: Vec = Vec::new(); + + if let Some(agent) = devops_agent { + system_parts.push(agent.system_prompt.clone()); + } + + if let Some(ref prompt) = system_prompt { + if !prompt.is_empty() { + system_parts.push(prompt.clone()); + } + } + if tools.is_some() && is_openai_compatible { - messages.push(Message { - role: "system".into(), - content: "CRITICAL: You have tools available. When calling tools, you MUST use the native JSON function calling format in your API response. DO NOT output XML tags like . DO NOT output text descriptions of tool calls. Use the structured tool_calls field in your response.".into(), - tool_call_id: None, - tool_calls: None, - }); - - messages.push(Message { - role: "system".into(), - content: format!( - "TOOL EXECUTION BUDGET: You have a maximum of {MAX_TOOL_ITERATIONS} rounds (each AI response counts as one round). \ - You can call multiple tools in a single round. \ - Plan your investigation efficiently:\n\ - - Call multiple related tools in the same round when possible\n\ - - Prioritize high-value diagnostic commands first\n\ - - Use comprehensive output formats (e.g., kubectl --output=yaml) to gather more data per call\n\ - - Reserve 1 round for your final summary/answer\n\ - - If you exceed the budget, you'll be cut off mid-investigation\n\ - Current round count is not visible to you, so plan conservatively." - ), - tool_call_id: None, - tool_calls: None, - }); + system_parts.push( + "CRITICAL: You have tools available. When calling tools, you MUST use the native \ + JSON function calling format in your API response. DO NOT output XML tags like \ + . DO NOT output text descriptions of tool calls. Use the structured \ + tool_calls field in your response." + .to_string(), + ); + system_parts.push(format!( + "TOOL EXECUTION BUDGET: You have a maximum of {MAX_TOOL_ITERATIONS} rounds (each \ + AI response counts as one round). You can call multiple tools in a single round. \ + Plan your investigation efficiently:\n\ + - Call multiple related tools in the same round when possible\n\ + - Prioritize high-value diagnostic commands first\n\ + - Use comprehensive output formats (e.g., kubectl --output=yaml) to gather more data per call\n\ + - Reserve 1 round for your final summary/answer\n\ + - If you exceed the budget, you'll be cut off mid-investigation\n\ + Current round count is not visible to you, so plan conservatively." + )); } - // 4. Integration context as system message — still before history if !integration_context.is_empty() { + system_parts.push(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." + )); + } + + let mut messages = Vec::new(); + + if !system_parts.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." - ), + content: system_parts.join("\n\n---\n\n"), tool_call_id: None, tool_calls: None, }); } - // 5. Filter out any system messages from history to avoid duplicates and maintain order + // Filter system messages from history (already merged above) and append let filtered_history: Vec = 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 { @@ -541,19 +523,25 @@ pub async fn chat_message( if let Some(tool_calls) = &response.tool_calls { tracing::info!("AI requested {} tool call(s)", tool_calls.len()); - // Execute each tool call + // OpenAI API contract: push the assistant message WITH tool_calls BEFORE any tool results + messages.push(Message { + role: "assistant".into(), + content: response.content.clone(), + tool_call_id: None, + tool_calls: Some(tool_calls.clone()), + }); + + // Execute each tool call and append result messages for tool_call in tool_calls { tracing::info!("Executing tool: {}", tool_call.name); let tool_result = execute_tool_call(tool_call, &app_handle, &state).await; - // Format result let result_content = match tool_result { Ok(result) => result, Err(e) => format!("Error executing tool: {e}"), }; - // Add tool result as a message messages.push(Message { role: "tool".into(), content: result_content, diff --git a/src-tauri/src/commands/proxmox.rs b/src-tauri/src/commands/proxmox.rs index 59653657..4ee9df66 100644 --- a/src-tauri/src/commands/proxmox.rs +++ b/src-tauri/src/commands/proxmox.rs @@ -432,7 +432,7 @@ pub async fn list_proxmox_vms( #[tauri::command] pub async fn get_proxmox_vm( cluster_id: String, - node: String, + node_id: String, vm_id: u32, state: State<'_, AppState>, ) -> Result { @@ -441,7 +441,7 @@ pub async fn get_proxmox_vm( let vm = crate::proxmox::vm::get_vm( &client_guard, - &node, + &node_id, vm_id, client_guard.ticket.as_deref().unwrap_or(""), ) @@ -455,7 +455,7 @@ pub async fn get_proxmox_vm( #[tauri::command] pub async fn start_proxmox_vm( cluster_id: String, - node: String, + node_id: String, vm_id: u32, state: State<'_, AppState>, ) -> Result<(), String> { @@ -464,7 +464,7 @@ pub async fn start_proxmox_vm( crate::proxmox::vm::start_vm( &client_guard, - &node, + &node_id, vm_id, client_guard.ticket.as_deref().unwrap_or(""), ) @@ -476,7 +476,7 @@ pub async fn start_proxmox_vm( #[tauri::command] pub async fn stop_proxmox_vm( cluster_id: String, - node: String, + node_id: String, vm_id: u32, state: State<'_, AppState>, ) -> Result<(), String> { @@ -485,7 +485,7 @@ pub async fn stop_proxmox_vm( crate::proxmox::vm::stop_vm( &client_guard, - &node, + &node_id, vm_id, client_guard.ticket.as_deref().unwrap_or(""), ) @@ -497,7 +497,7 @@ pub async fn stop_proxmox_vm( #[tauri::command] pub async fn reboot_proxmox_vm( cluster_id: String, - node: String, + node_id: String, vm_id: u32, state: State<'_, AppState>, ) -> Result<(), String> { @@ -506,7 +506,7 @@ pub async fn reboot_proxmox_vm( crate::proxmox::vm::reboot_vm( &client_guard, - &node, + &node_id, vm_id, client_guard.ticket.as_deref().unwrap_or(""), ) @@ -518,7 +518,7 @@ pub async fn reboot_proxmox_vm( #[tauri::command] pub async fn shutdown_proxmox_vm( cluster_id: String, - node: String, + node_id: String, vm_id: u32, state: State<'_, AppState>, ) -> Result<(), String> { @@ -527,7 +527,7 @@ pub async fn shutdown_proxmox_vm( crate::proxmox::vm::shutdown_vm( &client_guard, - &node, + &node_id, vm_id, client_guard.ticket.as_deref().unwrap_or(""), ) @@ -539,7 +539,7 @@ pub async fn shutdown_proxmox_vm( #[tauri::command] pub async fn resume_proxmox_vm( cluster_id: String, - node: String, + node_id: String, vm_id: u32, state: State<'_, AppState>, ) -> Result<(), String> { @@ -548,7 +548,7 @@ pub async fn resume_proxmox_vm( crate::proxmox::vm::resume_vm( &client_guard, - &node, + &node_id, vm_id, client_guard.ticket.as_deref().unwrap_or(""), ) @@ -560,7 +560,7 @@ pub async fn resume_proxmox_vm( #[tauri::command] pub async fn suspend_proxmox_vm( cluster_id: String, - node: String, + node_id: String, vm_id: u32, state: State<'_, AppState>, ) -> Result<(), String> { @@ -569,7 +569,7 @@ pub async fn suspend_proxmox_vm( crate::proxmox::vm::suspend_vm( &client_guard, - &node, + &node_id, vm_id, client_guard.ticket.as_deref().unwrap_or(""), ) @@ -581,7 +581,7 @@ pub async fn suspend_proxmox_vm( #[tauri::command] pub async fn clone_vm( cluster_id: String, - node: String, + node_id: String, vm_id: u32, new_vmid: u32, name: String, @@ -592,7 +592,7 @@ pub async fn clone_vm( crate::proxmox::vm::clone_vm( &client_guard, - &node, + &node_id, vm_id, new_vmid, &name, @@ -606,7 +606,7 @@ pub async fn clone_vm( #[tauri::command] pub async fn delete_vm( cluster_id: String, - node: String, + node_id: String, vm_id: u32, state: State<'_, AppState>, ) -> Result<(), String> { @@ -615,7 +615,7 @@ pub async fn delete_vm( crate::proxmox::vm::delete_vm( &client_guard, - &node, + &node_id, vm_id, client_guard.ticket.as_deref().unwrap_or(""), ) @@ -681,74 +681,99 @@ pub async fn list_proxmox_backup_jobs( Ok(jobs) } -/// List Proxmox Datastores (Storage per node) +/// List Proxmox Datastores (cluster-wide via cluster/resources) #[tauri::command] pub async fn list_proxmox_datastores( cluster_id: String, _state: State<'_, AppState>, ) -> Result, String> { - // Note: Proxmox VE storage is per-node, not cluster-wide - // We need to get all nodes first, then fetch storage for each node let client = get_proxmox_client_for_cluster(&cluster_id, &_state).await?; let client_guard = client.lock().await; - // First, get all nodes - let nodes_path = "cluster/resources?type=node"; - let nodes_response: serde_json::Value = client_guard + let response: serde_json::Value = client_guard .get( - nodes_path, + "cluster/resources?type=storage", Some(client_guard.ticket.as_deref().unwrap_or("")), ) .await - .map_err(|e| format!("Failed to list nodes: {}", e))?; + .map_err(|e| format!("Failed to list cluster storage: {}", e))?; - let nodes: Vec = nodes_response - .as_array() - .unwrap_or(&vec![]) + let entries = response.as_array().ok_or("Invalid response format")?; + + let all_storage: Vec = entries .iter() - .filter_map(|n| { - n.get("node") - .and_then(|node| node.as_str()) - .map(|s| s.to_string()) + .filter_map(|entry| { + let obj = entry.as_object()?; + let mut normalized = serde_json::Map::new(); + + let storage_name = obj.get("storage").and_then(|v| v.as_str()).unwrap_or(""); + let node_name = obj.get("node").and_then(|v| v.as_str()).unwrap_or(""); + + normalized.insert( + "id".to_string(), + serde_json::Value::String(format!( + "storage/{}/{}", + node_name, storage_name + )), + ); + normalized.insert( + "storage".to_string(), + serde_json::Value::String(storage_name.to_string()), + ); + normalized.insert( + "name".to_string(), + serde_json::Value::String(storage_name.to_string()), + ); + + let plugin_type = obj + .get("plugintype") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + normalized.insert( + "type".to_string(), + serde_json::Value::String(plugin_type.to_string()), + ); + + let content = obj + .get("content") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + normalized.insert( + "content".to_string(), + serde_json::Value::String(content), + ); + + normalized.insert( + "node".to_string(), + serde_json::Value::String(node_name.to_string()), + ); + + // cluster/resources uses disk/maxdisk; normalize to used/available/size + let disk_used = obj.get("disk").and_then(|v| v.as_u64()).unwrap_or(0); + let disk_total = obj.get("maxdisk").and_then(|v| v.as_u64()).unwrap_or(0); + let disk_avail = disk_total.saturating_sub(disk_used); + + normalized.insert("used".to_string(), serde_json::Value::Number(disk_used.into())); + normalized.insert("size".to_string(), serde_json::Value::Number(disk_total.into())); + normalized.insert("available".to_string(), serde_json::Value::Number(disk_avail.into())); + + let status = obj + .get("status") + .and_then(|v| v.as_str()) + .unwrap_or("available") + .to_string(); + normalized.insert("status".to_string(), serde_json::Value::String(status)); + + // Preserve shared flag if present + if let Some(shared) = obj.get("shared") { + normalized.insert("shared".to_string(), shared.clone()); + } + + Some(serde_json::Value::Object(normalized)) }) .collect(); - if nodes.is_empty() { - return Ok(vec![]); - } - - // Fetch storage for each node - let mut all_storage: Vec = vec![]; - - for node in nodes { - let storage_path = format!("nodes/{}/storage", node); - let storage_response: serde_json::Value = client_guard - .get( - &storage_path, - Some(client_guard.ticket.as_deref().unwrap_or("")), - ) - .await - .map_err(|e| format!("Failed to list storage for node {}: {}", node, e))?; - - if let Some(storage_array) = storage_response.as_array() { - for mut storage in storage_array.clone() { - // Add node information to each storage entry - if let Some(storage_obj) = storage.as_object_mut() { - storage_obj.insert("node".to_string(), serde_json::Value::String(node.clone())); - // Create a unique ID - if let Some(storage_name) = storage_obj.get("storage").and_then(|s| s.as_str()) - { - storage_obj.insert( - "id".to_string(), - serde_json::Value::String(format!("storage/{}", storage_name)), - ); - } - } - all_storage.push(storage); - } - } - } - Ok(all_storage) } @@ -756,7 +781,7 @@ pub async fn list_proxmox_datastores( #[tauri::command] pub async fn trigger_proxmox_backup_job( cluster_id: String, - node: String, + node_id: String, job_id: u32, state: State<'_, AppState>, ) -> Result<(), String> { @@ -765,7 +790,7 @@ pub async fn trigger_proxmox_backup_job( crate::proxmox::backup::trigger_backup_job( &client_guard, - &node, + &node_id, job_id, client_guard.ticket.as_deref().unwrap_or(""), ) @@ -1384,7 +1409,7 @@ pub async fn get_certificate( #[tauri::command] pub async fn list_firewall_rules( cluster_id: String, - node: String, + node_id: String, state: State<'_, AppState>, ) -> Result, String> { let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?; @@ -1392,7 +1417,7 @@ pub async fn list_firewall_rules( let rules = crate::proxmox::firewall::list_firewall_rules( &client_guard, - &node, + &node_id, client_guard.ticket.as_deref().unwrap_or(""), ) .await @@ -1408,35 +1433,56 @@ pub async fn list_firewall_rules( /// Add firewall rule #[tauri::command] -#[allow(clippy::too_many_arguments)] pub async fn add_firewall_rule( cluster_id: String, - node: String, - action: String, - protocol: String, - source: String, - destination: String, - port: Option, - enabled: bool, + node_id: String, + rule: serde_json::Value, state: State<'_, AppState>, ) -> Result<(), String> { let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?; let client_guard = client.lock().await; - let rule = crate::proxmox::firewall::FirewallRule { + let firewall_rule = crate::proxmox::firewall::FirewallRule { rule_num: 0, - action, - protocol, - source, - destination, - port, - enabled, + action: rule + .get("action") + .and_then(|v| v.as_str()) + .unwrap_or("ACCEPT") + .to_string(), + protocol: rule + .get("proto") + .and_then(|v| v.as_str()) + .unwrap_or("tcp") + .to_string(), + source: rule + .get("source") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + destination: rule + .get("dest") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + port: rule + .get("dport") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()), + enabled: rule + .get("enable") + .and_then(|v| { + v.as_bool() + .or_else(|| v.as_i64().map(|n| n != 0)) + .or_else(|| v.as_str().map(|s| s == "1" || s == "true")) + }) + .unwrap_or(true), }; crate::proxmox::firewall::add_rule( &client_guard, - &node, - &rule, + &node_id, + &firewall_rule, client_guard.ticket.as_deref().unwrap_or(""), ) .await @@ -1447,7 +1493,7 @@ pub async fn add_firewall_rule( #[tauri::command] pub async fn delete_firewall_rule( cluster_id: String, - node: String, + node_id: String, rule_num: u32, state: State<'_, AppState>, ) -> Result<(), String> { @@ -1456,7 +1502,7 @@ pub async fn delete_firewall_rule( crate::proxmox::firewall::delete_rule( &client_guard, - &node, + &node_id, rule_num, client_guard.ticket.as_deref().unwrap_or(""), ) @@ -1594,7 +1640,7 @@ pub async fn get_ceph_cluster_status( #[tauri::command] pub async fn migrate_vm( cluster_id: String, - node: String, + node_id: String, vm_id: u32, target_node: String, target_cluster: String, @@ -1605,7 +1651,7 @@ pub async fn migrate_vm( let task = crate::proxmox::migration::migrate_vm( &client_guard, - &node, + &node_id, vm_id, &target_node, &target_cluster, @@ -1621,7 +1667,7 @@ pub async fn migrate_vm( #[tauri::command] pub async fn list_migration_status( cluster_id: String, - node: String, + node_id: String, state: State<'_, AppState>, ) -> Result, String> { let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?; @@ -1629,7 +1675,7 @@ pub async fn list_migration_status( let tasks = crate::proxmox::migration::list_migration_status( &client_guard, - &node, + &node_id, client_guard.ticket.as_deref().unwrap_or(""), ) .await diff --git a/src/components/Proxmox/BackupJobList.tsx b/src/components/Proxmox/BackupJobList.tsx index feafb09e..9ba4a3cf 100644 --- a/src/components/Proxmox/BackupJobList.tsx +++ b/src/components/Proxmox/BackupJobList.tsx @@ -15,6 +15,10 @@ interface BackupJobInfo { size?: number; count?: number; enabled: boolean; + storage?: string; + vmid?: string | number; + mode?: string; + comment?: string; } interface BackupJobListProps { @@ -57,37 +61,34 @@ export function BackupJobList({ - Name + ID + Storage + VMs Node Schedule - Status - Last Run + Enabled Next Run - Size - Count + Mode Actions {jobs.map((job) => ( - {job.name} - {job.node} - {job.schedule} + {job.name} + {job.storage || '-'} + {job.vmid ? String(job.vmid) : 'all'} + {job.node || 'all'} + {job.schedule} - {job.status} + {job.enabled ? 'enabled' : 'disabled'} - {job.lastRun || '-'} - {job.nextRun || '-'} - {job.size ? `${(job.size / (1024 * 1024 * 1024)).toFixed(2)} GB` : '-'} - {job.count || '-'} + {job.nextRun || '-'} + {job.mode || '-'}
- diff --git a/src/components/Proxmox/VMList.tsx b/src/components/Proxmox/VMList.tsx index f046611d..3002d038 100644 --- a/src/components/Proxmox/VMList.tsx +++ b/src/components/Proxmox/VMList.tsx @@ -14,6 +14,7 @@ import { Checkbox as UICheckbox } from '@/components/ui/index'; import { Input } from '@/components/ui/index'; import { AlertCircle } from 'lucide-react'; import { Alert, AlertDescription } from '@/components/ui/index'; +import type { ClusterInfo } from '@/lib/domain'; interface VMInfo { id: string; @@ -50,6 +51,8 @@ interface RawVMInfo { interface VMListProps { vms: RawVMInfo[]; + clusterId: string; + clusters?: ClusterInfo[]; onRefresh?: () => void; isLoading?: boolean; onSnapshotAction?: (vm: VMInfo, action: 'create' | 'list' | 'rollback' | 'delete') => void; @@ -62,16 +65,16 @@ interface VMListProps { function formatUptime(seconds: number): string { if (seconds <= 0) return '-'; - + const days = Math.floor(seconds / 86400); const hours = Math.floor((seconds % 86400) / 3600); const minutes = Math.floor((seconds % 3600) / 60); - + const parts: string[] = []; if (days > 0) parts.push(`${days}d`); if (hours > 0) parts.push(`${hours}h`); if (minutes > 0 || parts.length === 0) parts.push(`${minutes}m`); - + return parts.join(' '); } @@ -85,22 +88,23 @@ function formatBytes(bytes: number): string { export function VMList({ vms: rawVms, + clusterId, + clusters = [], onRefresh, isLoading, - onSnapshotAction, - onMigrate, - onClone, - onDelete, + onSnapshotAction: _onSnapshotAction, + onMigrate: _onMigrate, + onClone: _onClone, + onDelete: _onDelete, selectedVMs = new Set(), onToggleSelect, }: VMListProps) { - const [clusterId, setClusterId] = useState(''); const [migrationVM, setMigrationVM] = useState(null); const [targetNode, setTargetNode] = useState(''); + const [targetCluster, setTargetCluster] = useState(''); const [onlineMigration, setOnlineMigration] = useState(true); const [maxDowntime, setMaxDowntime] = useState(30); - // Transform raw VM data to VMInfo format const vms: VMInfo[] = React.useMemo(() => { return rawVms.map((vm) => ({ id: String(vm.id || vm.vmid), @@ -118,17 +122,11 @@ export function VMList({ })); }, [rawVms]); - useEffect(() => { - invoke('list_proxmox_clusters') - .then((clusters: any[]) => { - if (clusters.length > 0) { - setClusterId(clusters[0].id); - } - }) - .catch(() => {}); - }, []); - const handleVMAction = useCallback(async (vm: VMInfo, action: string) => { + if (!clusterId) { + toast.error('No cluster selected'); + return; + } try { switch (action) { case 'start': @@ -204,7 +202,8 @@ export function VMList({ .map((v) => v.node) .filter((node, index, self) => self.indexOf(node) === index && node !== vm.node); setTargetNode(availableNodes[0] || ''); - }, [vms]); + setTargetCluster(clusterId); + }, [vms, clusterId]); const submitMigration = useCallback(async () => { if (!migrationVM || !targetNode) { @@ -212,27 +211,34 @@ export function VMList({ return; } + const sourceCluster = clusterId; + const destCluster = targetCluster || clusterId; + try { await invoke('migrate_vm', { - clusterId, + clusterId: sourceCluster, nodeId: migrationVM.node, vmId: migrationVM.vmid, targetNode, - online: onlineMigration, - max_downtime: maxDowntime, + targetCluster: destCluster, }); - - toast.success(`VM ${migrationVM.name} migration started to ${targetNode}`); + + toast.success(`VM ${migrationVM.name} migration started to ${targetNode}${destCluster !== sourceCluster ? ` (cluster: ${destCluster})` : ''}`); setMigrationVM(null); setTargetNode(''); + setTargetCluster(''); onRefresh?.(); } catch (error) { console.error('Failed to migrate VM:', error); toast.error(`Failed to migrate VM ${migrationVM.name}: ${error}`); } - }, [migrationVM, targetNode, onlineMigration, maxDowntime, clusterId, onRefresh]); + }, [migrationVM, targetNode, targetCluster, clusterId, onRefresh]); const handleClone = useCallback(async (vm: VMInfo) => { + if (!clusterId) { + toast.error('No cluster selected'); + return; + } try { const nextVmid = Math.max(...vms.map((v) => v.vmid), 100) + 1; const newVmidStr = window.prompt(`Enter new VM ID for ${vm.name}:`, `${nextVmid}`); @@ -250,7 +256,7 @@ export function VMList({ toast.info('Clone cancelled'); return; } - + await invoke('clone_vm', { clusterId, nodeId: vm.node, @@ -258,7 +264,7 @@ export function VMList({ newVmid, name: newName, }); - + toast.success(`VM ${vm.name} cloned successfully to VM ${newVmid}`); onRefresh?.(); } catch (error) { @@ -268,11 +274,15 @@ export function VMList({ }, [clusterId, vms, onRefresh]); const handleDelete = useCallback(async (vm: VMInfo) => { + if (!clusterId) { + toast.error('No cluster selected'); + return; + } const confirmed = await confirm(`Are you sure you want to delete VM ${vm.name} (VMID: ${vm.vmid})? This action cannot be undone!`, { title: 'Delete VM', kind: 'warning', }); - + if (!confirmed) { return; } @@ -283,7 +293,7 @@ export function VMList({ nodeId: vm.node, vmId: vm.vmid, }); - + toast.success(`VM ${vm.name} deleted successfully`); onRefresh?.(); } catch (error) { @@ -309,7 +319,7 @@ export function VMList({ selectedVMs.has(vm.id))} + checked={vms.length > 0 && vms.every((vm) => selectedVMs.has(vm.id))} onCheckedChange={(checked) => { if (checked) { vms.forEach((vm) => selectedVMs.add(vm.id)); @@ -335,7 +345,7 @@ export function VMList({ const cpuPercent = vm.cpu > 0 ? Math.min(vm.cpu * 100, 100) : 0; const memoryPercent = vm.memoryTotal > 0 ? (vm.memory / vm.memoryTotal) * 100 : 0; const diskPercent = vm.diskTotal > 0 ? (vm.disk / vm.diskTotal) * 100 : 0; - + return ( @@ -413,11 +423,15 @@ export function VMList({ setMigrationVM(null)} + onClose={() => { setMigrationVM(null); setTargetNode(''); setTargetCluster(''); }} onSubmit={submitMigration} availableNodes={vms} + clusters={clusters} + currentClusterId={clusterId} targetNode={targetNode} onTargetNodeChange={setTargetNode} + targetCluster={targetCluster} + onTargetClusterChange={setTargetCluster} online={onlineMigration} onOnlineChange={setOnlineMigration} maxDowntime={maxDowntime} @@ -447,7 +461,6 @@ function VMActionMenu({ const [isOpen, setIsOpen] = useState(false); const menuRef = useRef(null); - // Close menu when clicking outside useEffect(() => { function handleClickOutside(event: MouseEvent) { if (menuRef.current && !menuRef.current.contains(event.target as Node)) { @@ -471,43 +484,10 @@ function VMActionMenu({ const handleAction = (action: () => void) => (e: React.MouseEvent) => { e.stopPropagation(); - action(); setIsOpen(false); + action(); }; - // Calculate menu position to avoid overflow - const getMenuPosition = () => { - const viewportHeight = window.innerHeight; - const viewportWidth = window.innerWidth; - const buttonRect = menuRef.current?.querySelector('button')?.getBoundingClientRect(); - - if (!buttonRect) return { top: '100%', left: 0 }; - - const menuHeight = 400; // approximate menu height - const menuWidth = 192; // approximate menu width (w-48 = 12rem = 192px) - const spaceBelow = viewportHeight - buttonRect.bottom; - const spaceAbove = buttonRect.top; - const spaceRight = viewportWidth - buttonRect.right; - - // Vertical positioning - let verticalPos: { top?: string; bottom?: string } = { top: '100%' }; - if (spaceBelow >= menuHeight) { - verticalPos = { top: '100%' }; - } else if (spaceAbove >= menuHeight) { - verticalPos = { bottom: '100%' }; - } - - // Horizontal positioning - account for overflow on the right - let horizontalPos: { left?: number; right?: number } = { left: 0 }; - if (spaceRight < menuWidth) { - horizontalPos = { right: 0 }; - } - - return { ...verticalPos, ...horizontalPos }; - }; - - const position = getMenuPosition(); - return (
{isOpen && ( -
+
{vm.status === 'stopped' && (
- diff --git a/src/pages/Proxmox/BackupPage.tsx b/src/pages/Proxmox/BackupPage.tsx index f31fe16a..853da271 100644 --- a/src/pages/Proxmox/BackupPage.tsx +++ b/src/pages/Proxmox/BackupPage.tsx @@ -8,7 +8,6 @@ import { toast } from 'sonner'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/index'; import { Input } from '@/components/ui/index'; import { Label } from '@/components/ui/index'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/index'; export function ProxmoxBackupPage() { const [clusters, setClusters] = useState([]); @@ -40,8 +39,34 @@ export function ProxmoxBackupPage() { if (!clusterId) return; setIsLoading(true); try { - const data = await listProxmoxBackupJobs(clusterId, ''); - setJobs(data); + const raw = await listProxmoxBackupJobs(clusterId, ''); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const normalized = (raw as any[]).map((job) => { + const enabledRaw = job.enabled ?? job.enable ?? 1; + const isEnabled = enabledRaw === 1 || enabledRaw === true || enabledRaw === '1'; + const nextRunRaw = job['next-run'] ?? job.next_run ?? job.nextRun; + const nextRunStr = nextRunRaw + ? new Date(Number(nextRunRaw) * 1000).toLocaleString() + : undefined; + return { + id: job.id || String(job.jobid || ''), + name: job.id || job.comment || `job-${job.jobid || '?'}`, + node: job.node || 'all', + schedule: job.schedule || '-', + status: isEnabled ? ('idle' as const) : ('idle' as const), + lastRun: undefined, + nextRun: nextRunStr, + size: undefined, + count: undefined, + enabled: isEnabled, + vmid: job.vmid, + storage: job.storage, + mode: job.mode, + compress: job.compress, + comment: job.comment, + }; + }); + setJobs(normalized); } catch (err) { console.error('Failed to load backup jobs:', err); toast.error('Failed to load backup jobs'); diff --git a/src/pages/Proxmox/FirewallPage.tsx b/src/pages/Proxmox/FirewallPage.tsx index a7eed7f5..c60b688e 100644 --- a/src/pages/Proxmox/FirewallPage.tsx +++ b/src/pages/Proxmox/FirewallPage.tsx @@ -1,9 +1,12 @@ import React, { useState, useEffect, useCallback } from 'react'; import { Button } from '@/components/ui/index'; import { Input } from '@/components/ui/index'; +import { Label } from '@/components/ui/index'; import { RefreshCw } from 'lucide-react'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/index'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/index'; import { FirewallRuleList } from '@/components/Proxmox'; -import { listProxmoxClusters, listFirewallRules } from '@/lib/proxmoxClient'; +import { listProxmoxClusters, listFirewallRules, addFirewallRule } from '@/lib/proxmoxClient'; import type { ClusterInfo } from '@/lib/domain'; import { toast } from 'sonner'; @@ -16,6 +19,15 @@ export function ProxmoxFirewallPage() { const [rules, setRules] = useState([]); const [isLoading, setIsLoading] = useState(false); + // New rule dialog + const [showNewRuleDialog, setShowNewRuleDialog] = useState(false); + const [ruleAction, setRuleAction] = useState('ACCEPT'); + const [ruleProtocol, setRuleProtocol] = useState('tcp'); + const [ruleSource, setRuleSource] = useState(''); + const [ruleDest, setRuleDest] = useState(''); + const [ruleDport, setRuleDport] = useState(''); + const [ruleComment, setRuleComment] = useState(''); + useEffect(() => { listProxmoxClusters() .then((cls) => { @@ -50,6 +62,41 @@ export function ProxmoxFirewallPage() { setNodeId(nodeInputValue.trim() || 'localhost'); }; + const handleNewRule = () => { + setRuleAction('ACCEPT'); + setRuleProtocol('tcp'); + setRuleSource(''); + setRuleDest(''); + setRuleDport(''); + setRuleComment(''); + setShowNewRuleDialog(true); + }; + + const handleSubmitNewRule = async () => { + if (!ruleAction || !ruleProtocol) { + toast.error('Action and protocol are required'); + return; + } + + try { + await addFirewallRule(selectedClusterId, nodeId, { + action: ruleAction, + proto: ruleProtocol, + source: ruleSource || undefined, + dest: ruleDest || undefined, + dport: ruleDport || undefined, + comment: ruleComment || undefined, + enable: 1, + }); + toast.success('Firewall rule created'); + setShowNewRuleDialog(false); + await loadRules(selectedClusterId, nodeId); + } catch (error) { + console.error('Failed to create firewall rule:', error); + toast.error(`Failed to create firewall rule: ${error}`); + } + }; + if (clusters.length === 0 && !isLoading) { return (
@@ -113,7 +160,89 @@ export function ProxmoxFirewallPage() { loadRules(selectedClusterId, nodeId)} + onNewRule={handleNewRule} /> + + + + + New Firewall Rule + +
+
+ + +
+
+ + +
+
+ + setRuleSource(e.target.value)} + placeholder="e.g. 192.168.1.0/24" + /> +
+
+ + setRuleDest(e.target.value)} + placeholder="e.g. 10.0.0.1" + /> +
+
+ + setRuleDport(e.target.value)} + placeholder="e.g. 80, 443, 8000:9000" + /> +
+
+ + setRuleComment(e.target.value)} + placeholder="Rule description" + /> +
+
+ + + + +
+
); } diff --git a/src/pages/Proxmox/NetworkPage.tsx b/src/pages/Proxmox/NetworkPage.tsx index 84ab1644..7908eca7 100644 --- a/src/pages/Proxmox/NetworkPage.tsx +++ b/src/pages/Proxmox/NetworkPage.tsx @@ -7,6 +7,7 @@ import { listNetworkInterfaces, listProxmoxClusters, NetworkInterface } from '@/ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/index'; import { Input } from '@/components/ui/index'; import { Label } from '@/components/ui/index'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/index'; import { toast } from 'sonner'; export function ProxmoxNetworkPage() { @@ -216,12 +217,21 @@ export function ProxmoxNetworkPage() {
- setIfaceType(e.target.value)} - placeholder="eth, bond, bridge, vlan" - /> +
diff --git a/src/pages/Proxmox/VMsPage.tsx b/src/pages/Proxmox/VMsPage.tsx index de4b15e7..bb1a262c 100644 --- a/src/pages/Proxmox/VMsPage.tsx +++ b/src/pages/Proxmox/VMsPage.tsx @@ -87,6 +87,8 @@ export function ProxmoxVMsPage() { loadVms(selectedClusterId)} selectedVMs={selectedVMs} onToggleSelect={(vm) => { From 37f729923b92a77ccecfd48d0b1f33a6c5b28972 Mon Sep 17 00:00:00 2001 From: Shaun Arman Date: Sun, 21 Jun 2026 15:09:57 -0500 Subject: [PATCH 2/6] docs: add ticket summary for proxmox and AI chat fixes --- tickets/fix-proxmox-and-ai-issues.md | 79 ++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 tickets/fix-proxmox-and-ai-issues.md diff --git a/tickets/fix-proxmox-and-ai-issues.md b/tickets/fix-proxmox-and-ai-issues.md new file mode 100644 index 00000000..efbc690d --- /dev/null +++ b/tickets/fix-proxmox-and-ai-issues.md @@ -0,0 +1,79 @@ +# Fix: Proxmox Dashboard and AI Chat Issues + +## Description + +Resolved 7 bugs across the Proxmox management dashboard and the AI triage chat that rendered several features non-functional. Issues were verified against a live Proxmox VE host at `172.0.0.18`. + +## Acceptance Criteria + +- [x] VM action menu items (start, stop, reboot, shutdown, suspend, resume, migrate, clone, delete) execute and close the menu +- [x] Migration dialog presents a "Target Remote" dropdown listing all configured clusters for cross-datacenter migration +- [x] Storage page displays actual storage from PVE (cluster-wide, with correct size/used/available figures) +- [x] Network interface "Type" field is a dropdown of all valid PVE interface types, not a free-text input +- [x] Firewall "New Rule" button opens a dialog with action/protocol/source/dest/port fields that submits to PVE +- [x] Backup page renders all backup jobs configured in PVE with their storage target, schedule, VM list, and next-run time +- [x] AI chat with Qwen 3.5 (via LiteLLM) no longer throws `BadRequestError: System message must be at the beginning` +- [x] `cargo check`, `cargo clippy -D warnings`, `tsc --noEmit`, `eslint --max-warnings 0`, all Rust tests (432), all frontend tests (386) pass + +## Work Implemented + +### Issue 1 — VM Actions: Tauri param name mismatch + menu state + +**Root cause**: `VMList` internally re-fetched cluster data on its own, causing a race condition; all VM action Tauri commands used `node: String` but the frontend sent `nodeId` (Tauri 2.x maps camelCase `nodeId` → snake_case `node_id`, so no value arrived); action menu buttons had no `onClick` handlers. + +**Fix**: +- `src/pages/Proxmox/VMsPage.tsx`: pass `clusterId` and `clusters` props to ``. +- `src/components/Proxmox/VMList.tsx`: accept those props, remove internal cluster `useEffect`, wire all action buttons through `handleAction()` wrapper (closes menu, stops propagation). +- `src-tauri/src/commands/proxmox.rs`: renamed `node: String` → `node_id: String` (and body usages) in `get_proxmox_vm`, `start_proxmox_vm`, `stop_proxmox_vm`, `reboot_proxmox_vm`, `shutdown_proxmox_vm`, `resume_proxmox_vm`, `suspend_proxmox_vm`, `clone_vm`, `delete_vm`. + +### Issue 2 — Migration: no Target Remote option + +**Fix**: `VMList.tsx` `MigrationDialog` now receives `clusters` and `currentClusterId` props; when more than one cluster is configured a "Target Remote" `` with a `