fix(proxmox): resolve 7 dashboard and AI chat issues #129

Merged
sarman merged 6 commits from fix/proxmox-issues-v2 into beta 2026-06-21 21:35:29 +00:00
11 changed files with 769 additions and 411 deletions

View File

@ -351,37 +351,10 @@ pub async fn chat_message(
let agent_registry = create_agent_registry(); let agent_registry = create_agent_registry();
let devops_agent = agent_registry.get("devops-incident-responder"); 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 // 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 // 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 tools = if provider_config.supports_tool_calling.unwrap_or(false) {
let mut all_tools = crate::ai::tools::get_available_tools(); let mut all_tools = crate::ai::tools::get_available_tools();
let mcp_tools = crate::ai::tools::get_enabled_mcp_tools(&state).await; let mcp_tools = crate::ai::tools::get_enabled_mcp_tools(&state).await;
@ -395,9 +368,6 @@ pub async fn chat_message(
None 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 is_openai_compatible = {
let kind = if provider_config.provider_type.is_empty() { let kind = if provider_config.provider_type.is_empty() {
provider_config.name.as_str() provider_config.name.as_str()
@ -407,59 +377,71 @@ pub async fn chat_message(
!matches!(kind, "anthropic" | "gemini" | "mistral" | "ollama") !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<String> = 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 { if tools.is_some() && is_openai_compatible {
messages.push(Message { system_parts.push(
role: "system".into(), "CRITICAL: You have tools available. When calling tools, you MUST use the native \
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 <tool_name>. DO NOT output text descriptions of tool calls. Use the structured tool_calls field in your response.".into(), JSON function calling format in your API response. DO NOT output XML tags like \
tool_call_id: None, <tool_name>. DO NOT output text descriptions of tool calls. Use the structured \
tool_calls: None, tool_calls field in your response."
}); .to_string(),
);
messages.push(Message { system_parts.push(format!(
role: "system".into(), "TOOL EXECUTION BUDGET: You have a maximum of {MAX_TOOL_ITERATIONS} rounds (each \
content: format!( AI response counts as one round). You can call multiple tools in a single round. \
"TOOL EXECUTION BUDGET: You have a maximum of {MAX_TOOL_ITERATIONS} rounds (each AI response counts as one round). \ Plan your investigation efficiently:\n\
You can call multiple tools in a single round. \ - Call multiple related tools in the same round when possible\n\
Plan your investigation efficiently:\n\ - Prioritize high-value diagnostic commands first\n\
- Call multiple related tools in the same round when possible\n\ - Use comprehensive output formats (e.g., kubectl --output=yaml) to gather more data per call\n\
- Prioritize high-value diagnostic commands first\n\ - Reserve 1 round for your final summary/answer\n\
- Use comprehensive output formats (e.g., kubectl --output=yaml) to gather more data per call\n\ - If you exceed the budget, you'll be cut off mid-investigation\n\
- Reserve 1 round for your final summary/answer\n\ Current round count is not visible to you, so plan conservatively."
- 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,
});
} }
// 4. Integration context as system message — still before history
if !integration_context.is_empty() { 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 { messages.push(Message {
role: "system".into(), role: "system".into(),
content: format!( content: system_parts.join("\n\n---\n\n"),
"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_call_id: None,
tool_calls: 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<Message> = history let filtered_history: Vec<Message> = history
.into_iter() .into_iter()
.filter(|msg| msg.role != "system") .filter(|msg| msg.role != "system")
.collect(); .collect();
// 6. Add filtered history (user/assistant messages only) — all system messages are already above
messages.extend(filtered_history); messages.extend(filtered_history);
messages.push(Message { messages.push(Message {
@ -541,19 +523,25 @@ pub async fn chat_message(
if let Some(tool_calls) = &response.tool_calls { if let Some(tool_calls) = &response.tool_calls {
tracing::info!("AI requested {} tool call(s)", tool_calls.len()); 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 { for tool_call in tool_calls {
tracing::info!("Executing tool: {}", tool_call.name); tracing::info!("Executing tool: {}", tool_call.name);
let tool_result = execute_tool_call(tool_call, &app_handle, &state).await; let tool_result = execute_tool_call(tool_call, &app_handle, &state).await;
// Format result
let result_content = match tool_result { let result_content = match tool_result {
Ok(result) => result, Ok(result) => result,
Err(e) => format!("Error executing tool: {e}"), Err(e) => format!("Error executing tool: {e}"),
}; };
// Add tool result as a message
messages.push(Message { messages.push(Message {
role: "tool".into(), role: "tool".into(),
content: result_content, content: result_content,

View File

@ -432,7 +432,7 @@ pub async fn list_proxmox_vms(
#[tauri::command] #[tauri::command]
pub async fn get_proxmox_vm( pub async fn get_proxmox_vm(
cluster_id: String, cluster_id: String,
node: String, node_id: String,
vm_id: u32, vm_id: u32,
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<serde_json::Value, String> { ) -> Result<serde_json::Value, String> {
@ -441,7 +441,7 @@ pub async fn get_proxmox_vm(
let vm = crate::proxmox::vm::get_vm( let vm = crate::proxmox::vm::get_vm(
&client_guard, &client_guard,
&node, &node_id,
vm_id, vm_id,
client_guard.ticket.as_deref().unwrap_or(""), client_guard.ticket.as_deref().unwrap_or(""),
) )
@ -455,7 +455,7 @@ pub async fn get_proxmox_vm(
#[tauri::command] #[tauri::command]
pub async fn start_proxmox_vm( pub async fn start_proxmox_vm(
cluster_id: String, cluster_id: String,
node: String, node_id: String,
vm_id: u32, vm_id: u32,
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<(), String> { ) -> Result<(), String> {
@ -464,7 +464,7 @@ pub async fn start_proxmox_vm(
crate::proxmox::vm::start_vm( crate::proxmox::vm::start_vm(
&client_guard, &client_guard,
&node, &node_id,
vm_id, vm_id,
client_guard.ticket.as_deref().unwrap_or(""), client_guard.ticket.as_deref().unwrap_or(""),
) )
@ -476,7 +476,7 @@ pub async fn start_proxmox_vm(
#[tauri::command] #[tauri::command]
pub async fn stop_proxmox_vm( pub async fn stop_proxmox_vm(
cluster_id: String, cluster_id: String,
node: String, node_id: String,
vm_id: u32, vm_id: u32,
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<(), String> { ) -> Result<(), String> {
@ -485,7 +485,7 @@ pub async fn stop_proxmox_vm(
crate::proxmox::vm::stop_vm( crate::proxmox::vm::stop_vm(
&client_guard, &client_guard,
&node, &node_id,
vm_id, vm_id,
client_guard.ticket.as_deref().unwrap_or(""), client_guard.ticket.as_deref().unwrap_or(""),
) )
@ -497,7 +497,7 @@ pub async fn stop_proxmox_vm(
#[tauri::command] #[tauri::command]
pub async fn reboot_proxmox_vm( pub async fn reboot_proxmox_vm(
cluster_id: String, cluster_id: String,
node: String, node_id: String,
vm_id: u32, vm_id: u32,
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<(), String> { ) -> Result<(), String> {
@ -506,7 +506,7 @@ pub async fn reboot_proxmox_vm(
crate::proxmox::vm::reboot_vm( crate::proxmox::vm::reboot_vm(
&client_guard, &client_guard,
&node, &node_id,
vm_id, vm_id,
client_guard.ticket.as_deref().unwrap_or(""), client_guard.ticket.as_deref().unwrap_or(""),
) )
@ -518,7 +518,7 @@ pub async fn reboot_proxmox_vm(
#[tauri::command] #[tauri::command]
pub async fn shutdown_proxmox_vm( pub async fn shutdown_proxmox_vm(
cluster_id: String, cluster_id: String,
node: String, node_id: String,
vm_id: u32, vm_id: u32,
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<(), String> { ) -> Result<(), String> {
@ -527,7 +527,7 @@ pub async fn shutdown_proxmox_vm(
crate::proxmox::vm::shutdown_vm( crate::proxmox::vm::shutdown_vm(
&client_guard, &client_guard,
&node, &node_id,
vm_id, vm_id,
client_guard.ticket.as_deref().unwrap_or(""), client_guard.ticket.as_deref().unwrap_or(""),
) )
@ -539,7 +539,7 @@ pub async fn shutdown_proxmox_vm(
#[tauri::command] #[tauri::command]
pub async fn resume_proxmox_vm( pub async fn resume_proxmox_vm(
cluster_id: String, cluster_id: String,
node: String, node_id: String,
vm_id: u32, vm_id: u32,
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<(), String> { ) -> Result<(), String> {
@ -548,7 +548,7 @@ pub async fn resume_proxmox_vm(
crate::proxmox::vm::resume_vm( crate::proxmox::vm::resume_vm(
&client_guard, &client_guard,
&node, &node_id,
vm_id, vm_id,
client_guard.ticket.as_deref().unwrap_or(""), client_guard.ticket.as_deref().unwrap_or(""),
) )
@ -560,7 +560,7 @@ pub async fn resume_proxmox_vm(
#[tauri::command] #[tauri::command]
pub async fn suspend_proxmox_vm( pub async fn suspend_proxmox_vm(
cluster_id: String, cluster_id: String,
node: String, node_id: String,
vm_id: u32, vm_id: u32,
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<(), String> { ) -> Result<(), String> {
@ -569,7 +569,7 @@ pub async fn suspend_proxmox_vm(
crate::proxmox::vm::suspend_vm( crate::proxmox::vm::suspend_vm(
&client_guard, &client_guard,
&node, &node_id,
vm_id, vm_id,
client_guard.ticket.as_deref().unwrap_or(""), client_guard.ticket.as_deref().unwrap_or(""),
) )
@ -581,7 +581,7 @@ pub async fn suspend_proxmox_vm(
#[tauri::command] #[tauri::command]
pub async fn clone_vm( pub async fn clone_vm(
cluster_id: String, cluster_id: String,
node: String, node_id: String,
vm_id: u32, vm_id: u32,
new_vmid: u32, new_vmid: u32,
name: String, name: String,
@ -592,7 +592,7 @@ pub async fn clone_vm(
crate::proxmox::vm::clone_vm( crate::proxmox::vm::clone_vm(
&client_guard, &client_guard,
&node, &node_id,
vm_id, vm_id,
new_vmid, new_vmid,
&name, &name,
@ -606,7 +606,7 @@ pub async fn clone_vm(
#[tauri::command] #[tauri::command]
pub async fn delete_vm( pub async fn delete_vm(
cluster_id: String, cluster_id: String,
node: String, node_id: String,
vm_id: u32, vm_id: u32,
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<(), String> { ) -> Result<(), String> {
@ -615,7 +615,7 @@ pub async fn delete_vm(
crate::proxmox::vm::delete_vm( crate::proxmox::vm::delete_vm(
&client_guard, &client_guard,
&node, &node_id,
vm_id, vm_id,
client_guard.ticket.as_deref().unwrap_or(""), client_guard.ticket.as_deref().unwrap_or(""),
) )
@ -681,74 +681,113 @@ pub async fn list_proxmox_backup_jobs(
Ok(jobs) Ok(jobs)
} }
/// List Proxmox Datastores (Storage per node) /// List Proxmox Datastores (cluster-wide via cluster/resources)
#[tauri::command] #[tauri::command]
pub async fn list_proxmox_datastores( pub async fn list_proxmox_datastores(
cluster_id: String, cluster_id: String,
_state: State<'_, AppState>, _state: State<'_, AppState>,
) -> Result<Vec<serde_json::Value>, String> { ) -> Result<Vec<serde_json::Value>, 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 = get_proxmox_client_for_cluster(&cluster_id, &_state).await?;
let client_guard = client.lock().await; let client_guard = client.lock().await;
// First, get all nodes let response: serde_json::Value = client_guard
let nodes_path = "cluster/resources?type=node";
let nodes_response: serde_json::Value = client_guard
.get( .get(
nodes_path, "cluster/resources?type=storage",
Some(client_guard.ticket.as_deref().unwrap_or("")), Some(client_guard.ticket.as_deref().unwrap_or("")),
) )
.await .await
.map_err(|e| format!("Failed to list nodes: {}", e))?; .map_err(|e| format!("Failed to list cluster storage: {}", e))?;
let nodes: Vec<String> = nodes_response let entries = response.as_array().ok_or("Invalid response format")?;
.as_array()
.unwrap_or(&vec![]) let all_storage: Vec<serde_json::Value> = entries
.iter() .iter()
.filter_map(|n| { .filter_map(|entry| {
n.get("node") let obj = entry.as_object()?;
.and_then(|node| node.as_str()) let mut normalized = serde_json::Map::new();
.map(|s| s.to_string())
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("");
// Avoid double-slash when cluster/resources omits "node" for shared storage
let storage_id = if node_name.is_empty() {
format!("storage/{}", storage_name)
} else {
format!("storage/{}/{}", node_name, storage_name)
};
if storage_name.is_empty() {
tracing::warn!(
node = node_name,
"storage entry has empty storage name — skipping"
);
return None;
}
tracing::debug!(storage_id = %storage_id, "generated storage ID");
normalized.insert("id".to_string(), serde_json::Value::String(storage_id));
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(); .collect();
if nodes.is_empty() {
return Ok(vec![]);
}
// Fetch storage for each node
let mut all_storage: Vec<serde_json::Value> = 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) Ok(all_storage)
} }
@ -756,7 +795,7 @@ pub async fn list_proxmox_datastores(
#[tauri::command] #[tauri::command]
pub async fn trigger_proxmox_backup_job( pub async fn trigger_proxmox_backup_job(
cluster_id: String, cluster_id: String,
node: String, node_id: String,
job_id: u32, job_id: u32,
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<(), String> { ) -> Result<(), String> {
@ -765,7 +804,7 @@ pub async fn trigger_proxmox_backup_job(
crate::proxmox::backup::trigger_backup_job( crate::proxmox::backup::trigger_backup_job(
&client_guard, &client_guard,
&node, &node_id,
job_id, job_id,
client_guard.ticket.as_deref().unwrap_or(""), client_guard.ticket.as_deref().unwrap_or(""),
) )
@ -1384,7 +1423,7 @@ pub async fn get_certificate(
#[tauri::command] #[tauri::command]
pub async fn list_firewall_rules( pub async fn list_firewall_rules(
cluster_id: String, cluster_id: String,
node: String, node_id: String,
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<Vec<serde_json::Value>, String> { ) -> Result<Vec<serde_json::Value>, String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?; let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
@ -1392,51 +1431,85 @@ pub async fn list_firewall_rules(
let rules = crate::proxmox::firewall::list_firewall_rules( let rules = crate::proxmox::firewall::list_firewall_rules(
&client_guard, &client_guard,
&node, &node_id,
client_guard.ticket.as_deref().unwrap_or(""), client_guard.ticket.as_deref().unwrap_or(""),
) )
.await .await
.map_err(|e| format!("Failed to list firewall rules: {}", e))?; .map_err(|e| format!("Failed to list firewall rules: {}", e))?;
// Normalize to match what FirewallRuleList component expects:
// rule (position number), action, protocol, source, destination, port, status
let json_rules: Vec<serde_json::Value> = rules let json_rules: Vec<serde_json::Value> = rules
.into_iter() .into_iter()
.map(|r| serde_json::to_value(r).map_err(|e| format!("Failed to serialize rule: {}", e))) .map(|r| {
.collect::<Result<Vec<_>, _>>()?; serde_json::json!({
"id": r.rule_num.to_string(),
"rule": r.rule_num,
"action": r.action,
"protocol": r.protocol,
"source": r.source,
"destination": r.destination,
"port": r.port,
"status": if r.enabled { "enabled" } else { "disabled" },
})
})
.collect();
Ok(json_rules) Ok(json_rules)
} }
/// Add firewall rule /// Add firewall rule
#[tauri::command] #[tauri::command]
#[allow(clippy::too_many_arguments)]
pub async fn add_firewall_rule( pub async fn add_firewall_rule(
cluster_id: String, cluster_id: String,
node: String, node_id: String,
action: String, rule: serde_json::Value,
protocol: String,
source: String,
destination: String,
port: Option<String>,
enabled: bool,
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<(), String> { ) -> Result<(), String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?; let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let client_guard = client.lock().await; let client_guard = client.lock().await;
let rule = crate::proxmox::firewall::FirewallRule { let firewall_rule = crate::proxmox::firewall::FirewallRule {
rule_num: 0, rule_num: 0,
action, action: rule
protocol, .get("action")
source, .and_then(|v| v.as_str())
destination, .unwrap_or("ACCEPT")
port, .to_string(),
enabled, 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( crate::proxmox::firewall::add_rule(
&client_guard, &client_guard,
&node, &node_id,
&rule, &firewall_rule,
client_guard.ticket.as_deref().unwrap_or(""), client_guard.ticket.as_deref().unwrap_or(""),
) )
.await .await
@ -1447,7 +1520,7 @@ pub async fn add_firewall_rule(
#[tauri::command] #[tauri::command]
pub async fn delete_firewall_rule( pub async fn delete_firewall_rule(
cluster_id: String, cluster_id: String,
node: String, node_id: String,
rule_num: u32, rule_num: u32,
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<(), String> { ) -> Result<(), String> {
@ -1456,7 +1529,7 @@ pub async fn delete_firewall_rule(
crate::proxmox::firewall::delete_rule( crate::proxmox::firewall::delete_rule(
&client_guard, &client_guard,
&node, &node_id,
rule_num, rule_num,
client_guard.ticket.as_deref().unwrap_or(""), client_guard.ticket.as_deref().unwrap_or(""),
) )
@ -1594,7 +1667,7 @@ pub async fn get_ceph_cluster_status(
#[tauri::command] #[tauri::command]
pub async fn migrate_vm( pub async fn migrate_vm(
cluster_id: String, cluster_id: String,
node: String, node_id: String,
vm_id: u32, vm_id: u32,
target_node: String, target_node: String,
target_cluster: String, target_cluster: String,
@ -1605,7 +1678,7 @@ pub async fn migrate_vm(
let task = crate::proxmox::migration::migrate_vm( let task = crate::proxmox::migration::migrate_vm(
&client_guard, &client_guard,
&node, &node_id,
vm_id, vm_id,
&target_node, &target_node,
&target_cluster, &target_cluster,
@ -1621,7 +1694,7 @@ pub async fn migrate_vm(
#[tauri::command] #[tauri::command]
pub async fn list_migration_status( pub async fn list_migration_status(
cluster_id: String, cluster_id: String,
node: String, node_id: String,
state: State<'_, AppState>, state: State<'_, AppState>,
) -> Result<Vec<serde_json::Value>, String> { ) -> Result<Vec<serde_json::Value>, String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?; let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
@ -1629,7 +1702,7 @@ pub async fn list_migration_status(
let tasks = crate::proxmox::migration::list_migration_status( let tasks = crate::proxmox::migration::list_migration_status(
&client_guard, &client_guard,
&node, &node_id,
client_guard.ticket.as_deref().unwrap_or(""), client_guard.ticket.as_deref().unwrap_or(""),
) )
.await .await

View File

@ -23,7 +23,8 @@ pub struct FirewallStatus {
pub rule_count: u32, pub rule_count: u32,
} }
/// List firewall rules /// List firewall rules — returns normalized Vec<FirewallRule> using correct PVE field names.
/// PVE uses: pos (position), proto (protocol), enable (0/1 integer), src (source), dest (destination).
pub async fn list_firewall_rules( pub async fn list_firewall_rules(
client: &crate::proxmox::client::ProxmoxClient, client: &crate::proxmox::client::ProxmoxClient,
node: &str, node: &str,
@ -35,44 +36,60 @@ pub async fn list_firewall_rules(
.await .await
.map_err(|e| format!("Failed to list firewall rules: {}", e))?; .map_err(|e| format!("Failed to list firewall rules: {}", e))?;
if let Some(rules) = response.as_array() { let rules = response.as_array().ok_or("Invalid response format")?;
let rule_list: Vec<FirewallRule> = rules
.iter()
.filter_map(|rule| {
let rule_num = rule.get("rule_num")?.as_u64()? as u32;
let action = rule.get("action")?.as_str()?.to_string();
let protocol = rule.get("protocol")?.as_str().unwrap_or("").to_string();
let source = rule.get("source")?.as_str().unwrap_or("").to_string();
let destination = rule.get("dest")?.as_str().unwrap_or("").to_string();
let port = rule
.get("dport")
.or(rule.get("sport"))
.and_then(|p| p.as_str())
.map(|s| s.to_string());
let enabled = rule
.get("enabled")
.and_then(|e| e.as_bool())
.unwrap_or(true);
Some(FirewallRule { let rule_list: Vec<FirewallRule> = rules
rule_num, .iter()
action, .filter_map(|rule| {
protocol, // PVE uses "pos" for the rule position number
source, let rule_num = rule.get("pos").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
destination, let action = rule.get("action").and_then(|v| v.as_str())?.to_string();
port, // PVE uses "proto" not "protocol"
enabled, let protocol = rule
}) .get("proto")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
// source and dest are optional fields
let source = rule
.get("source")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let destination = rule
.get("dest")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let port = rule
.get("dport")
.or_else(|| rule.get("sport"))
.and_then(|p| p.as_str())
.map(|s| s.to_string());
// PVE uses "enable" as integer (1=enabled, 0=disabled), not "enabled" bool
let enabled = rule
.get("enable")
.and_then(|e| e.as_i64().map(|n| n != 0).or_else(|| e.as_bool()))
.unwrap_or(true);
Some(FirewallRule {
rule_num,
action,
protocol,
source,
destination,
port,
enabled,
}) })
.collect(); })
.collect();
Ok(rule_list) Ok(rule_list)
} else {
Err("Invalid response format".to_string())
}
} }
/// Add firewall rule /// Add firewall rule — uses correct PVE API field names (proto, enable, dest).
/// `rule.rule_num` is intentionally not sent: PVE assigns the position (pos) automatically
/// on creation. rule_num is only used for update/delete operations on existing rules.
pub async fn add_rule( pub async fn add_rule(
client: &crate::proxmox::client::ProxmoxClient, client: &crate::proxmox::client::ProxmoxClient,
node: &str, node: &str,
@ -80,15 +97,29 @@ pub async fn add_rule(
ticket: &str, ticket: &str,
) -> Result<(), String> { ) -> Result<(), String> {
let path = format!("nodes/{}/firewall/rules", node); let path = format!("nodes/{}/firewall/rules", node);
let config = serde_json::json!({
let mut config = serde_json::json!({
"action": rule.action, "action": rule.action,
"protocol": rule.protocol, "type": "in",
"source": rule.source, "enable": if rule.enabled { 1 } else { 0 }
"dest": rule.destination,
"dport": rule.port,
"enabled": rule.enabled
}); });
// Only include optional fields when non-empty
if !rule.protocol.is_empty() {
config["proto"] = serde_json::Value::String(rule.protocol.clone());
}
if !rule.source.is_empty() {
config["source"] = serde_json::Value::String(rule.source.clone());
}
if !rule.destination.is_empty() {
config["dest"] = serde_json::Value::String(rule.destination.clone());
}
if let Some(ref port) = rule.port {
if !port.is_empty() {
config["dport"] = serde_json::Value::String(port.clone());
}
}
let _response: serde_json::Value = client let _response: serde_json::Value = client
.post(&path, &config, Some(ticket)) .post(&path, &config, Some(ticket))
.await .await
@ -200,19 +231,31 @@ pub async fn get_firewall_status(
.unwrap_or(&Vec::new()) .unwrap_or(&Vec::new())
.iter() .iter()
.filter_map(|rule| { .filter_map(|rule| {
let rule_num = rule.get("rule_num")?.as_u64()? as u32; let rule_num = rule.get("pos").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
let action = rule.get("action")?.as_str()?.to_string(); let action = rule.get("action").and_then(|v| v.as_str())?.to_string();
let protocol = rule.get("protocol")?.as_str().unwrap_or("").to_string(); let protocol = rule
let source = rule.get("source")?.as_str().unwrap_or("").to_string(); .get("proto")
let destination = rule.get("dest")?.as_str().unwrap_or("").to_string(); .and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let source = rule
.get("source")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let destination = rule
.get("dest")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let port = rule let port = rule
.get("dport") .get("dport")
.or(rule.get("sport")) .or_else(|| rule.get("sport"))
.and_then(|p| p.as_str()) .and_then(|p| p.as_str())
.map(|s| s.to_string()); .map(|s| s.to_string());
let enabled = rule let enabled = rule
.get("enabled") .get("enable")
.and_then(|e| e.as_bool()) .and_then(|e| e.as_i64().map(|n| n != 0).or_else(|| e.as_bool()))
.unwrap_or(true); .unwrap_or(true);
Some(FirewallRule { Some(FirewallRule {

View File

@ -15,6 +15,10 @@ interface BackupJobInfo {
size?: number; size?: number;
count?: number; count?: number;
enabled: boolean; enabled: boolean;
storage?: string;
vmid?: string | number;
mode?: string;
comment?: string;
} }
interface BackupJobListProps { interface BackupJobListProps {
@ -57,37 +61,34 @@ export function BackupJobList({
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Name</TableHead> <TableHead>ID</TableHead>
<TableHead>Storage</TableHead>
<TableHead>VMs</TableHead>
<TableHead>Node</TableHead> <TableHead>Node</TableHead>
<TableHead>Schedule</TableHead> <TableHead>Schedule</TableHead>
<TableHead>Status</TableHead> <TableHead>Enabled</TableHead>
<TableHead>Last Run</TableHead>
<TableHead>Next Run</TableHead> <TableHead>Next Run</TableHead>
<TableHead>Size</TableHead> <TableHead>Mode</TableHead>
<TableHead>Count</TableHead>
<TableHead className="text-right">Actions</TableHead> <TableHead className="text-right">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{jobs.map((job) => ( {jobs.map((job) => (
<TableRow key={job.id}> <TableRow key={job.id}>
<TableCell className="font-medium">{job.name}</TableCell> <TableCell className="font-medium font-mono text-xs">{job.name}</TableCell>
<TableCell>{job.node}</TableCell> <TableCell>{job.storage || '-'}</TableCell>
<TableCell>{job.schedule}</TableCell> <TableCell className="text-xs">{job.vmid ? String(job.vmid) : 'all'}</TableCell>
<TableCell>{job.node || 'all'}</TableCell>
<TableCell className="font-mono text-xs">{job.schedule}</TableCell>
<TableCell> <TableCell>
<span className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${ <span className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${
job.status === 'running' ? 'bg-blue-100 text-blue-800' : job.enabled ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'
job.status === 'success' ? 'bg-green-100 text-green-800' :
job.status === 'failed' ? 'bg-red-100 text-red-800' :
'bg-gray-100 text-gray-800'
}`}> }`}>
{job.status} {job.enabled ? 'enabled' : 'disabled'}
</span> </span>
</TableCell> </TableCell>
<TableCell>{job.lastRun || '-'}</TableCell> <TableCell className="text-xs">{job.nextRun || '-'}</TableCell>
<TableCell>{job.nextRun || '-'}</TableCell> <TableCell className="text-xs">{job.mode || '-'}</TableCell>
<TableCell>{job.size ? `${(job.size / (1024 * 1024 * 1024)).toFixed(2)} GB` : '-'}</TableCell>
<TableCell>{job.count || '-'}</TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
<div className="flex items-center justify-end space-x-2"> <div className="flex items-center justify-end space-x-2">
<button <button

View File

@ -19,6 +19,7 @@ interface FirewallRuleListProps {
rules: FirewallRuleInfo[]; rules: FirewallRuleInfo[];
onRefresh?: () => void; onRefresh?: () => void;
isLoading?: boolean; isLoading?: boolean;
onNewRule?: () => void;
onEnable?: (rule: FirewallRuleInfo) => void; onEnable?: (rule: FirewallRuleInfo) => void;
onDisable?: (rule: FirewallRuleInfo) => void; onDisable?: (rule: FirewallRuleInfo) => void;
onEdit?: (rule: FirewallRuleInfo) => void; onEdit?: (rule: FirewallRuleInfo) => void;
@ -30,6 +31,7 @@ export function FirewallRuleList({
rules, rules,
onRefresh, onRefresh,
isLoading, isLoading,
onNewRule,
onEnable, onEnable,
onDisable, onDisable,
onEdit, onEdit,
@ -44,7 +46,7 @@ export function FirewallRuleList({
<Button variant="outline" size="sm" onClick={onRefresh} disabled={isLoading}> <Button variant="outline" size="sm" onClick={onRefresh} disabled={isLoading}>
Refresh Refresh
</Button> </Button>
<Button size="sm"> <Button size="sm" onClick={onNewRule}>
<span className="mr-2 h-4 w-4">+</span> <span className="mr-2 h-4 w-4">+</span>
New Rule New Rule
</Button> </Button>

View File

@ -1,4 +1,5 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'; import React, { useState, useEffect, useCallback, useRef } from 'react';
import { clsx } from 'clsx';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/index'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/index';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/index'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/index';
import { Button } from '@/components/ui/index'; import { Button } from '@/components/ui/index';
@ -14,6 +15,7 @@ import { Checkbox as UICheckbox } from '@/components/ui/index';
import { Input } from '@/components/ui/index'; import { Input } from '@/components/ui/index';
import { AlertCircle } from 'lucide-react'; import { AlertCircle } from 'lucide-react';
import { Alert, AlertDescription } from '@/components/ui/index'; import { Alert, AlertDescription } from '@/components/ui/index';
import type { ClusterInfo } from '@/lib/domain';
interface VMInfo { interface VMInfo {
id: string; id: string;
@ -50,6 +52,8 @@ interface RawVMInfo {
interface VMListProps { interface VMListProps {
vms: RawVMInfo[]; vms: RawVMInfo[];
clusterId: string;
clusters?: ClusterInfo[];
onRefresh?: () => void; onRefresh?: () => void;
isLoading?: boolean; isLoading?: boolean;
onSnapshotAction?: (vm: VMInfo, action: 'create' | 'list' | 'rollback' | 'delete') => void; onSnapshotAction?: (vm: VMInfo, action: 'create' | 'list' | 'rollback' | 'delete') => void;
@ -85,22 +89,23 @@ function formatBytes(bytes: number): string {
export function VMList({ export function VMList({
vms: rawVms, vms: rawVms,
clusterId,
clusters = [],
onRefresh, onRefresh,
isLoading, isLoading,
onSnapshotAction, onSnapshotAction: _onSnapshotAction,
onMigrate, onMigrate: _onMigrate,
onClone, onClone: _onClone,
onDelete, onDelete: _onDelete,
selectedVMs = new Set<string>(), selectedVMs = new Set<string>(),
onToggleSelect, onToggleSelect,
}: VMListProps) { }: VMListProps) {
const [clusterId, setClusterId] = useState<string>('');
const [migrationVM, setMigrationVM] = useState<VMInfo | null>(null); const [migrationVM, setMigrationVM] = useState<VMInfo | null>(null);
const [targetNode, setTargetNode] = useState<string>(''); const [targetNode, setTargetNode] = useState<string>('');
const [targetCluster, setTargetCluster] = useState<string>('');
const [onlineMigration, setOnlineMigration] = useState(true); const [onlineMigration, setOnlineMigration] = useState(true);
const [maxDowntime, setMaxDowntime] = useState(30); const [maxDowntime, setMaxDowntime] = useState(30);
// Transform raw VM data to VMInfo format
const vms: VMInfo[] = React.useMemo(() => { const vms: VMInfo[] = React.useMemo(() => {
return rawVms.map((vm) => ({ return rawVms.map((vm) => ({
id: String(vm.id || vm.vmid), id: String(vm.id || vm.vmid),
@ -118,17 +123,13 @@ export function VMList({
})); }));
}, [rawVms]); }, [rawVms]);
useEffect(() => { // clusterId comes from props (not captured via closure over state), so it is always
invoke<string[]>('list_proxmox_clusters') // current when an action fires even if the user switches clusters mid-session.
.then((clusters: any[]) => {
if (clusters.length > 0) {
setClusterId(clusters[0].id);
}
})
.catch(() => {});
}, []);
const handleVMAction = useCallback(async (vm: VMInfo, action: string) => { const handleVMAction = useCallback(async (vm: VMInfo, action: string) => {
if (!clusterId) {
toast.error('No cluster selected');
return;
}
try { try {
switch (action) { switch (action) {
case 'start': case 'start':
@ -204,7 +205,8 @@ export function VMList({
.map((v) => v.node) .map((v) => v.node)
.filter((node, index, self) => self.indexOf(node) === index && node !== vm.node); .filter((node, index, self) => self.indexOf(node) === index && node !== vm.node);
setTargetNode(availableNodes[0] || ''); setTargetNode(availableNodes[0] || '');
}, [vms]); setTargetCluster(clusterId);
}, [vms, clusterId]);
const submitMigration = useCallback(async () => { const submitMigration = useCallback(async () => {
if (!migrationVM || !targetNode) { if (!migrationVM || !targetNode) {
@ -212,27 +214,34 @@ export function VMList({
return; return;
} }
const sourceCluster = clusterId;
const destCluster = targetCluster || clusterId;
try { try {
await invoke('migrate_vm', { await invoke('migrate_vm', {
clusterId, clusterId: sourceCluster,
nodeId: migrationVM.node, nodeId: migrationVM.node,
vmId: migrationVM.vmid, vmId: migrationVM.vmid,
targetNode, targetNode,
online: onlineMigration, targetCluster: destCluster,
max_downtime: maxDowntime,
}); });
toast.success(`VM ${migrationVM.name} migration started to ${targetNode}`); toast.success(`VM ${migrationVM.name} migration started to ${targetNode}${destCluster !== sourceCluster ? ` (cluster: ${destCluster})` : ''}`);
setMigrationVM(null); setMigrationVM(null);
setTargetNode(''); setTargetNode('');
setTargetCluster('');
onRefresh?.(); onRefresh?.();
} catch (error) { } catch (error) {
console.error('Failed to migrate VM:', error); console.error('Failed to migrate VM:', error);
toast.error(`Failed to migrate VM ${migrationVM.name}: ${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) => { const handleClone = useCallback(async (vm: VMInfo) => {
if (!clusterId) {
toast.error('No cluster selected');
return;
}
try { try {
const nextVmid = Math.max(...vms.map((v) => v.vmid), 100) + 1; const nextVmid = Math.max(...vms.map((v) => v.vmid), 100) + 1;
const newVmidStr = window.prompt(`Enter new VM ID for ${vm.name}:`, `${nextVmid}`); const newVmidStr = window.prompt(`Enter new VM ID for ${vm.name}:`, `${nextVmid}`);
@ -268,6 +277,10 @@ export function VMList({
}, [clusterId, vms, onRefresh]); }, [clusterId, vms, onRefresh]);
const handleDelete = useCallback(async (vm: VMInfo) => { 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!`, { 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', title: 'Delete VM',
kind: 'warning', kind: 'warning',
@ -309,7 +322,7 @@ export function VMList({
<TableRow> <TableRow>
<TableHead className="w-[40px]"> <TableHead className="w-[40px]">
<Checkbox <Checkbox
checked={vms.every((vm) => selectedVMs.has(vm.id))} checked={vms.length > 0 && vms.every((vm) => selectedVMs.has(vm.id))}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
if (checked) { if (checked) {
vms.forEach((vm) => selectedVMs.add(vm.id)); vms.forEach((vm) => selectedVMs.add(vm.id));
@ -413,11 +426,15 @@ export function VMList({
<MigrationDialog <MigrationDialog
vm={migrationVM} vm={migrationVM}
isOpen={!!migrationVM} isOpen={!!migrationVM}
onClose={() => setMigrationVM(null)} onClose={() => { setMigrationVM(null); setTargetNode(''); setTargetCluster(''); }}
onSubmit={submitMigration} onSubmit={submitMigration}
availableNodes={vms} availableNodes={vms}
clusters={clusters}
currentClusterId={clusterId}
targetNode={targetNode} targetNode={targetNode}
onTargetNodeChange={setTargetNode} onTargetNodeChange={setTargetNode}
targetCluster={targetCluster}
onTargetClusterChange={setTargetCluster}
online={onlineMigration} online={onlineMigration}
onOnlineChange={setOnlineMigration} onOnlineChange={setOnlineMigration}
maxDowntime={maxDowntime} maxDowntime={maxDowntime}
@ -445,12 +462,13 @@ function VMActionMenu({
onDelete, onDelete,
}: VMActionMenuProps) { }: VMActionMenuProps) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null); const [flipUpward, setFlipUpward] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const menuContentRef = useRef<HTMLDivElement>(null);
// Close menu when clicking outside
useEffect(() => { useEffect(() => {
function handleClickOutside(event: MouseEvent) { function handleClickOutside(event: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) { if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false); setIsOpen(false);
} }
} }
@ -464,6 +482,14 @@ function VMActionMenu({
}; };
}, [isOpen]); }, [isOpen]);
// After the menu renders, check whether it overflows the viewport bottom and flip if needed.
// Done in useEffect (not during render) to avoid the react-hooks/refs ESLint violation.
useEffect(() => {
if (!isOpen || !menuContentRef.current) return;
const rect = menuContentRef.current.getBoundingClientRect();
setFlipUpward(window.innerHeight - rect.bottom < 20);
}, [isOpen]);
const toggleMenu = (e: React.MouseEvent) => { const toggleMenu = (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
setIsOpen(!isOpen); setIsOpen(!isOpen);
@ -471,45 +497,12 @@ function VMActionMenu({
const handleAction = (action: () => void) => (e: React.MouseEvent) => { const handleAction = (action: () => void) => (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
action();
setIsOpen(false); 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 ( return (
<div className="relative" ref={menuRef}> <div className="relative" ref={containerRef}>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@ -520,16 +513,17 @@ function VMActionMenu({
</Button> </Button>
{isOpen && ( {isOpen && (
<div <div
className={`absolute z-50 w-48 rounded-md border bg-background shadow-md ${ ref={menuContentRef}
position.bottom ? 'bottom-full mb-2' : 'top-full mt-2' className={clsx(
} ${position.right ? 'right-0' : ''}`} 'absolute right-0 z-50 w-48 rounded-md border bg-background shadow-md',
style={{ left: position.left ?? undefined, right: position.right ?? undefined }} flipUpward ? 'bottom-full mb-2' : 'top-full mt-2',
)}
> >
<div className="space-y-1 p-1"> <div className="space-y-1 p-1">
{vm.status === 'stopped' && ( {vm.status === 'stopped' && (
<button <button
className="flex w-full items-center rounded-md px-2 py-1.5 text-sm hover:bg-accent" className="flex w-full items-center rounded-md px-2 py-1.5 text-sm hover:bg-accent"
onClick={() => onVMAction(vm, 'start')} onClick={handleAction(() => onVMAction(vm, 'start'))}
> >
<Play className="mr-2 h-4 w-4" /> <Play className="mr-2 h-4 w-4" />
Start Start
@ -539,28 +533,28 @@ function VMActionMenu({
<> <>
<button <button
className="flex w-full items-center rounded-md px-2 py-1.5 text-sm hover:bg-accent" className="flex w-full items-center rounded-md px-2 py-1.5 text-sm hover:bg-accent"
onClick={() => onVMAction(vm, 'stop')} onClick={handleAction(() => onVMAction(vm, 'stop'))}
> >
<Square className="mr-2 h-4 w-4" /> <Square className="mr-2 h-4 w-4" />
Stop Stop
</button> </button>
<button <button
className="flex w-full items-center rounded-md px-2 py-1.5 text-sm hover:bg-accent" className="flex w-full items-center rounded-md px-2 py-1.5 text-sm hover:bg-accent"
onClick={() => onVMAction(vm, 'reboot')} onClick={handleAction(() => onVMAction(vm, 'reboot'))}
> >
<RotateCcw className="mr-2 h-4 w-4" /> <RotateCcw className="mr-2 h-4 w-4" />
Reboot Reboot
</button> </button>
<button <button
className="flex w-full items-center rounded-md px-2 py-1.5 text-sm hover:bg-accent" className="flex w-full items-center rounded-md px-2 py-1.5 text-sm hover:bg-accent"
onClick={() => onVMAction(vm, 'shutdown')} onClick={handleAction(() => onVMAction(vm, 'shutdown'))}
> >
<Power className="mr-2 h-4 w-4" /> <Power className="mr-2 h-4 w-4" />
Shutdown Shutdown
</button> </button>
<button <button
className="flex w-full items-center rounded-md px-2 py-1.5 text-sm hover:bg-accent" className="flex w-full items-center rounded-md px-2 py-1.5 text-sm hover:bg-accent"
onClick={() => onVMAction(vm, 'suspend')} onClick={handleAction(() => onVMAction(vm, 'suspend'))}
> >
<Pause className="mr-2 h-4 w-4" /> <Pause className="mr-2 h-4 w-4" />
Suspend Suspend
@ -570,7 +564,7 @@ function VMActionMenu({
{vm.status === 'paused' && ( {vm.status === 'paused' && (
<button <button
className="flex w-full items-center rounded-md px-2 py-1.5 text-sm hover:bg-accent" className="flex w-full items-center rounded-md px-2 py-1.5 text-sm hover:bg-accent"
onClick={() => onVMAction(vm, 'resume')} onClick={handleAction(() => onVMAction(vm, 'resume'))}
> >
<PlayCircle className="mr-2 h-4 w-4" /> <PlayCircle className="mr-2 h-4 w-4" />
Resume Resume
@ -579,27 +573,27 @@ function VMActionMenu({
<div className="h-px bg-border my-1" /> <div className="h-px bg-border my-1" />
<button <button
className="flex w-full items-center rounded-md px-2 py-1.5 text-sm hover:bg-accent" className="flex w-full items-center rounded-md px-2 py-1.5 text-sm hover:bg-accent"
onClick={() => onSnapshotAction(vm, 'create')} onClick={handleAction(() => onSnapshotAction(vm, 'create'))}
> >
📸 Create Snapshot 📸 Create Snapshot
</button> </button>
<button <button
className="flex w-full items-center rounded-md px-2 py-1.5 text-sm hover:bg-accent" className="flex w-full items-center rounded-md px-2 py-1.5 text-sm hover:bg-accent"
onClick={() => onSnapshotAction(vm, 'list')} onClick={handleAction(() => onSnapshotAction(vm, 'list'))}
> >
📋 List Snapshots 📋 List Snapshots
</button> </button>
<div className="h-px bg-border my-1" /> <div className="h-px bg-border my-1" />
<button <button
className="flex w-full items-center rounded-md px-2 py-1.5 text-sm hover:bg-accent" className="flex w-full items-center rounded-md px-2 py-1.5 text-sm hover:bg-accent"
onClick={() => onMigrate(vm)} onClick={handleAction(() => onMigrate(vm))}
> >
<MoveRight className="mr-2 h-4 w-4" /> <MoveRight className="mr-2 h-4 w-4" />
Migrate Migrate
</button> </button>
<button <button
className="flex w-full items-center rounded-md px-2 py-1.5 text-sm hover:bg-accent" className="flex w-full items-center rounded-md px-2 py-1.5 text-sm hover:bg-accent"
onClick={() => onClone(vm)} onClick={handleAction(() => onClone(vm))}
> >
<Copy className="mr-2 h-4 w-4" /> <Copy className="mr-2 h-4 w-4" />
Clone Clone
@ -607,7 +601,7 @@ function VMActionMenu({
<div className="h-px bg-border my-1" /> <div className="h-px bg-border my-1" />
<button <button
className="flex w-full items-center rounded-md px-2 py-1.5 text-sm hover:bg-red-100 hover:text-red-600" className="flex w-full items-center rounded-md px-2 py-1.5 text-sm hover:bg-red-100 hover:text-red-600"
onClick={() => onDelete(vm)} onClick={handleAction(() => onDelete(vm))}
> >
<X className="mr-2 h-4 w-4" /> <X className="mr-2 h-4 w-4" />
Delete Delete
@ -625,8 +619,12 @@ interface MigrationDialogProps {
onClose: () => void; onClose: () => void;
onSubmit: () => void; onSubmit: () => void;
availableNodes: VMInfo[]; availableNodes: VMInfo[];
clusters: ClusterInfo[];
currentClusterId: string;
targetNode: string; targetNode: string;
onTargetNodeChange: (node: string) => void; onTargetNodeChange: (node: string) => void;
targetCluster: string;
onTargetClusterChange: (clusterId: string) => void;
online: boolean; online: boolean;
onOnlineChange: (online: boolean) => void; onOnlineChange: (online: boolean) => void;
maxDowntime: number; maxDowntime: number;
@ -639,8 +637,12 @@ function MigrationDialog({
onClose, onClose,
onSubmit, onSubmit,
availableNodes, availableNodes,
clusters,
currentClusterId,
targetNode, targetNode,
onTargetNodeChange, onTargetNodeChange,
targetCluster,
onTargetClusterChange,
online, online,
onOnlineChange, onOnlineChange,
maxDowntime, maxDowntime,
@ -648,10 +650,18 @@ function MigrationDialog({
}: MigrationDialogProps) { }: MigrationDialogProps) {
if (!vm) return null; if (!vm) return null;
const isCrossCluster = targetCluster && targetCluster !== currentClusterId;
const availableTargets = availableNodes const availableTargets = availableNodes
.map((v) => v.node) .map((v) => v.node)
.filter((node, index, self) => self.indexOf(node) === index && node !== vm.node); .filter((node, index, self) => self.indexOf(node) === index && node !== vm.node);
const canSubmitMigration = () => {
if (!targetNode) return false;
if (isCrossCluster) return true;
return availableTargets.length > 0;
};
return ( return (
<Dialog open={isOpen} onOpenChange={onClose}> <Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent> <DialogContent>
@ -666,24 +676,63 @@ function MigrationDialog({
</AlertDescription> </AlertDescription>
</Alert> </Alert>
{clusters.length > 1 && (
<div className="space-y-2">
<Label htmlFor="targetCluster">Target Remote</Label>
<Select value={targetCluster || currentClusterId} onValueChange={onTargetClusterChange}>
<SelectTrigger>
<SelectValue placeholder="Select target cluster" />
</SelectTrigger>
<SelectContent>
{clusters.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}{c.id === currentClusterId ? ' (current)' : ''}
</SelectItem>
))}
</SelectContent>
</Select>
{isCrossCluster && (
<p className="text-xs text-amber-600">
Cross-cluster migration VM will be moved to a different datacenter.
</p>
)}
</div>
)}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="targetNode">Target Node</Label> <Label htmlFor="targetNode">Target Node</Label>
<Select value={targetNode} onValueChange={onTargetNodeChange}> {isCrossCluster ? (
<SelectTrigger> <>
<SelectValue placeholder="Select target node" /> <Input
</SelectTrigger> id="targetNode"
<SelectContent> value={targetNode}
{availableTargets.map((node) => ( onChange={(e) => onTargetNodeChange(e.target.value)}
<SelectItem key={node} value={node}> placeholder="Enter target node name"
{node} />
</SelectItem> <p className="text-xs text-muted-foreground">
))} Enter the node name on the destination cluster
</SelectContent> </p>
</Select> </>
{availableTargets.length === 0 && ( ) : (
<p className="text-xs text-muted-foreground"> <>
No other nodes available for migration <Select value={targetNode} onValueChange={onTargetNodeChange}>
</p> <SelectTrigger>
<SelectValue placeholder="Select target node" />
</SelectTrigger>
<SelectContent>
{availableTargets.map((node) => (
<SelectItem key={node} value={node}>
{node}
</SelectItem>
))}
</SelectContent>
</Select>
{availableTargets.length === 0 && (
<p className="text-xs text-muted-foreground">
No other nodes available in this cluster
</p>
)}
</>
)} )}
</div> </div>
@ -722,7 +771,10 @@ function MigrationDialog({
<Button variant="outline" onClick={onClose}> <Button variant="outline" onClick={onClose}>
Cancel Cancel
</Button> </Button>
<Button onClick={onSubmit} disabled={!targetNode || availableTargets.length === 0}> <Button
onClick={onSubmit}
disabled={!canSubmitMigration()}
>
Start Migration Start Migration
</Button> </Button>
</DialogFooter> </DialogFooter>

View File

@ -8,7 +8,6 @@ import { toast } from 'sonner';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/index'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/index';
import { Input } from '@/components/ui/index'; import { Input } from '@/components/ui/index';
import { Label } from '@/components/ui/index'; import { Label } from '@/components/ui/index';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/index';
export function ProxmoxBackupPage() { export function ProxmoxBackupPage() {
const [clusters, setClusters] = useState<ClusterInfo[]>([]); const [clusters, setClusters] = useState<ClusterInfo[]>([]);
@ -40,8 +39,34 @@ export function ProxmoxBackupPage() {
if (!clusterId) return; if (!clusterId) return;
setIsLoading(true); setIsLoading(true);
try { try {
const data = await listProxmoxBackupJobs(clusterId, ''); const raw = await listProxmoxBackupJobs(clusterId, '');
setJobs(data); // 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) { } catch (err) {
console.error('Failed to load backup jobs:', err); console.error('Failed to load backup jobs:', err);
toast.error('Failed to load backup jobs'); toast.error('Failed to load backup jobs');
@ -55,26 +80,14 @@ export function ProxmoxBackupPage() {
}, [selectedClusterId, loadJobs]); }, [selectedClusterId, loadJobs]);
const handleNewJob = () => { const handleNewJob = () => {
setJobName(''); toast.warning(
setJobNode(''); 'Backup job creation requires additional backend implementation (POST cluster/backup) and is not yet available.',
setJobSchedule(''); );
setJobVms('');
setShowNewJobDialog(true);
}; };
const handleSubmitNewJob = async () => { const handleSubmitNewJob = async () => {
if (!jobName || !jobNode || !jobSchedule) { toast.warning('Backup job creation is not yet available.');
toast.error('Job name, node, and schedule are required'); setShowNewJobDialog(false);
return;
}
try {
toast.info(`Creating backup job ${jobName} - implementation pending`);
setShowNewJobDialog(false);
} catch (error) {
console.error('Failed to create backup job:', error);
toast.error(`Failed to create backup job: ${error}`);
}
}; };
if (clusters.length === 0 && !isLoading) { if (clusters.length === 0 && !isLoading) {

View File

@ -1,9 +1,12 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { Button } from '@/components/ui/index'; import { Button } from '@/components/ui/index';
import { Input } from '@/components/ui/index'; import { Input } from '@/components/ui/index';
import { Label } from '@/components/ui/index';
import { RefreshCw } from 'lucide-react'; 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 { FirewallRuleList } from '@/components/Proxmox';
import { listProxmoxClusters, listFirewallRules } from '@/lib/proxmoxClient'; import { listProxmoxClusters, listFirewallRules, addFirewallRule } from '@/lib/proxmoxClient';
import type { ClusterInfo } from '@/lib/domain'; import type { ClusterInfo } from '@/lib/domain';
import { toast } from 'sonner'; import { toast } from 'sonner';
@ -16,6 +19,15 @@ export function ProxmoxFirewallPage() {
const [rules, setRules] = useState<any[]>([]); const [rules, setRules] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(false); 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(() => { useEffect(() => {
listProxmoxClusters() listProxmoxClusters()
.then((cls) => { .then((cls) => {
@ -50,6 +62,41 @@ export function ProxmoxFirewallPage() {
setNodeId(nodeInputValue.trim() || 'localhost'); 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) { if (clusters.length === 0 && !isLoading) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@ -113,7 +160,89 @@ export function ProxmoxFirewallPage() {
<FirewallRuleList <FirewallRuleList
rules={rules} rules={rules}
onRefresh={() => loadRules(selectedClusterId, nodeId)} onRefresh={() => loadRules(selectedClusterId, nodeId)}
onNewRule={handleNewRule}
/> />
<Dialog open={showNewRuleDialog} onOpenChange={setShowNewRuleDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>New Firewall Rule</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="ruleAction">Action</Label>
<Select value={ruleAction} onValueChange={setRuleAction}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ACCEPT">ACCEPT</SelectItem>
<SelectItem value="DROP">DROP</SelectItem>
<SelectItem value="REJECT">REJECT</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="ruleProtocol">Protocol</Label>
<Select value={ruleProtocol} onValueChange={setRuleProtocol}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="tcp">TCP</SelectItem>
<SelectItem value="udp">UDP</SelectItem>
<SelectItem value="icmp">ICMP</SelectItem>
<SelectItem value="any">Any</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="ruleSource">Source (optional)</Label>
<Input
id="ruleSource"
value={ruleSource}
onChange={(e) => setRuleSource(e.target.value)}
placeholder="e.g. 192.168.1.0/24"
/>
</div>
<div className="space-y-2">
<Label htmlFor="ruleDest">Destination (optional)</Label>
<Input
id="ruleDest"
value={ruleDest}
onChange={(e) => setRuleDest(e.target.value)}
placeholder="e.g. 10.0.0.1"
/>
</div>
<div className="space-y-2">
<Label htmlFor="ruleDport">Destination Port (optional)</Label>
<Input
id="ruleDport"
value={ruleDport}
onChange={(e) => setRuleDport(e.target.value)}
placeholder="e.g. 80, 443, 8000:9000"
/>
</div>
<div className="space-y-2">
<Label htmlFor="ruleComment">Comment (optional)</Label>
<Input
id="ruleComment"
value={ruleComment}
onChange={(e) => setRuleComment(e.target.value)}
placeholder="Rule description"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowNewRuleDialog(false)}>
Cancel
</Button>
<Button onClick={handleSubmitNewRule}>
Create Rule
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
); );
} }

View File

@ -7,6 +7,7 @@ import { listNetworkInterfaces, listProxmoxClusters, NetworkInterface } from '@/
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/index'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/index';
import { Input } from '@/components/ui/index'; import { Input } from '@/components/ui/index';
import { Label } from '@/components/ui/index'; import { Label } from '@/components/ui/index';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/index';
import { toast } from 'sonner'; import { toast } from 'sonner';
export function ProxmoxNetworkPage() { export function ProxmoxNetworkPage() {
@ -16,7 +17,7 @@ export function ProxmoxNetworkPage() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [showAddDialog, setShowAddDialog] = useState(false); const [showAddDialog, setShowAddDialog] = useState(false);
const [editingInterface, setEditingInterface] = useState<NetworkInterface | null>(null); const [editingInterface] = useState<NetworkInterface | null>(null);
// Form state // Form state
const [ifaceName, setIfaceName] = useState(''); const [ifaceName, setIfaceName] = useState('');
@ -51,58 +52,24 @@ export function ProxmoxNetworkPage() {
.catch(console.error); .catch(console.error);
}, [loadInterfaces, nodeId]); }, [loadInterfaces, nodeId]);
const NOT_IMPLEMENTED_MSG =
'Network interface management requires additional backend implementation (POST/PUT/DELETE nodes/{node}/network) and is not yet available.';
const handleAddInterface = () => { const handleAddInterface = () => {
setEditingInterface(null); toast.warning(NOT_IMPLEMENTED_MSG);
setIfaceName('');
setIfaceType('eth');
setAddress('');
setNetmask('');
setGateway('');
setActive(true);
setShowAddDialog(true);
}; };
const handleEditInterface = (iface: NetworkInterface) => { const handleEditInterface = (_iface: NetworkInterface) => {
setEditingInterface(iface); toast.warning(NOT_IMPLEMENTED_MSG);
setIfaceName(iface.iface);
setIfaceType(iface.type);
setAddress(iface.address || '');
setNetmask(iface.netmask || '');
setGateway(iface.gateway || '');
setActive(iface.active);
setShowAddDialog(true);
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
if (!ifaceName || !ifaceType) { toast.warning(NOT_IMPLEMENTED_MSG);
toast.error('Interface name and type are required'); setShowAddDialog(false);
return;
}
try {
if (editingInterface) {
toast.info(`Updating interface ${ifaceName} - implementation pending`);
} else {
toast.info(`Creating interface ${ifaceName} - implementation pending`);
}
setShowAddDialog(false);
} catch (error) {
console.error('Failed to save interface:', error);
toast.error(`Failed to save interface: ${error}`);
}
}; };
const handleDeleteInterface = async (iface: NetworkInterface) => { const handleDeleteInterface = async (_iface: NetworkInterface) => {
if (!confirm(`Are you sure you want to delete interface ${iface.iface}?`)) { toast.warning(NOT_IMPLEMENTED_MSG);
return;
}
try {
toast.info(`Deleting interface ${iface.iface} - implementation pending`);
} catch (error) {
console.error('Failed to delete interface:', error);
toast.error(`Failed to delete interface: ${error}`);
}
}; };
return ( return (
@ -216,12 +183,21 @@ export function ProxmoxNetworkPage() {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="type">Interface Type</Label> <Label htmlFor="type">Interface Type</Label>
<Input <Select value={ifaceType} onValueChange={setIfaceType}>
id="type" <SelectTrigger>
value={ifaceType} <SelectValue placeholder="Select interface type" />
onChange={(e) => setIfaceType(e.target.value)} </SelectTrigger>
placeholder="eth, bond, bridge, vlan" <SelectContent>
/> <SelectItem value="eth">eth Ethernet</SelectItem>
<SelectItem value="bond">bond Network Bond</SelectItem>
<SelectItem value="bridge">bridge Linux Bridge</SelectItem>
<SelectItem value="vlan">vlan VLAN</SelectItem>
<SelectItem value="OVSBridge">OVSBridge Open vSwitch Bridge</SelectItem>
<SelectItem value="OVSBond">OVSBond Open vSwitch Bond</SelectItem>
<SelectItem value="OVSIntPort">OVSIntPort OVS Internal Port</SelectItem>
<SelectItem value="OVSPort">OVSPort OVS Port</SelectItem>
</SelectContent>
</Select>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="address">IP Address</Label> <Label htmlFor="address">IP Address</Label>

View File

@ -87,6 +87,8 @@ export function ProxmoxVMsPage() {
<VMList <VMList
vms={vms} vms={vms}
clusterId={selectedClusterId}
clusters={clusters}
onRefresh={() => loadVms(selectedClusterId)} onRefresh={() => loadVms(selectedClusterId)}
selectedVMs={selectedVMs} selectedVMs={selectedVMs}
onToggleSelect={(vm) => { onToggleSelect={(vm) => {

View File

@ -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 `<VMList>`.
- `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" `<Select>` appears. Selecting a different cluster switches the node input to free-text (cross-cluster node names can't be enumerated locally). `submitMigration` passes `targetCluster` to `migrate_vm`. Rust `migrate_vm` had `node: String` renamed to `node_id: String` as part of the bulk rename above.
### Issue 3 — Storage: fields mismatch, empty display
**Root cause**: `list_proxmox_datastores` made N per-node requests and returned raw PVE fields (`avail`, `total`, `plugintype`); `StorageList` expected (`available`, `size`, `type`).
**Fix** (`src-tauri/src/commands/proxmox.rs`): replaced the N-request per-node loop with a single `cluster/resources?type=storage` call. Response normalization maps `plugintype``type`, `disk`/`maxdisk` → `used`/`size`, computes `available = maxdisk.saturating_sub(disk)`.
### Issue 4 — Network: Interface Type free-text
**Fix** (`src/pages/Proxmox/NetworkPage.tsx`): replaced `<Input>` with a `<Select>` listing all PVE network interface types: `eth`, `bond`, `bridge`, `vlan`, `OVSBridge`, `OVSBond`, `OVSIntPort`, `OVSPort`.
### Issue 5 — Firewall: "New Rule" button did nothing
**Root cause**: Button had no `onClick`. `FirewallRuleListProps` had no `onNewRule` prop.
**Fix**:
- `src/components/Proxmox/FirewallRuleList.tsx`: added `onNewRule?: () => void` to props interface; wired button.
- `src/pages/Proxmox/FirewallPage.tsx`: added full new-rule dialog (action, protocol, source, dest, dport, comment fields); calls `addFirewallRule(clusterId, nodeId, ruleObject)` on submit; refreshes list.
- `src-tauri/src/commands/proxmox.rs` `add_firewall_rule`: rewrote signature from 6 flat params to `rule: serde_json::Value` (matching what the frontend sends as a single object) plus `node_id: String` rename.
### Issue 6 — Backup: empty display
**Root cause**: PVE `cluster/backup` returns `{ id, storage, schedule, enabled, next-run }` but `BackupJobInfo` expected `{ name, node, status, lastRun, nextRun }` — no fields matched.
**Fix**:
- `src/pages/Proxmox/BackupPage.tsx`: added normalizer that maps `id``name`, derives `enabled` (0/1/bool), converts `next-run` unix timestamp to locale string.
- `src/components/Proxmox/BackupJobList.tsx`: added `storage`, `vmid`, `mode`, `comment` optional fields to interface; updated table columns to show ID, Storage, VMs, Node, Schedule, Enabled, Next Run, Mode.
### Issue 7 — AI chat: system message ordering / Qwen 3.5 rejection
**Root cause**:
1. `chat_message` in `src-tauri/src/commands/ai.rs` pushed 45 consecutive `system` role messages before history. Qwen 3.5 (and LiteLLM's OpenAI compatibility layer) rejects anything but a single system message at position 0.
2. The tool-calling loop pushed `tool` role messages without first emitting the assistant message that contains `tool_calls` — violating the OpenAI API contract.
**Fix**:
1. All system prompt sections (agent prompt, domain prompt, tool instructions, integration context) are now collected into a `Vec<String>` and merged with `"\n\n---\n\n"` into a single `Message { role: "system" }` before history.
2. When tool calls are present, the assistant's response (with `tool_calls` populated) is pushed to the message history before any tool result messages.
## Testing Needed
- [ ] Start the app against Proxmox host `172.0.0.18`; verify all VM action menu items execute on a running VM
- [ ] Trigger a migration from one node to another (same cluster); verify the dialog lists nodes and submits
- [ ] If multiple clusters configured: verify "Target Remote" dropdown appears in Migration dialog
- [ ] Navigate to Storage page; verify all storage volumes appear with correct used/total/available figures
- [ ] Open Network → Add Interface; verify Type field is a dropdown with all interface types
- [ ] Open Firewall → New Rule; fill in action/protocol/port; verify rule is created in PVE
- [ ] Open Backup page; verify backup jobs configured in PVE appear with storage target and next-run time
- [ ] Start an AI chat session using a Qwen 3.5 model via LiteLLM; verify no `BadRequestError` and tool calls work correctly