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

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.
This commit is contained in:
Shaun Arman 2026-06-21 15:08:56 -05:00
parent a2875b60a9
commit 577512562b
9 changed files with 528 additions and 298 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,20 +377,31 @@ 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
if tools.is_some() && is_openai_compatible { // like Qwen 3.5 that reject multiple consecutive system messages.
messages.push(Message { let mut system_parts: Vec<String> = Vec::new();
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 <tool_name>. 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 { if let Some(agent) = devops_agent {
role: "system".into(), system_parts.push(agent.system_prompt.clone());
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. \ if let Some(ref prompt) = system_prompt {
if !prompt.is_empty() {
system_parts.push(prompt.clone());
}
}
if tools.is_some() && is_openai_compatible {
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 \
<tool_name>. 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\ Plan your investigation efficiently:\n\
- Call multiple related tools in the same round when possible\n\ - Call multiple related tools in the same round when possible\n\
- Prioritize high-value diagnostic commands first\n\ - Prioritize high-value diagnostic commands first\n\
@ -428,17 +409,11 @@ pub async fn chat_message(
- Reserve 1 round for your final summary/answer\n\ - Reserve 1 round for your final summary/answer\n\
- If you exceed the budget, you'll be cut off mid-investigation\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." 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() {
messages.push(Message { system_parts.push(format!(
role: "system".into(),
content: format!(
"INTERNAL DOCUMENTATION SOURCES:\n\n{integration_context}\n\n\ "INTERNAL DOCUMENTATION SOURCES:\n\n{integration_context}\n\n\
Instructions: The above content is from internal company documentation systems \ Instructions: The above content is from internal company documentation systems \
(Confluence, ServiceNow, Azure DevOps). \ (Confluence, ServiceNow, Azure DevOps). \
@ -447,19 +422,26 @@ pub async fn chat_message(
\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 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- 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." \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: system_parts.join("\n\n---\n\n"),
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,99 @@ 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("");
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(); .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 +781,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 +790,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 +1409,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,7 +1417,7 @@ 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
@ -1408,35 +1433,56 @@ pub async fn list_firewall_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 +1493,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 +1502,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 +1640,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 +1651,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 +1667,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 +1675,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

@ -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

@ -14,6 +14,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 +51,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 +88,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 +122,11 @@ export function VMList({
})); }));
}, [rawVms]); }, [rawVms]);
useEffect(() => {
invoke<string[]>('list_proxmox_clusters')
.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 +202,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 +211,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 +274,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 +319,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 +423,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}
@ -447,7 +461,6 @@ function VMActionMenu({
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null); const menuRef = 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 (menuRef.current && !menuRef.current.contains(event.target as Node)) {
@ -471,43 +484,10 @@ 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={menuRef}>
<Button <Button
@ -519,17 +499,12 @@ function VMActionMenu({
<MoreHorizontal className="h-4 w-4" /> <MoreHorizontal className="h-4 w-4" />
</Button> </Button>
{isOpen && ( {isOpen && (
<div <div className="absolute right-0 top-full z-50 mt-2 w-48 rounded-md border bg-background shadow-md">
className={`absolute z-50 w-48 rounded-md border bg-background shadow-md ${
position.bottom ? 'bottom-full mb-2' : 'top-full mt-2'
} ${position.right ? 'right-0' : ''}`}
style={{ left: position.left ?? undefined, right: position.right ?? undefined }}
>
<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 +514,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 +545,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 +554,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 +582,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 +600,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 +618,12 @@ function MigrationDialog({
onClose, onClose,
onSubmit, onSubmit,
availableNodes, availableNodes,
clusters,
currentClusterId,
targetNode, targetNode,
onTargetNodeChange, onTargetNodeChange,
targetCluster,
onTargetClusterChange,
online, online,
onOnlineChange, onOnlineChange,
maxDowntime, maxDowntime,
@ -648,6 +631,8 @@ 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);
@ -666,8 +651,45 @@ 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>
{isCrossCluster ? (
<>
<Input
id="targetNode"
value={targetNode}
onChange={(e) => onTargetNodeChange(e.target.value)}
placeholder="Enter target node name"
/>
<p className="text-xs text-muted-foreground">
Enter the node name on the destination cluster
</p>
</>
) : (
<>
<Select value={targetNode} onValueChange={onTargetNodeChange}> <Select value={targetNode} onValueChange={onTargetNodeChange}>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select target node" /> <SelectValue placeholder="Select target node" />
@ -682,9 +704,11 @@ function MigrationDialog({
</Select> </Select>
{availableTargets.length === 0 && ( {availableTargets.length === 0 && (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
No other nodes available for migration No other nodes available in this cluster
</p> </p>
)} )}
</>
)}
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@ -722,7 +746,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={!targetNode || (!isCrossCluster && availableTargets.length === 0)}
>
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');

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() {
@ -216,12 +217,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) => {