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:
parent
a2875b60a9
commit
577512562b
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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');
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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) => {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user