fix(proxmox): resolve 7 dashboard and AI chat issues #129
@ -351,37 +351,10 @@ pub async fn chat_message(
|
||||
let agent_registry = create_agent_registry();
|
||||
let devops_agent = agent_registry.get("devops-incident-responder");
|
||||
|
||||
// CRITICAL: Build messages array with ALL system messages FIRST, then history, then user message
|
||||
// This ensures system messages are always at the beginning as required by most LLM APIs
|
||||
let mut messages = Vec::new();
|
||||
|
||||
// 1. Inject devops-incident-responder as primary system prompt (always first)
|
||||
if let Some(agent) = devops_agent {
|
||||
messages.push(Message {
|
||||
role: "system".into(),
|
||||
content: agent.system_prompt.clone(),
|
||||
tool_call_id: None,
|
||||
tool_calls: None,
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Inject domain system prompt if provided (second position)
|
||||
if let Some(ref prompt) = system_prompt {
|
||||
if !prompt.is_empty() {
|
||||
messages.push(Message {
|
||||
role: "system".into(),
|
||||
content: prompt.clone(),
|
||||
tool_call_id: None,
|
||||
tool_calls: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Tool execution configuration
|
||||
const MAX_TOOL_ITERATIONS: usize = 20; // Allow sufficient iterations for complex diagnostics
|
||||
const MAX_TOOL_ITERATIONS: usize = 20;
|
||||
|
||||
// Get available tools — static + MCP
|
||||
// Only enable tools if the provider explicitly supports tool calling
|
||||
let tools = if provider_config.supports_tool_calling.unwrap_or(false) {
|
||||
let mut all_tools = crate::ai::tools::get_available_tools();
|
||||
let mcp_tools = crate::ai::tools::get_enabled_mcp_tools(&state).await;
|
||||
@ -395,9 +368,6 @@ pub async fn chat_message(
|
||||
None
|
||||
};
|
||||
|
||||
// If tools are available AND using OpenAI-compatible provider, add explicit JSON format instruction
|
||||
// Only OpenAI-compatible providers (default case in create_provider) actually support tool calling.
|
||||
// Others (anthropic, gemini, mistral, ollama) either ignore tools or use provider-specific formats.
|
||||
let is_openai_compatible = {
|
||||
let kind = if provider_config.provider_type.is_empty() {
|
||||
provider_config.name.as_str()
|
||||
@ -407,59 +377,71 @@ pub async fn chat_message(
|
||||
!matches!(kind, "anthropic" | "gemini" | "mistral" | "ollama")
|
||||
};
|
||||
|
||||
// 3. Tool-calling system messages — must come BEFORE history so all system messages are contiguous
|
||||
// Collect all system prompt parts and merge into ONE message to satisfy providers
|
||||
// like Qwen 3.5 that reject multiple consecutive system messages.
|
||||
let mut system_parts: Vec<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 {
|
||||
messages.push(Message {
|
||||
role: "system".into(),
|
||||
content: "CRITICAL: You have tools available. When calling tools, you MUST use the native JSON function calling format in your API response. DO NOT output XML tags like <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 {
|
||||
role: "system".into(),
|
||||
content: format!(
|
||||
"TOOL EXECUTION BUDGET: You have a maximum of {MAX_TOOL_ITERATIONS} rounds (each AI response counts as one round). \
|
||||
You can call multiple tools in a single round. \
|
||||
Plan your investigation efficiently:\n\
|
||||
- Call multiple related tools in the same round when possible\n\
|
||||
- Prioritize high-value diagnostic commands first\n\
|
||||
- Use comprehensive output formats (e.g., kubectl --output=yaml) to gather more data per call\n\
|
||||
- Reserve 1 round for your final summary/answer\n\
|
||||
- If you exceed the budget, you'll be cut off mid-investigation\n\
|
||||
Current round count is not visible to you, so plan conservatively."
|
||||
),
|
||||
tool_call_id: None,
|
||||
tool_calls: None,
|
||||
});
|
||||
system_parts.push(
|
||||
"CRITICAL: You have tools available. When calling tools, you MUST use the native \
|
||||
JSON function calling format in your API response. DO NOT output XML tags like \
|
||||
<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\
|
||||
- Call multiple related tools in the same round when possible\n\
|
||||
- Prioritize high-value diagnostic commands first\n\
|
||||
- Use comprehensive output formats (e.g., kubectl --output=yaml) to gather more data per call\n\
|
||||
- Reserve 1 round for your final summary/answer\n\
|
||||
- If you exceed the budget, you'll be cut off mid-investigation\n\
|
||||
Current round count is not visible to you, so plan conservatively."
|
||||
));
|
||||
}
|
||||
|
||||
// 4. Integration context as system message — still before history
|
||||
if !integration_context.is_empty() {
|
||||
system_parts.push(format!(
|
||||
"INTERNAL DOCUMENTATION SOURCES:\n\n{integration_context}\n\n\
|
||||
Instructions: The above content is from internal company documentation systems \
|
||||
(Confluence, ServiceNow, Azure DevOps). \
|
||||
\n\n**IMPORTANT**: First determine if this documentation is RELEVANT to the user's question:\
|
||||
\n- If the documentation directly addresses the question → Use it and cite sources with URLs\
|
||||
\n- If the documentation is tangentially related but doesn't answer the question → Briefly mention what internal docs exist, then provide a complete answer using general knowledge\
|
||||
\n- If the documentation is completely unrelated → Ignore it and answer using general knowledge\
|
||||
\n\nDo NOT force irrelevant internal documentation into your answer. The user needs accurate information, not forced citations."
|
||||
));
|
||||
}
|
||||
|
||||
let mut messages = Vec::new();
|
||||
|
||||
if !system_parts.is_empty() {
|
||||
messages.push(Message {
|
||||
role: "system".into(),
|
||||
content: format!(
|
||||
"INTERNAL DOCUMENTATION SOURCES:\n\n{integration_context}\n\n\
|
||||
Instructions: The above content is from internal company documentation systems \
|
||||
(Confluence, ServiceNow, Azure DevOps). \
|
||||
\n\n**IMPORTANT**: First determine if this documentation is RELEVANT to the user's question:\
|
||||
\n- If the documentation directly addresses the question → Use it and cite sources with URLs\
|
||||
\n- If the documentation is tangentially related but doesn't answer the question → Briefly mention what internal docs exist, then provide a complete answer using general knowledge\
|
||||
\n- If the documentation is completely unrelated → Ignore it and answer using general knowledge\
|
||||
\n\nDo NOT force irrelevant internal documentation into your answer. The user needs accurate information, not forced citations."
|
||||
),
|
||||
content: system_parts.join("\n\n---\n\n"),
|
||||
tool_call_id: None,
|
||||
tool_calls: None,
|
||||
});
|
||||
}
|
||||
|
||||
// 5. Filter out any system messages from history to avoid duplicates and maintain order
|
||||
// Filter system messages from history (already merged above) and append
|
||||
let filtered_history: Vec<Message> = history
|
||||
.into_iter()
|
||||
.filter(|msg| msg.role != "system")
|
||||
.collect();
|
||||
|
||||
// 6. Add filtered history (user/assistant messages only) — all system messages are already above
|
||||
messages.extend(filtered_history);
|
||||
|
||||
messages.push(Message {
|
||||
@ -541,19 +523,25 @@ pub async fn chat_message(
|
||||
if let Some(tool_calls) = &response.tool_calls {
|
||||
tracing::info!("AI requested {} tool call(s)", tool_calls.len());
|
||||
|
||||
// Execute each tool call
|
||||
// OpenAI API contract: push the assistant message WITH tool_calls BEFORE any tool results
|
||||
messages.push(Message {
|
||||
role: "assistant".into(),
|
||||
content: response.content.clone(),
|
||||
tool_call_id: None,
|
||||
tool_calls: Some(tool_calls.clone()),
|
||||
});
|
||||
|
||||
// Execute each tool call and append result messages
|
||||
for tool_call in tool_calls {
|
||||
tracing::info!("Executing tool: {}", tool_call.name);
|
||||
|
||||
let tool_result = execute_tool_call(tool_call, &app_handle, &state).await;
|
||||
|
||||
// Format result
|
||||
let result_content = match tool_result {
|
||||
Ok(result) => result,
|
||||
Err(e) => format!("Error executing tool: {e}"),
|
||||
};
|
||||
|
||||
// Add tool result as a message
|
||||
messages.push(Message {
|
||||
role: "tool".into(),
|
||||
content: result_content,
|
||||
|
||||
@ -432,7 +432,7 @@ pub async fn list_proxmox_vms(
|
||||
#[tauri::command]
|
||||
pub async fn get_proxmox_vm(
|
||||
cluster_id: String,
|
||||
node: String,
|
||||
node_id: String,
|
||||
vm_id: u32,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
@ -441,7 +441,7 @@ pub async fn get_proxmox_vm(
|
||||
|
||||
let vm = crate::proxmox::vm::get_vm(
|
||||
&client_guard,
|
||||
&node,
|
||||
&node_id,
|
||||
vm_id,
|
||||
client_guard.ticket.as_deref().unwrap_or(""),
|
||||
)
|
||||
@ -455,7 +455,7 @@ pub async fn get_proxmox_vm(
|
||||
#[tauri::command]
|
||||
pub async fn start_proxmox_vm(
|
||||
cluster_id: String,
|
||||
node: String,
|
||||
node_id: String,
|
||||
vm_id: u32,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
@ -464,7 +464,7 @@ pub async fn start_proxmox_vm(
|
||||
|
||||
crate::proxmox::vm::start_vm(
|
||||
&client_guard,
|
||||
&node,
|
||||
&node_id,
|
||||
vm_id,
|
||||
client_guard.ticket.as_deref().unwrap_or(""),
|
||||
)
|
||||
@ -476,7 +476,7 @@ pub async fn start_proxmox_vm(
|
||||
#[tauri::command]
|
||||
pub async fn stop_proxmox_vm(
|
||||
cluster_id: String,
|
||||
node: String,
|
||||
node_id: String,
|
||||
vm_id: u32,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
@ -485,7 +485,7 @@ pub async fn stop_proxmox_vm(
|
||||
|
||||
crate::proxmox::vm::stop_vm(
|
||||
&client_guard,
|
||||
&node,
|
||||
&node_id,
|
||||
vm_id,
|
||||
client_guard.ticket.as_deref().unwrap_or(""),
|
||||
)
|
||||
@ -497,7 +497,7 @@ pub async fn stop_proxmox_vm(
|
||||
#[tauri::command]
|
||||
pub async fn reboot_proxmox_vm(
|
||||
cluster_id: String,
|
||||
node: String,
|
||||
node_id: String,
|
||||
vm_id: u32,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
@ -506,7 +506,7 @@ pub async fn reboot_proxmox_vm(
|
||||
|
||||
crate::proxmox::vm::reboot_vm(
|
||||
&client_guard,
|
||||
&node,
|
||||
&node_id,
|
||||
vm_id,
|
||||
client_guard.ticket.as_deref().unwrap_or(""),
|
||||
)
|
||||
@ -518,7 +518,7 @@ pub async fn reboot_proxmox_vm(
|
||||
#[tauri::command]
|
||||
pub async fn shutdown_proxmox_vm(
|
||||
cluster_id: String,
|
||||
node: String,
|
||||
node_id: String,
|
||||
vm_id: u32,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
@ -527,7 +527,7 @@ pub async fn shutdown_proxmox_vm(
|
||||
|
||||
crate::proxmox::vm::shutdown_vm(
|
||||
&client_guard,
|
||||
&node,
|
||||
&node_id,
|
||||
vm_id,
|
||||
client_guard.ticket.as_deref().unwrap_or(""),
|
||||
)
|
||||
@ -539,7 +539,7 @@ pub async fn shutdown_proxmox_vm(
|
||||
#[tauri::command]
|
||||
pub async fn resume_proxmox_vm(
|
||||
cluster_id: String,
|
||||
node: String,
|
||||
node_id: String,
|
||||
vm_id: u32,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
@ -548,7 +548,7 @@ pub async fn resume_proxmox_vm(
|
||||
|
||||
crate::proxmox::vm::resume_vm(
|
||||
&client_guard,
|
||||
&node,
|
||||
&node_id,
|
||||
vm_id,
|
||||
client_guard.ticket.as_deref().unwrap_or(""),
|
||||
)
|
||||
@ -560,7 +560,7 @@ pub async fn resume_proxmox_vm(
|
||||
#[tauri::command]
|
||||
pub async fn suspend_proxmox_vm(
|
||||
cluster_id: String,
|
||||
node: String,
|
||||
node_id: String,
|
||||
vm_id: u32,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
@ -569,7 +569,7 @@ pub async fn suspend_proxmox_vm(
|
||||
|
||||
crate::proxmox::vm::suspend_vm(
|
||||
&client_guard,
|
||||
&node,
|
||||
&node_id,
|
||||
vm_id,
|
||||
client_guard.ticket.as_deref().unwrap_or(""),
|
||||
)
|
||||
@ -581,7 +581,7 @@ pub async fn suspend_proxmox_vm(
|
||||
#[tauri::command]
|
||||
pub async fn clone_vm(
|
||||
cluster_id: String,
|
||||
node: String,
|
||||
node_id: String,
|
||||
vm_id: u32,
|
||||
new_vmid: u32,
|
||||
name: String,
|
||||
@ -592,7 +592,7 @@ pub async fn clone_vm(
|
||||
|
||||
crate::proxmox::vm::clone_vm(
|
||||
&client_guard,
|
||||
&node,
|
||||
&node_id,
|
||||
vm_id,
|
||||
new_vmid,
|
||||
&name,
|
||||
@ -606,7 +606,7 @@ pub async fn clone_vm(
|
||||
#[tauri::command]
|
||||
pub async fn delete_vm(
|
||||
cluster_id: String,
|
||||
node: String,
|
||||
node_id: String,
|
||||
vm_id: u32,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
@ -615,7 +615,7 @@ pub async fn delete_vm(
|
||||
|
||||
crate::proxmox::vm::delete_vm(
|
||||
&client_guard,
|
||||
&node,
|
||||
&node_id,
|
||||
vm_id,
|
||||
client_guard.ticket.as_deref().unwrap_or(""),
|
||||
)
|
||||
@ -681,74 +681,99 @@ pub async fn list_proxmox_backup_jobs(
|
||||
Ok(jobs)
|
||||
}
|
||||
|
||||
/// List Proxmox Datastores (Storage per node)
|
||||
/// List Proxmox Datastores (cluster-wide via cluster/resources)
|
||||
#[tauri::command]
|
||||
pub async fn list_proxmox_datastores(
|
||||
cluster_id: String,
|
||||
_state: State<'_, AppState>,
|
||||
) -> Result<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_guard = client.lock().await;
|
||||
|
||||
// First, get all nodes
|
||||
let nodes_path = "cluster/resources?type=node";
|
||||
let nodes_response: serde_json::Value = client_guard
|
||||
let response: serde_json::Value = client_guard
|
||||
.get(
|
||||
nodes_path,
|
||||
"cluster/resources?type=storage",
|
||||
Some(client_guard.ticket.as_deref().unwrap_or("")),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to list nodes: {}", e))?;
|
||||
.map_err(|e| format!("Failed to list cluster storage: {}", e))?;
|
||||
|
||||
let nodes: Vec<String> = nodes_response
|
||||
.as_array()
|
||||
.unwrap_or(&vec![])
|
||||
let entries = response.as_array().ok_or("Invalid response format")?;
|
||||
|
||||
let all_storage: Vec<serde_json::Value> = entries
|
||||
.iter()
|
||||
.filter_map(|n| {
|
||||
n.get("node")
|
||||
.and_then(|node| node.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.filter_map(|entry| {
|
||||
let obj = entry.as_object()?;
|
||||
let mut normalized = serde_json::Map::new();
|
||||
|
||||
let storage_name = obj.get("storage").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let node_name = obj.get("node").and_then(|v| v.as_str()).unwrap_or("");
|
||||
|
||||
normalized.insert(
|
||||
"id".to_string(),
|
||||
serde_json::Value::String(format!(
|
||||
"storage/{}/{}",
|
||||
node_name, storage_name
|
||||
)),
|
||||
);
|
||||
normalized.insert(
|
||||
"storage".to_string(),
|
||||
serde_json::Value::String(storage_name.to_string()),
|
||||
);
|
||||
normalized.insert(
|
||||
"name".to_string(),
|
||||
serde_json::Value::String(storage_name.to_string()),
|
||||
);
|
||||
|
||||
let plugin_type = obj
|
||||
.get("plugintype")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown");
|
||||
normalized.insert(
|
||||
"type".to_string(),
|
||||
serde_json::Value::String(plugin_type.to_string()),
|
||||
);
|
||||
|
||||
let content = obj
|
||||
.get("content")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
normalized.insert(
|
||||
"content".to_string(),
|
||||
serde_json::Value::String(content),
|
||||
);
|
||||
|
||||
normalized.insert(
|
||||
"node".to_string(),
|
||||
serde_json::Value::String(node_name.to_string()),
|
||||
);
|
||||
|
||||
// cluster/resources uses disk/maxdisk; normalize to used/available/size
|
||||
let disk_used = obj.get("disk").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let disk_total = obj.get("maxdisk").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let disk_avail = disk_total.saturating_sub(disk_used);
|
||||
|
||||
normalized.insert("used".to_string(), serde_json::Value::Number(disk_used.into()));
|
||||
normalized.insert("size".to_string(), serde_json::Value::Number(disk_total.into()));
|
||||
normalized.insert("available".to_string(), serde_json::Value::Number(disk_avail.into()));
|
||||
|
||||
let status = obj
|
||||
.get("status")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("available")
|
||||
.to_string();
|
||||
normalized.insert("status".to_string(), serde_json::Value::String(status));
|
||||
|
||||
// Preserve shared flag if present
|
||||
if let Some(shared) = obj.get("shared") {
|
||||
normalized.insert("shared".to_string(), shared.clone());
|
||||
}
|
||||
|
||||
Some(serde_json::Value::Object(normalized))
|
||||
})
|
||||
.collect();
|
||||
|
||||
if nodes.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
// Fetch storage for each node
|
||||
let mut all_storage: Vec<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)
|
||||
}
|
||||
|
||||
@ -756,7 +781,7 @@ pub async fn list_proxmox_datastores(
|
||||
#[tauri::command]
|
||||
pub async fn trigger_proxmox_backup_job(
|
||||
cluster_id: String,
|
||||
node: String,
|
||||
node_id: String,
|
||||
job_id: u32,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
@ -765,7 +790,7 @@ pub async fn trigger_proxmox_backup_job(
|
||||
|
||||
crate::proxmox::backup::trigger_backup_job(
|
||||
&client_guard,
|
||||
&node,
|
||||
&node_id,
|
||||
job_id,
|
||||
client_guard.ticket.as_deref().unwrap_or(""),
|
||||
)
|
||||
@ -1384,7 +1409,7 @@ pub async fn get_certificate(
|
||||
#[tauri::command]
|
||||
pub async fn list_firewall_rules(
|
||||
cluster_id: String,
|
||||
node: String,
|
||||
node_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Vec<serde_json::Value>, String> {
|
||||
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
|
||||
@ -1392,7 +1417,7 @@ pub async fn list_firewall_rules(
|
||||
|
||||
let rules = crate::proxmox::firewall::list_firewall_rules(
|
||||
&client_guard,
|
||||
&node,
|
||||
&node_id,
|
||||
client_guard.ticket.as_deref().unwrap_or(""),
|
||||
)
|
||||
.await
|
||||
@ -1408,35 +1433,56 @@ pub async fn list_firewall_rules(
|
||||
|
||||
/// Add firewall rule
|
||||
#[tauri::command]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn add_firewall_rule(
|
||||
cluster_id: String,
|
||||
node: String,
|
||||
action: String,
|
||||
protocol: String,
|
||||
source: String,
|
||||
destination: String,
|
||||
port: Option<String>,
|
||||
enabled: bool,
|
||||
node_id: String,
|
||||
rule: serde_json::Value,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
|
||||
let client_guard = client.lock().await;
|
||||
|
||||
let rule = crate::proxmox::firewall::FirewallRule {
|
||||
let firewall_rule = crate::proxmox::firewall::FirewallRule {
|
||||
rule_num: 0,
|
||||
action,
|
||||
protocol,
|
||||
source,
|
||||
destination,
|
||||
port,
|
||||
enabled,
|
||||
action: rule
|
||||
.get("action")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("ACCEPT")
|
||||
.to_string(),
|
||||
protocol: rule
|
||||
.get("proto")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("tcp")
|
||||
.to_string(),
|
||||
source: rule
|
||||
.get("source")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
destination: rule
|
||||
.get("dest")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
port: rule
|
||||
.get("dport")
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string()),
|
||||
enabled: rule
|
||||
.get("enable")
|
||||
.and_then(|v| {
|
||||
v.as_bool()
|
||||
.or_else(|| v.as_i64().map(|n| n != 0))
|
||||
.or_else(|| v.as_str().map(|s| s == "1" || s == "true"))
|
||||
})
|
||||
.unwrap_or(true),
|
||||
};
|
||||
|
||||
crate::proxmox::firewall::add_rule(
|
||||
&client_guard,
|
||||
&node,
|
||||
&rule,
|
||||
&node_id,
|
||||
&firewall_rule,
|
||||
client_guard.ticket.as_deref().unwrap_or(""),
|
||||
)
|
||||
.await
|
||||
@ -1447,7 +1493,7 @@ pub async fn add_firewall_rule(
|
||||
#[tauri::command]
|
||||
pub async fn delete_firewall_rule(
|
||||
cluster_id: String,
|
||||
node: String,
|
||||
node_id: String,
|
||||
rule_num: u32,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
@ -1456,7 +1502,7 @@ pub async fn delete_firewall_rule(
|
||||
|
||||
crate::proxmox::firewall::delete_rule(
|
||||
&client_guard,
|
||||
&node,
|
||||
&node_id,
|
||||
rule_num,
|
||||
client_guard.ticket.as_deref().unwrap_or(""),
|
||||
)
|
||||
@ -1594,7 +1640,7 @@ pub async fn get_ceph_cluster_status(
|
||||
#[tauri::command]
|
||||
pub async fn migrate_vm(
|
||||
cluster_id: String,
|
||||
node: String,
|
||||
node_id: String,
|
||||
vm_id: u32,
|
||||
target_node: String,
|
||||
target_cluster: String,
|
||||
@ -1605,7 +1651,7 @@ pub async fn migrate_vm(
|
||||
|
||||
let task = crate::proxmox::migration::migrate_vm(
|
||||
&client_guard,
|
||||
&node,
|
||||
&node_id,
|
||||
vm_id,
|
||||
&target_node,
|
||||
&target_cluster,
|
||||
@ -1621,7 +1667,7 @@ pub async fn migrate_vm(
|
||||
#[tauri::command]
|
||||
pub async fn list_migration_status(
|
||||
cluster_id: String,
|
||||
node: String,
|
||||
node_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Vec<serde_json::Value>, String> {
|
||||
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
|
||||
@ -1629,7 +1675,7 @@ pub async fn list_migration_status(
|
||||
|
||||
let tasks = crate::proxmox::migration::list_migration_status(
|
||||
&client_guard,
|
||||
&node,
|
||||
&node_id,
|
||||
client_guard.ticket.as_deref().unwrap_or(""),
|
||||
)
|
||||
.await
|
||||
|
||||
@ -15,6 +15,10 @@ interface BackupJobInfo {
|
||||
size?: number;
|
||||
count?: number;
|
||||
enabled: boolean;
|
||||
storage?: string;
|
||||
vmid?: string | number;
|
||||
mode?: string;
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
interface BackupJobListProps {
|
||||
@ -57,37 +61,34 @@ export function BackupJobList({
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>Storage</TableHead>
|
||||
<TableHead>VMs</TableHead>
|
||||
<TableHead>Node</TableHead>
|
||||
<TableHead>Schedule</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Last Run</TableHead>
|
||||
<TableHead>Enabled</TableHead>
|
||||
<TableHead>Next Run</TableHead>
|
||||
<TableHead>Size</TableHead>
|
||||
<TableHead>Count</TableHead>
|
||||
<TableHead>Mode</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{jobs.map((job) => (
|
||||
<TableRow key={job.id}>
|
||||
<TableCell className="font-medium">{job.name}</TableCell>
|
||||
<TableCell>{job.node}</TableCell>
|
||||
<TableCell>{job.schedule}</TableCell>
|
||||
<TableCell className="font-medium font-mono text-xs">{job.name}</TableCell>
|
||||
<TableCell>{job.storage || '-'}</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>
|
||||
<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.status === 'success' ? 'bg-green-100 text-green-800' :
|
||||
job.status === 'failed' ? 'bg-red-100 text-red-800' :
|
||||
'bg-gray-100 text-gray-800'
|
||||
job.enabled ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{job.status}
|
||||
{job.enabled ? 'enabled' : 'disabled'}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>{job.lastRun || '-'}</TableCell>
|
||||
<TableCell>{job.nextRun || '-'}</TableCell>
|
||||
<TableCell>{job.size ? `${(job.size / (1024 * 1024 * 1024)).toFixed(2)} GB` : '-'}</TableCell>
|
||||
<TableCell>{job.count || '-'}</TableCell>
|
||||
<TableCell className="text-xs">{job.nextRun || '-'}</TableCell>
|
||||
<TableCell className="text-xs">{job.mode || '-'}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
<button
|
||||
|
||||
@ -19,6 +19,7 @@ interface FirewallRuleListProps {
|
||||
rules: FirewallRuleInfo[];
|
||||
onRefresh?: () => void;
|
||||
isLoading?: boolean;
|
||||
onNewRule?: () => void;
|
||||
onEnable?: (rule: FirewallRuleInfo) => void;
|
||||
onDisable?: (rule: FirewallRuleInfo) => void;
|
||||
onEdit?: (rule: FirewallRuleInfo) => void;
|
||||
@ -30,6 +31,7 @@ export function FirewallRuleList({
|
||||
rules,
|
||||
onRefresh,
|
||||
isLoading,
|
||||
onNewRule,
|
||||
onEnable,
|
||||
onDisable,
|
||||
onEdit,
|
||||
@ -44,7 +46,7 @@ export function FirewallRuleList({
|
||||
<Button variant="outline" size="sm" onClick={onRefresh} disabled={isLoading}>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button size="sm">
|
||||
<Button size="sm" onClick={onNewRule}>
|
||||
<span className="mr-2 h-4 w-4">+</span>
|
||||
New Rule
|
||||
</Button>
|
||||
|
||||
@ -14,6 +14,7 @@ import { Checkbox as UICheckbox } from '@/components/ui/index';
|
||||
import { Input } from '@/components/ui/index';
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
import { Alert, AlertDescription } from '@/components/ui/index';
|
||||
import type { ClusterInfo } from '@/lib/domain';
|
||||
|
||||
interface VMInfo {
|
||||
id: string;
|
||||
@ -50,6 +51,8 @@ interface RawVMInfo {
|
||||
|
||||
interface VMListProps {
|
||||
vms: RawVMInfo[];
|
||||
clusterId: string;
|
||||
clusters?: ClusterInfo[];
|
||||
onRefresh?: () => void;
|
||||
isLoading?: boolean;
|
||||
onSnapshotAction?: (vm: VMInfo, action: 'create' | 'list' | 'rollback' | 'delete') => void;
|
||||
@ -62,16 +65,16 @@ interface VMListProps {
|
||||
|
||||
function formatUptime(seconds: number): string {
|
||||
if (seconds <= 0) return '-';
|
||||
|
||||
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
|
||||
|
||||
const parts: string[] = [];
|
||||
if (days > 0) parts.push(`${days}d`);
|
||||
if (hours > 0) parts.push(`${hours}h`);
|
||||
if (minutes > 0 || parts.length === 0) parts.push(`${minutes}m`);
|
||||
|
||||
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
@ -85,22 +88,23 @@ function formatBytes(bytes: number): string {
|
||||
|
||||
export function VMList({
|
||||
vms: rawVms,
|
||||
clusterId,
|
||||
clusters = [],
|
||||
onRefresh,
|
||||
isLoading,
|
||||
onSnapshotAction,
|
||||
onMigrate,
|
||||
onClone,
|
||||
onDelete,
|
||||
onSnapshotAction: _onSnapshotAction,
|
||||
onMigrate: _onMigrate,
|
||||
onClone: _onClone,
|
||||
onDelete: _onDelete,
|
||||
selectedVMs = new Set<string>(),
|
||||
onToggleSelect,
|
||||
}: VMListProps) {
|
||||
const [clusterId, setClusterId] = useState<string>('');
|
||||
const [migrationVM, setMigrationVM] = useState<VMInfo | null>(null);
|
||||
const [targetNode, setTargetNode] = useState<string>('');
|
||||
const [targetCluster, setTargetCluster] = useState<string>('');
|
||||
const [onlineMigration, setOnlineMigration] = useState(true);
|
||||
const [maxDowntime, setMaxDowntime] = useState(30);
|
||||
|
||||
// Transform raw VM data to VMInfo format
|
||||
const vms: VMInfo[] = React.useMemo(() => {
|
||||
return rawVms.map((vm) => ({
|
||||
id: String(vm.id || vm.vmid),
|
||||
@ -118,17 +122,11 @@ export function VMList({
|
||||
}));
|
||||
}, [rawVms]);
|
||||
|
||||
useEffect(() => {
|
||||
invoke<string[]>('list_proxmox_clusters')
|
||||
.then((clusters: any[]) => {
|
||||
if (clusters.length > 0) {
|
||||
setClusterId(clusters[0].id);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleVMAction = useCallback(async (vm: VMInfo, action: string) => {
|
||||
if (!clusterId) {
|
||||
toast.error('No cluster selected');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
switch (action) {
|
||||
case 'start':
|
||||
@ -204,7 +202,8 @@ export function VMList({
|
||||
.map((v) => v.node)
|
||||
.filter((node, index, self) => self.indexOf(node) === index && node !== vm.node);
|
||||
setTargetNode(availableNodes[0] || '');
|
||||
}, [vms]);
|
||||
setTargetCluster(clusterId);
|
||||
}, [vms, clusterId]);
|
||||
|
||||
const submitMigration = useCallback(async () => {
|
||||
if (!migrationVM || !targetNode) {
|
||||
@ -212,27 +211,34 @@ export function VMList({
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceCluster = clusterId;
|
||||
const destCluster = targetCluster || clusterId;
|
||||
|
||||
try {
|
||||
await invoke('migrate_vm', {
|
||||
clusterId,
|
||||
clusterId: sourceCluster,
|
||||
nodeId: migrationVM.node,
|
||||
vmId: migrationVM.vmid,
|
||||
targetNode,
|
||||
online: onlineMigration,
|
||||
max_downtime: maxDowntime,
|
||||
targetCluster: destCluster,
|
||||
});
|
||||
|
||||
toast.success(`VM ${migrationVM.name} migration started to ${targetNode}`);
|
||||
|
||||
toast.success(`VM ${migrationVM.name} migration started to ${targetNode}${destCluster !== sourceCluster ? ` (cluster: ${destCluster})` : ''}`);
|
||||
setMigrationVM(null);
|
||||
setTargetNode('');
|
||||
setTargetCluster('');
|
||||
onRefresh?.();
|
||||
} catch (error) {
|
||||
console.error('Failed to migrate VM:', error);
|
||||
toast.error(`Failed to migrate VM ${migrationVM.name}: ${error}`);
|
||||
}
|
||||
}, [migrationVM, targetNode, onlineMigration, maxDowntime, clusterId, onRefresh]);
|
||||
}, [migrationVM, targetNode, targetCluster, clusterId, onRefresh]);
|
||||
|
||||
const handleClone = useCallback(async (vm: VMInfo) => {
|
||||
if (!clusterId) {
|
||||
toast.error('No cluster selected');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const nextVmid = Math.max(...vms.map((v) => v.vmid), 100) + 1;
|
||||
const newVmidStr = window.prompt(`Enter new VM ID for ${vm.name}:`, `${nextVmid}`);
|
||||
@ -250,7 +256,7 @@ export function VMList({
|
||||
toast.info('Clone cancelled');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
await invoke('clone_vm', {
|
||||
clusterId,
|
||||
nodeId: vm.node,
|
||||
@ -258,7 +264,7 @@ export function VMList({
|
||||
newVmid,
|
||||
name: newName,
|
||||
});
|
||||
|
||||
|
||||
toast.success(`VM ${vm.name} cloned successfully to VM ${newVmid}`);
|
||||
onRefresh?.();
|
||||
} catch (error) {
|
||||
@ -268,11 +274,15 @@ export function VMList({
|
||||
}, [clusterId, vms, onRefresh]);
|
||||
|
||||
const handleDelete = useCallback(async (vm: VMInfo) => {
|
||||
if (!clusterId) {
|
||||
toast.error('No cluster selected');
|
||||
return;
|
||||
}
|
||||
const confirmed = await confirm(`Are you sure you want to delete VM ${vm.name} (VMID: ${vm.vmid})? This action cannot be undone!`, {
|
||||
title: 'Delete VM',
|
||||
kind: 'warning',
|
||||
});
|
||||
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
@ -283,7 +293,7 @@ export function VMList({
|
||||
nodeId: vm.node,
|
||||
vmId: vm.vmid,
|
||||
});
|
||||
|
||||
|
||||
toast.success(`VM ${vm.name} deleted successfully`);
|
||||
onRefresh?.();
|
||||
} catch (error) {
|
||||
@ -309,7 +319,7 @@ export function VMList({
|
||||
<TableRow>
|
||||
<TableHead className="w-[40px]">
|
||||
<Checkbox
|
||||
checked={vms.every((vm) => selectedVMs.has(vm.id))}
|
||||
checked={vms.length > 0 && vms.every((vm) => selectedVMs.has(vm.id))}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
vms.forEach((vm) => selectedVMs.add(vm.id));
|
||||
@ -335,7 +345,7 @@ export function VMList({
|
||||
const cpuPercent = vm.cpu > 0 ? Math.min(vm.cpu * 100, 100) : 0;
|
||||
const memoryPercent = vm.memoryTotal > 0 ? (vm.memory / vm.memoryTotal) * 100 : 0;
|
||||
const diskPercent = vm.diskTotal > 0 ? (vm.disk / vm.diskTotal) * 100 : 0;
|
||||
|
||||
|
||||
return (
|
||||
<TableRow key={vm.id}>
|
||||
<TableCell>
|
||||
@ -413,11 +423,15 @@ export function VMList({
|
||||
<MigrationDialog
|
||||
vm={migrationVM}
|
||||
isOpen={!!migrationVM}
|
||||
onClose={() => setMigrationVM(null)}
|
||||
onClose={() => { setMigrationVM(null); setTargetNode(''); setTargetCluster(''); }}
|
||||
onSubmit={submitMigration}
|
||||
availableNodes={vms}
|
||||
clusters={clusters}
|
||||
currentClusterId={clusterId}
|
||||
targetNode={targetNode}
|
||||
onTargetNodeChange={setTargetNode}
|
||||
targetCluster={targetCluster}
|
||||
onTargetClusterChange={setTargetCluster}
|
||||
online={onlineMigration}
|
||||
onOnlineChange={setOnlineMigration}
|
||||
maxDowntime={maxDowntime}
|
||||
@ -447,7 +461,6 @@ function VMActionMenu({
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close menu when clicking outside
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
@ -471,43 +484,10 @@ function VMActionMenu({
|
||||
|
||||
const handleAction = (action: () => void) => (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
action();
|
||||
setIsOpen(false);
|
||||
action();
|
||||
};
|
||||
|
||||
// Calculate menu position to avoid overflow
|
||||
const getMenuPosition = () => {
|
||||
const viewportHeight = window.innerHeight;
|
||||
const viewportWidth = window.innerWidth;
|
||||
const buttonRect = menuRef.current?.querySelector('button')?.getBoundingClientRect();
|
||||
|
||||
if (!buttonRect) return { top: '100%', left: 0 };
|
||||
|
||||
const menuHeight = 400; // approximate menu height
|
||||
const menuWidth = 192; // approximate menu width (w-48 = 12rem = 192px)
|
||||
const spaceBelow = viewportHeight - buttonRect.bottom;
|
||||
const spaceAbove = buttonRect.top;
|
||||
const spaceRight = viewportWidth - buttonRect.right;
|
||||
|
||||
// Vertical positioning
|
||||
let verticalPos: { top?: string; bottom?: string } = { top: '100%' };
|
||||
if (spaceBelow >= menuHeight) {
|
||||
verticalPos = { top: '100%' };
|
||||
} else if (spaceAbove >= menuHeight) {
|
||||
verticalPos = { bottom: '100%' };
|
||||
}
|
||||
|
||||
// Horizontal positioning - account for overflow on the right
|
||||
let horizontalPos: { left?: number; right?: number } = { left: 0 };
|
||||
if (spaceRight < menuWidth) {
|
||||
horizontalPos = { right: 0 };
|
||||
}
|
||||
|
||||
return { ...verticalPos, ...horizontalPos };
|
||||
};
|
||||
|
||||
const position = getMenuPosition();
|
||||
|
||||
return (
|
||||
<div className="relative" ref={menuRef}>
|
||||
<Button
|
||||
@ -519,17 +499,12 @@ function VMActionMenu({
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
{isOpen && (
|
||||
<div
|
||||
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="absolute right-0 top-full z-50 mt-2 w-48 rounded-md border bg-background shadow-md">
|
||||
<div className="space-y-1 p-1">
|
||||
{vm.status === 'stopped' && (
|
||||
<button
|
||||
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" />
|
||||
Start
|
||||
@ -539,28 +514,28 @@ function VMActionMenu({
|
||||
<>
|
||||
<button
|
||||
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" />
|
||||
Stop
|
||||
</button>
|
||||
<button
|
||||
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" />
|
||||
Reboot
|
||||
</button>
|
||||
<button
|
||||
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" />
|
||||
Shutdown
|
||||
</button>
|
||||
<button
|
||||
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" />
|
||||
Suspend
|
||||
@ -570,7 +545,7 @@ function VMActionMenu({
|
||||
{vm.status === 'paused' && (
|
||||
<button
|
||||
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" />
|
||||
Resume
|
||||
@ -579,27 +554,27 @@ function VMActionMenu({
|
||||
<div className="h-px bg-border my-1" />
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
<div className="h-px bg-border my-1" />
|
||||
<button
|
||||
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" />
|
||||
Migrate
|
||||
</button>
|
||||
<button
|
||||
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" />
|
||||
Clone
|
||||
@ -607,7 +582,7 @@ function VMActionMenu({
|
||||
<div className="h-px bg-border my-1" />
|
||||
<button
|
||||
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" />
|
||||
Delete
|
||||
@ -625,8 +600,12 @@ interface MigrationDialogProps {
|
||||
onClose: () => void;
|
||||
onSubmit: () => void;
|
||||
availableNodes: VMInfo[];
|
||||
clusters: ClusterInfo[];
|
||||
currentClusterId: string;
|
||||
targetNode: string;
|
||||
onTargetNodeChange: (node: string) => void;
|
||||
targetCluster: string;
|
||||
onTargetClusterChange: (clusterId: string) => void;
|
||||
online: boolean;
|
||||
onOnlineChange: (online: boolean) => void;
|
||||
maxDowntime: number;
|
||||
@ -639,8 +618,12 @@ function MigrationDialog({
|
||||
onClose,
|
||||
onSubmit,
|
||||
availableNodes,
|
||||
clusters,
|
||||
currentClusterId,
|
||||
targetNode,
|
||||
onTargetNodeChange,
|
||||
targetCluster,
|
||||
onTargetClusterChange,
|
||||
online,
|
||||
onOnlineChange,
|
||||
maxDowntime,
|
||||
@ -648,6 +631,8 @@ function MigrationDialog({
|
||||
}: MigrationDialogProps) {
|
||||
if (!vm) return null;
|
||||
|
||||
const isCrossCluster = targetCluster && targetCluster !== currentClusterId;
|
||||
|
||||
const availableTargets = availableNodes
|
||||
.map((v) => v.node)
|
||||
.filter((node, index, self) => self.indexOf(node) === index && node !== vm.node);
|
||||
@ -665,25 +650,64 @@ function MigrationDialog({
|
||||
Live migration requires the same hardware configuration on both nodes. Ensure storage is accessible from both nodes.
|
||||
</AlertDescription>
|
||||
</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">
|
||||
<Label htmlFor="targetNode">Target Node</Label>
|
||||
<Select value={targetNode} onValueChange={onTargetNodeChange}>
|
||||
<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 for migration
|
||||
</p>
|
||||
{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}>
|
||||
<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>
|
||||
|
||||
@ -722,7 +746,10 @@ function MigrationDialog({
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={onSubmit} disabled={!targetNode || availableTargets.length === 0}>
|
||||
<Button
|
||||
onClick={onSubmit}
|
||||
disabled={!targetNode || (!isCrossCluster && availableTargets.length === 0)}
|
||||
>
|
||||
Start Migration
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@ -8,7 +8,6 @@ import { toast } from 'sonner';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/index';
|
||||
import { Input } from '@/components/ui/index';
|
||||
import { Label } from '@/components/ui/index';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/index';
|
||||
|
||||
export function ProxmoxBackupPage() {
|
||||
const [clusters, setClusters] = useState<ClusterInfo[]>([]);
|
||||
@ -40,8 +39,34 @@ export function ProxmoxBackupPage() {
|
||||
if (!clusterId) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await listProxmoxBackupJobs(clusterId, '');
|
||||
setJobs(data);
|
||||
const raw = await listProxmoxBackupJobs(clusterId, '');
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const normalized = (raw as any[]).map((job) => {
|
||||
const enabledRaw = job.enabled ?? job.enable ?? 1;
|
||||
const isEnabled = enabledRaw === 1 || enabledRaw === true || enabledRaw === '1';
|
||||
const nextRunRaw = job['next-run'] ?? job.next_run ?? job.nextRun;
|
||||
const nextRunStr = nextRunRaw
|
||||
? new Date(Number(nextRunRaw) * 1000).toLocaleString()
|
||||
: undefined;
|
||||
return {
|
||||
id: job.id || String(job.jobid || ''),
|
||||
name: job.id || job.comment || `job-${job.jobid || '?'}`,
|
||||
node: job.node || 'all',
|
||||
schedule: job.schedule || '-',
|
||||
status: isEnabled ? ('idle' as const) : ('idle' as const),
|
||||
lastRun: undefined,
|
||||
nextRun: nextRunStr,
|
||||
size: undefined,
|
||||
count: undefined,
|
||||
enabled: isEnabled,
|
||||
vmid: job.vmid,
|
||||
storage: job.storage,
|
||||
mode: job.mode,
|
||||
compress: job.compress,
|
||||
comment: job.comment,
|
||||
};
|
||||
});
|
||||
setJobs(normalized);
|
||||
} catch (err) {
|
||||
console.error('Failed to load backup jobs:', err);
|
||||
toast.error('Failed to load backup jobs');
|
||||
|
||||
@ -1,9 +1,12 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Button } from '@/components/ui/index';
|
||||
import { Input } from '@/components/ui/index';
|
||||
import { Label } from '@/components/ui/index';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/index';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/index';
|
||||
import { FirewallRuleList } from '@/components/Proxmox';
|
||||
import { listProxmoxClusters, listFirewallRules } from '@/lib/proxmoxClient';
|
||||
import { listProxmoxClusters, listFirewallRules, addFirewallRule } from '@/lib/proxmoxClient';
|
||||
import type { ClusterInfo } from '@/lib/domain';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
@ -16,6 +19,15 @@ export function ProxmoxFirewallPage() {
|
||||
const [rules, setRules] = useState<any[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// New rule dialog
|
||||
const [showNewRuleDialog, setShowNewRuleDialog] = useState(false);
|
||||
const [ruleAction, setRuleAction] = useState('ACCEPT');
|
||||
const [ruleProtocol, setRuleProtocol] = useState('tcp');
|
||||
const [ruleSource, setRuleSource] = useState('');
|
||||
const [ruleDest, setRuleDest] = useState('');
|
||||
const [ruleDport, setRuleDport] = useState('');
|
||||
const [ruleComment, setRuleComment] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
listProxmoxClusters()
|
||||
.then((cls) => {
|
||||
@ -50,6 +62,41 @@ export function ProxmoxFirewallPage() {
|
||||
setNodeId(nodeInputValue.trim() || 'localhost');
|
||||
};
|
||||
|
||||
const handleNewRule = () => {
|
||||
setRuleAction('ACCEPT');
|
||||
setRuleProtocol('tcp');
|
||||
setRuleSource('');
|
||||
setRuleDest('');
|
||||
setRuleDport('');
|
||||
setRuleComment('');
|
||||
setShowNewRuleDialog(true);
|
||||
};
|
||||
|
||||
const handleSubmitNewRule = async () => {
|
||||
if (!ruleAction || !ruleProtocol) {
|
||||
toast.error('Action and protocol are required');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await addFirewallRule(selectedClusterId, nodeId, {
|
||||
action: ruleAction,
|
||||
proto: ruleProtocol,
|
||||
source: ruleSource || undefined,
|
||||
dest: ruleDest || undefined,
|
||||
dport: ruleDport || undefined,
|
||||
comment: ruleComment || undefined,
|
||||
enable: 1,
|
||||
});
|
||||
toast.success('Firewall rule created');
|
||||
setShowNewRuleDialog(false);
|
||||
await loadRules(selectedClusterId, nodeId);
|
||||
} catch (error) {
|
||||
console.error('Failed to create firewall rule:', error);
|
||||
toast.error(`Failed to create firewall rule: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
if (clusters.length === 0 && !isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@ -113,7 +160,89 @@ export function ProxmoxFirewallPage() {
|
||||
<FirewallRuleList
|
||||
rules={rules}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@ import { listNetworkInterfaces, listProxmoxClusters, NetworkInterface } from '@/
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/index';
|
||||
import { Input } from '@/components/ui/index';
|
||||
import { Label } from '@/components/ui/index';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/index';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export function ProxmoxNetworkPage() {
|
||||
@ -216,12 +217,21 @@ export function ProxmoxNetworkPage() {
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="type">Interface Type</Label>
|
||||
<Input
|
||||
id="type"
|
||||
value={ifaceType}
|
||||
onChange={(e) => setIfaceType(e.target.value)}
|
||||
placeholder="eth, bond, bridge, vlan"
|
||||
/>
|
||||
<Select value={ifaceType} onValueChange={setIfaceType}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select interface type" />
|
||||
</SelectTrigger>
|
||||
<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 className="space-y-2">
|
||||
<Label htmlFor="address">IP Address</Label>
|
||||
|
||||
@ -87,6 +87,8 @@ export function ProxmoxVMsPage() {
|
||||
|
||||
<VMList
|
||||
vms={vms}
|
||||
clusterId={selectedClusterId}
|
||||
clusters={clusters}
|
||||
onRefresh={() => loadVms(selectedClusterId)}
|
||||
selectedVMs={selectedVMs}
|
||||
onToggleSelect={(vm) => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user