Merge pull request 'fix(proxmox): resolve 7 dashboard and AI chat issues' (#129) from fix/proxmox-issues-v2 into beta
All checks were successful
Release Beta / autotag (push) Successful in 10s
Release Beta / changelog (push) Successful in 1m28s
Test / frontend-tests (push) Successful in 1m40s
Test / frontend-typecheck (push) Successful in 1m48s
Release Beta / build-macos-arm64 (push) Successful in 7m14s
Release Beta / build-linux-amd64 (push) Successful in 10m27s
Release Beta / build-windows-amd64 (push) Successful in 11m21s
Release Beta / build-linux-arm64 (push) Successful in 12m44s
Test / rust-fmt-check (push) Successful in 17m54s
Test / rust-clippy (push) Successful in 19m20s
Test / rust-tests (push) Successful in 21m22s
All checks were successful
Release Beta / autotag (push) Successful in 10s
Release Beta / changelog (push) Successful in 1m28s
Test / frontend-tests (push) Successful in 1m40s
Test / frontend-typecheck (push) Successful in 1m48s
Release Beta / build-macos-arm64 (push) Successful in 7m14s
Release Beta / build-linux-amd64 (push) Successful in 10m27s
Release Beta / build-windows-amd64 (push) Successful in 11m21s
Release Beta / build-linux-arm64 (push) Successful in 12m44s
Test / rust-fmt-check (push) Successful in 17m54s
Test / rust-clippy (push) Successful in 19m20s
Test / rust-tests (push) Successful in 21m22s
Reviewed-on: #129
This commit is contained in:
commit
5a008a82da
@ -351,37 +351,10 @@ pub async fn chat_message(
|
|||||||
let agent_registry = create_agent_registry();
|
let agent_registry = create_agent_registry();
|
||||||
let devops_agent = agent_registry.get("devops-incident-responder");
|
let devops_agent = agent_registry.get("devops-incident-responder");
|
||||||
|
|
||||||
// CRITICAL: Build messages array with ALL system messages FIRST, then history, then user message
|
|
||||||
// This ensures system messages are always at the beginning as required by most LLM APIs
|
|
||||||
let mut messages = Vec::new();
|
|
||||||
|
|
||||||
// 1. Inject devops-incident-responder as primary system prompt (always first)
|
|
||||||
if let Some(agent) = devops_agent {
|
|
||||||
messages.push(Message {
|
|
||||||
role: "system".into(),
|
|
||||||
content: agent.system_prompt.clone(),
|
|
||||||
tool_call_id: None,
|
|
||||||
tool_calls: None,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Inject domain system prompt if provided (second position)
|
|
||||||
if let Some(ref prompt) = system_prompt {
|
|
||||||
if !prompt.is_empty() {
|
|
||||||
messages.push(Message {
|
|
||||||
role: "system".into(),
|
|
||||||
content: prompt.clone(),
|
|
||||||
tool_call_id: None,
|
|
||||||
tool_calls: None,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tool execution configuration
|
// Tool execution configuration
|
||||||
const MAX_TOOL_ITERATIONS: usize = 20; // Allow sufficient iterations for complex diagnostics
|
const MAX_TOOL_ITERATIONS: usize = 20;
|
||||||
|
|
||||||
// Get available tools — static + MCP
|
// Get available tools — static + MCP
|
||||||
// Only enable tools if the provider explicitly supports tool calling
|
|
||||||
let tools = if provider_config.supports_tool_calling.unwrap_or(false) {
|
let tools = if provider_config.supports_tool_calling.unwrap_or(false) {
|
||||||
let mut all_tools = crate::ai::tools::get_available_tools();
|
let mut all_tools = crate::ai::tools::get_available_tools();
|
||||||
let mcp_tools = crate::ai::tools::get_enabled_mcp_tools(&state).await;
|
let mcp_tools = crate::ai::tools::get_enabled_mcp_tools(&state).await;
|
||||||
@ -395,9 +368,6 @@ pub async fn chat_message(
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
// If tools are available AND using OpenAI-compatible provider, add explicit JSON format instruction
|
|
||||||
// Only OpenAI-compatible providers (default case in create_provider) actually support tool calling.
|
|
||||||
// Others (anthropic, gemini, mistral, ollama) either ignore tools or use provider-specific formats.
|
|
||||||
let is_openai_compatible = {
|
let is_openai_compatible = {
|
||||||
let kind = if provider_config.provider_type.is_empty() {
|
let kind = if provider_config.provider_type.is_empty() {
|
||||||
provider_config.name.as_str()
|
provider_config.name.as_str()
|
||||||
@ -407,59 +377,71 @@ pub async fn chat_message(
|
|||||||
!matches!(kind, "anthropic" | "gemini" | "mistral" | "ollama")
|
!matches!(kind, "anthropic" | "gemini" | "mistral" | "ollama")
|
||||||
};
|
};
|
||||||
|
|
||||||
// 3. Tool-calling system messages — must come BEFORE history so all system messages are contiguous
|
// Collect all system prompt parts and merge into ONE message to satisfy providers
|
||||||
|
// like Qwen 3.5 that reject multiple consecutive system messages.
|
||||||
|
let mut system_parts: Vec<String> = Vec::new();
|
||||||
|
|
||||||
|
if let Some(agent) = devops_agent {
|
||||||
|
system_parts.push(agent.system_prompt.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref prompt) = system_prompt {
|
||||||
|
if !prompt.is_empty() {
|
||||||
|
system_parts.push(prompt.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if tools.is_some() && is_openai_compatible {
|
if tools.is_some() && is_openai_compatible {
|
||||||
messages.push(Message {
|
system_parts.push(
|
||||||
role: "system".into(),
|
"CRITICAL: You have tools available. When calling tools, you MUST use the native \
|
||||||
content: "CRITICAL: You have tools available. When calling tools, you MUST use the native JSON function calling format in your API response. DO NOT output XML tags like <tool_name>. DO NOT output text descriptions of tool calls. Use the structured tool_calls field in your response.".into(),
|
JSON function calling format in your API response. DO NOT output XML tags like \
|
||||||
tool_call_id: None,
|
<tool_name>. DO NOT output text descriptions of tool calls. Use the structured \
|
||||||
tool_calls: None,
|
tool_calls field in your response."
|
||||||
});
|
.to_string(),
|
||||||
|
);
|
||||||
messages.push(Message {
|
system_parts.push(format!(
|
||||||
role: "system".into(),
|
"TOOL EXECUTION BUDGET: You have a maximum of {MAX_TOOL_ITERATIONS} rounds (each \
|
||||||
content: format!(
|
AI response counts as one round). You can call multiple tools in a single round. \
|
||||||
"TOOL EXECUTION BUDGET: You have a maximum of {MAX_TOOL_ITERATIONS} rounds (each AI response counts as one round). \
|
Plan your investigation efficiently:\n\
|
||||||
You can call multiple tools in a single round. \
|
- Call multiple related tools in the same round when possible\n\
|
||||||
Plan your investigation efficiently:\n\
|
- Prioritize high-value diagnostic commands first\n\
|
||||||
- Call multiple related tools in the same round when possible\n\
|
- Use comprehensive output formats (e.g., kubectl --output=yaml) to gather more data per call\n\
|
||||||
- Prioritize high-value diagnostic commands first\n\
|
- Reserve 1 round for your final summary/answer\n\
|
||||||
- Use comprehensive output formats (e.g., kubectl --output=yaml) to gather more data per call\n\
|
- If you exceed the budget, you'll be cut off mid-investigation\n\
|
||||||
- Reserve 1 round for your final summary/answer\n\
|
Current round count is not visible to you, so plan conservatively."
|
||||||
- If you exceed the budget, you'll be cut off mid-investigation\n\
|
));
|
||||||
Current round count is not visible to you, so plan conservatively."
|
|
||||||
),
|
|
||||||
tool_call_id: None,
|
|
||||||
tool_calls: None,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Integration context as system message — still before history
|
|
||||||
if !integration_context.is_empty() {
|
if !integration_context.is_empty() {
|
||||||
|
system_parts.push(format!(
|
||||||
|
"INTERNAL DOCUMENTATION SOURCES:\n\n{integration_context}\n\n\
|
||||||
|
Instructions: The above content is from internal company documentation systems \
|
||||||
|
(Confluence, ServiceNow, Azure DevOps). \
|
||||||
|
\n\n**IMPORTANT**: First determine if this documentation is RELEVANT to the user's question:\
|
||||||
|
\n- If the documentation directly addresses the question → Use it and cite sources with URLs\
|
||||||
|
\n- If the documentation is tangentially related but doesn't answer the question → Briefly mention what internal docs exist, then provide a complete answer using general knowledge\
|
||||||
|
\n- If the documentation is completely unrelated → Ignore it and answer using general knowledge\
|
||||||
|
\n\nDo NOT force irrelevant internal documentation into your answer. The user needs accurate information, not forced citations."
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut messages = Vec::new();
|
||||||
|
|
||||||
|
if !system_parts.is_empty() {
|
||||||
messages.push(Message {
|
messages.push(Message {
|
||||||
role: "system".into(),
|
role: "system".into(),
|
||||||
content: format!(
|
content: system_parts.join("\n\n---\n\n"),
|
||||||
"INTERNAL DOCUMENTATION SOURCES:\n\n{integration_context}\n\n\
|
|
||||||
Instructions: The above content is from internal company documentation systems \
|
|
||||||
(Confluence, ServiceNow, Azure DevOps). \
|
|
||||||
\n\n**IMPORTANT**: First determine if this documentation is RELEVANT to the user's question:\
|
|
||||||
\n- If the documentation directly addresses the question → Use it and cite sources with URLs\
|
|
||||||
\n- If the documentation is tangentially related but doesn't answer the question → Briefly mention what internal docs exist, then provide a complete answer using general knowledge\
|
|
||||||
\n- If the documentation is completely unrelated → Ignore it and answer using general knowledge\
|
|
||||||
\n\nDo NOT force irrelevant internal documentation into your answer. The user needs accurate information, not forced citations."
|
|
||||||
),
|
|
||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
tool_calls: None,
|
tool_calls: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Filter out any system messages from history to avoid duplicates and maintain order
|
// Filter system messages from history (already merged above) and append
|
||||||
let filtered_history: Vec<Message> = history
|
let filtered_history: Vec<Message> = history
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|msg| msg.role != "system")
|
.filter(|msg| msg.role != "system")
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// 6. Add filtered history (user/assistant messages only) — all system messages are already above
|
|
||||||
messages.extend(filtered_history);
|
messages.extend(filtered_history);
|
||||||
|
|
||||||
messages.push(Message {
|
messages.push(Message {
|
||||||
@ -541,19 +523,25 @@ pub async fn chat_message(
|
|||||||
if let Some(tool_calls) = &response.tool_calls {
|
if let Some(tool_calls) = &response.tool_calls {
|
||||||
tracing::info!("AI requested {} tool call(s)", tool_calls.len());
|
tracing::info!("AI requested {} tool call(s)", tool_calls.len());
|
||||||
|
|
||||||
// Execute each tool call
|
// OpenAI API contract: push the assistant message WITH tool_calls BEFORE any tool results
|
||||||
|
messages.push(Message {
|
||||||
|
role: "assistant".into(),
|
||||||
|
content: response.content.clone(),
|
||||||
|
tool_call_id: None,
|
||||||
|
tool_calls: Some(tool_calls.clone()),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Execute each tool call and append result messages
|
||||||
for tool_call in tool_calls {
|
for tool_call in tool_calls {
|
||||||
tracing::info!("Executing tool: {}", tool_call.name);
|
tracing::info!("Executing tool: {}", tool_call.name);
|
||||||
|
|
||||||
let tool_result = execute_tool_call(tool_call, &app_handle, &state).await;
|
let tool_result = execute_tool_call(tool_call, &app_handle, &state).await;
|
||||||
|
|
||||||
// Format result
|
|
||||||
let result_content = match tool_result {
|
let result_content = match tool_result {
|
||||||
Ok(result) => result,
|
Ok(result) => result,
|
||||||
Err(e) => format!("Error executing tool: {e}"),
|
Err(e) => format!("Error executing tool: {e}"),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add tool result as a message
|
|
||||||
messages.push(Message {
|
messages.push(Message {
|
||||||
role: "tool".into(),
|
role: "tool".into(),
|
||||||
content: result_content,
|
content: result_content,
|
||||||
|
|||||||
@ -432,7 +432,7 @@ pub async fn list_proxmox_vms(
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_proxmox_vm(
|
pub async fn get_proxmox_vm(
|
||||||
cluster_id: String,
|
cluster_id: String,
|
||||||
node: String,
|
node_id: String,
|
||||||
vm_id: u32,
|
vm_id: u32,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<serde_json::Value, String> {
|
) -> Result<serde_json::Value, String> {
|
||||||
@ -441,7 +441,7 @@ pub async fn get_proxmox_vm(
|
|||||||
|
|
||||||
let vm = crate::proxmox::vm::get_vm(
|
let vm = crate::proxmox::vm::get_vm(
|
||||||
&client_guard,
|
&client_guard,
|
||||||
&node,
|
&node_id,
|
||||||
vm_id,
|
vm_id,
|
||||||
client_guard.ticket.as_deref().unwrap_or(""),
|
client_guard.ticket.as_deref().unwrap_or(""),
|
||||||
)
|
)
|
||||||
@ -455,7 +455,7 @@ pub async fn get_proxmox_vm(
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn start_proxmox_vm(
|
pub async fn start_proxmox_vm(
|
||||||
cluster_id: String,
|
cluster_id: String,
|
||||||
node: String,
|
node_id: String,
|
||||||
vm_id: u32,
|
vm_id: u32,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
@ -464,7 +464,7 @@ pub async fn start_proxmox_vm(
|
|||||||
|
|
||||||
crate::proxmox::vm::start_vm(
|
crate::proxmox::vm::start_vm(
|
||||||
&client_guard,
|
&client_guard,
|
||||||
&node,
|
&node_id,
|
||||||
vm_id,
|
vm_id,
|
||||||
client_guard.ticket.as_deref().unwrap_or(""),
|
client_guard.ticket.as_deref().unwrap_or(""),
|
||||||
)
|
)
|
||||||
@ -476,7 +476,7 @@ pub async fn start_proxmox_vm(
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn stop_proxmox_vm(
|
pub async fn stop_proxmox_vm(
|
||||||
cluster_id: String,
|
cluster_id: String,
|
||||||
node: String,
|
node_id: String,
|
||||||
vm_id: u32,
|
vm_id: u32,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
@ -485,7 +485,7 @@ pub async fn stop_proxmox_vm(
|
|||||||
|
|
||||||
crate::proxmox::vm::stop_vm(
|
crate::proxmox::vm::stop_vm(
|
||||||
&client_guard,
|
&client_guard,
|
||||||
&node,
|
&node_id,
|
||||||
vm_id,
|
vm_id,
|
||||||
client_guard.ticket.as_deref().unwrap_or(""),
|
client_guard.ticket.as_deref().unwrap_or(""),
|
||||||
)
|
)
|
||||||
@ -497,7 +497,7 @@ pub async fn stop_proxmox_vm(
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn reboot_proxmox_vm(
|
pub async fn reboot_proxmox_vm(
|
||||||
cluster_id: String,
|
cluster_id: String,
|
||||||
node: String,
|
node_id: String,
|
||||||
vm_id: u32,
|
vm_id: u32,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
@ -506,7 +506,7 @@ pub async fn reboot_proxmox_vm(
|
|||||||
|
|
||||||
crate::proxmox::vm::reboot_vm(
|
crate::proxmox::vm::reboot_vm(
|
||||||
&client_guard,
|
&client_guard,
|
||||||
&node,
|
&node_id,
|
||||||
vm_id,
|
vm_id,
|
||||||
client_guard.ticket.as_deref().unwrap_or(""),
|
client_guard.ticket.as_deref().unwrap_or(""),
|
||||||
)
|
)
|
||||||
@ -518,7 +518,7 @@ pub async fn reboot_proxmox_vm(
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn shutdown_proxmox_vm(
|
pub async fn shutdown_proxmox_vm(
|
||||||
cluster_id: String,
|
cluster_id: String,
|
||||||
node: String,
|
node_id: String,
|
||||||
vm_id: u32,
|
vm_id: u32,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
@ -527,7 +527,7 @@ pub async fn shutdown_proxmox_vm(
|
|||||||
|
|
||||||
crate::proxmox::vm::shutdown_vm(
|
crate::proxmox::vm::shutdown_vm(
|
||||||
&client_guard,
|
&client_guard,
|
||||||
&node,
|
&node_id,
|
||||||
vm_id,
|
vm_id,
|
||||||
client_guard.ticket.as_deref().unwrap_or(""),
|
client_guard.ticket.as_deref().unwrap_or(""),
|
||||||
)
|
)
|
||||||
@ -539,7 +539,7 @@ pub async fn shutdown_proxmox_vm(
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn resume_proxmox_vm(
|
pub async fn resume_proxmox_vm(
|
||||||
cluster_id: String,
|
cluster_id: String,
|
||||||
node: String,
|
node_id: String,
|
||||||
vm_id: u32,
|
vm_id: u32,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
@ -548,7 +548,7 @@ pub async fn resume_proxmox_vm(
|
|||||||
|
|
||||||
crate::proxmox::vm::resume_vm(
|
crate::proxmox::vm::resume_vm(
|
||||||
&client_guard,
|
&client_guard,
|
||||||
&node,
|
&node_id,
|
||||||
vm_id,
|
vm_id,
|
||||||
client_guard.ticket.as_deref().unwrap_or(""),
|
client_guard.ticket.as_deref().unwrap_or(""),
|
||||||
)
|
)
|
||||||
@ -560,7 +560,7 @@ pub async fn resume_proxmox_vm(
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn suspend_proxmox_vm(
|
pub async fn suspend_proxmox_vm(
|
||||||
cluster_id: String,
|
cluster_id: String,
|
||||||
node: String,
|
node_id: String,
|
||||||
vm_id: u32,
|
vm_id: u32,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
@ -569,7 +569,7 @@ pub async fn suspend_proxmox_vm(
|
|||||||
|
|
||||||
crate::proxmox::vm::suspend_vm(
|
crate::proxmox::vm::suspend_vm(
|
||||||
&client_guard,
|
&client_guard,
|
||||||
&node,
|
&node_id,
|
||||||
vm_id,
|
vm_id,
|
||||||
client_guard.ticket.as_deref().unwrap_or(""),
|
client_guard.ticket.as_deref().unwrap_or(""),
|
||||||
)
|
)
|
||||||
@ -581,7 +581,7 @@ pub async fn suspend_proxmox_vm(
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn clone_vm(
|
pub async fn clone_vm(
|
||||||
cluster_id: String,
|
cluster_id: String,
|
||||||
node: String,
|
node_id: String,
|
||||||
vm_id: u32,
|
vm_id: u32,
|
||||||
new_vmid: u32,
|
new_vmid: u32,
|
||||||
name: String,
|
name: String,
|
||||||
@ -592,7 +592,7 @@ pub async fn clone_vm(
|
|||||||
|
|
||||||
crate::proxmox::vm::clone_vm(
|
crate::proxmox::vm::clone_vm(
|
||||||
&client_guard,
|
&client_guard,
|
||||||
&node,
|
&node_id,
|
||||||
vm_id,
|
vm_id,
|
||||||
new_vmid,
|
new_vmid,
|
||||||
&name,
|
&name,
|
||||||
@ -606,7 +606,7 @@ pub async fn clone_vm(
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn delete_vm(
|
pub async fn delete_vm(
|
||||||
cluster_id: String,
|
cluster_id: String,
|
||||||
node: String,
|
node_id: String,
|
||||||
vm_id: u32,
|
vm_id: u32,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
@ -615,7 +615,7 @@ pub async fn delete_vm(
|
|||||||
|
|
||||||
crate::proxmox::vm::delete_vm(
|
crate::proxmox::vm::delete_vm(
|
||||||
&client_guard,
|
&client_guard,
|
||||||
&node,
|
&node_id,
|
||||||
vm_id,
|
vm_id,
|
||||||
client_guard.ticket.as_deref().unwrap_or(""),
|
client_guard.ticket.as_deref().unwrap_or(""),
|
||||||
)
|
)
|
||||||
@ -681,74 +681,113 @@ pub async fn list_proxmox_backup_jobs(
|
|||||||
Ok(jobs)
|
Ok(jobs)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List Proxmox Datastores (Storage per node)
|
/// List Proxmox Datastores (cluster-wide via cluster/resources)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn list_proxmox_datastores(
|
pub async fn list_proxmox_datastores(
|
||||||
cluster_id: String,
|
cluster_id: String,
|
||||||
_state: State<'_, AppState>,
|
_state: State<'_, AppState>,
|
||||||
) -> Result<Vec<serde_json::Value>, String> {
|
) -> Result<Vec<serde_json::Value>, String> {
|
||||||
// Note: Proxmox VE storage is per-node, not cluster-wide
|
|
||||||
// We need to get all nodes first, then fetch storage for each node
|
|
||||||
let client = get_proxmox_client_for_cluster(&cluster_id, &_state).await?;
|
let client = get_proxmox_client_for_cluster(&cluster_id, &_state).await?;
|
||||||
let client_guard = client.lock().await;
|
let client_guard = client.lock().await;
|
||||||
|
|
||||||
// First, get all nodes
|
let response: serde_json::Value = client_guard
|
||||||
let nodes_path = "cluster/resources?type=node";
|
|
||||||
let nodes_response: serde_json::Value = client_guard
|
|
||||||
.get(
|
.get(
|
||||||
nodes_path,
|
"cluster/resources?type=storage",
|
||||||
Some(client_guard.ticket.as_deref().unwrap_or("")),
|
Some(client_guard.ticket.as_deref().unwrap_or("")),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to list nodes: {}", e))?;
|
.map_err(|e| format!("Failed to list cluster storage: {}", e))?;
|
||||||
|
|
||||||
let nodes: Vec<String> = nodes_response
|
let entries = response.as_array().ok_or("Invalid response format")?;
|
||||||
.as_array()
|
|
||||||
.unwrap_or(&vec![])
|
let all_storage: Vec<serde_json::Value> = entries
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|n| {
|
.filter_map(|entry| {
|
||||||
n.get("node")
|
let obj = entry.as_object()?;
|
||||||
.and_then(|node| node.as_str())
|
let mut normalized = serde_json::Map::new();
|
||||||
.map(|s| s.to_string())
|
|
||||||
|
let storage_name = obj.get("storage").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
let node_name = obj.get("node").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
|
||||||
|
// Avoid double-slash when cluster/resources omits "node" for shared storage
|
||||||
|
let storage_id = if node_name.is_empty() {
|
||||||
|
format!("storage/{}", storage_name)
|
||||||
|
} else {
|
||||||
|
format!("storage/{}/{}", node_name, storage_name)
|
||||||
|
};
|
||||||
|
if storage_name.is_empty() {
|
||||||
|
tracing::warn!(
|
||||||
|
node = node_name,
|
||||||
|
"storage entry has empty storage name — skipping"
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
tracing::debug!(storage_id = %storage_id, "generated storage ID");
|
||||||
|
normalized.insert("id".to_string(), serde_json::Value::String(storage_id));
|
||||||
|
normalized.insert(
|
||||||
|
"storage".to_string(),
|
||||||
|
serde_json::Value::String(storage_name.to_string()),
|
||||||
|
);
|
||||||
|
normalized.insert(
|
||||||
|
"name".to_string(),
|
||||||
|
serde_json::Value::String(storage_name.to_string()),
|
||||||
|
);
|
||||||
|
|
||||||
|
let plugin_type = obj
|
||||||
|
.get("plugintype")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("unknown");
|
||||||
|
normalized.insert(
|
||||||
|
"type".to_string(),
|
||||||
|
serde_json::Value::String(plugin_type.to_string()),
|
||||||
|
);
|
||||||
|
|
||||||
|
let content = obj
|
||||||
|
.get("content")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
normalized.insert("content".to_string(), serde_json::Value::String(content));
|
||||||
|
|
||||||
|
normalized.insert(
|
||||||
|
"node".to_string(),
|
||||||
|
serde_json::Value::String(node_name.to_string()),
|
||||||
|
);
|
||||||
|
|
||||||
|
// cluster/resources uses disk/maxdisk; normalize to used/available/size
|
||||||
|
let disk_used = obj.get("disk").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||||
|
let disk_total = obj.get("maxdisk").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||||
|
let disk_avail = disk_total.saturating_sub(disk_used);
|
||||||
|
|
||||||
|
normalized.insert(
|
||||||
|
"used".to_string(),
|
||||||
|
serde_json::Value::Number(disk_used.into()),
|
||||||
|
);
|
||||||
|
normalized.insert(
|
||||||
|
"size".to_string(),
|
||||||
|
serde_json::Value::Number(disk_total.into()),
|
||||||
|
);
|
||||||
|
normalized.insert(
|
||||||
|
"available".to_string(),
|
||||||
|
serde_json::Value::Number(disk_avail.into()),
|
||||||
|
);
|
||||||
|
|
||||||
|
let status = obj
|
||||||
|
.get("status")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("available")
|
||||||
|
.to_string();
|
||||||
|
normalized.insert("status".to_string(), serde_json::Value::String(status));
|
||||||
|
|
||||||
|
// Preserve shared flag if present
|
||||||
|
if let Some(shared) = obj.get("shared") {
|
||||||
|
normalized.insert("shared".to_string(), shared.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(serde_json::Value::Object(normalized))
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
if nodes.is_empty() {
|
|
||||||
return Ok(vec![]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch storage for each node
|
|
||||||
let mut all_storage: Vec<serde_json::Value> = vec![];
|
|
||||||
|
|
||||||
for node in nodes {
|
|
||||||
let storage_path = format!("nodes/{}/storage", node);
|
|
||||||
let storage_response: serde_json::Value = client_guard
|
|
||||||
.get(
|
|
||||||
&storage_path,
|
|
||||||
Some(client_guard.ticket.as_deref().unwrap_or("")),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|e| format!("Failed to list storage for node {}: {}", node, e))?;
|
|
||||||
|
|
||||||
if let Some(storage_array) = storage_response.as_array() {
|
|
||||||
for mut storage in storage_array.clone() {
|
|
||||||
// Add node information to each storage entry
|
|
||||||
if let Some(storage_obj) = storage.as_object_mut() {
|
|
||||||
storage_obj.insert("node".to_string(), serde_json::Value::String(node.clone()));
|
|
||||||
// Create a unique ID
|
|
||||||
if let Some(storage_name) = storage_obj.get("storage").and_then(|s| s.as_str())
|
|
||||||
{
|
|
||||||
storage_obj.insert(
|
|
||||||
"id".to_string(),
|
|
||||||
serde_json::Value::String(format!("storage/{}", storage_name)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
all_storage.push(storage);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(all_storage)
|
Ok(all_storage)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -756,7 +795,7 @@ pub async fn list_proxmox_datastores(
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn trigger_proxmox_backup_job(
|
pub async fn trigger_proxmox_backup_job(
|
||||||
cluster_id: String,
|
cluster_id: String,
|
||||||
node: String,
|
node_id: String,
|
||||||
job_id: u32,
|
job_id: u32,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
@ -765,7 +804,7 @@ pub async fn trigger_proxmox_backup_job(
|
|||||||
|
|
||||||
crate::proxmox::backup::trigger_backup_job(
|
crate::proxmox::backup::trigger_backup_job(
|
||||||
&client_guard,
|
&client_guard,
|
||||||
&node,
|
&node_id,
|
||||||
job_id,
|
job_id,
|
||||||
client_guard.ticket.as_deref().unwrap_or(""),
|
client_guard.ticket.as_deref().unwrap_or(""),
|
||||||
)
|
)
|
||||||
@ -1384,7 +1423,7 @@ pub async fn get_certificate(
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn list_firewall_rules(
|
pub async fn list_firewall_rules(
|
||||||
cluster_id: String,
|
cluster_id: String,
|
||||||
node: String,
|
node_id: String,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<Vec<serde_json::Value>, String> {
|
) -> Result<Vec<serde_json::Value>, String> {
|
||||||
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
|
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
|
||||||
@ -1392,51 +1431,85 @@ pub async fn list_firewall_rules(
|
|||||||
|
|
||||||
let rules = crate::proxmox::firewall::list_firewall_rules(
|
let rules = crate::proxmox::firewall::list_firewall_rules(
|
||||||
&client_guard,
|
&client_guard,
|
||||||
&node,
|
&node_id,
|
||||||
client_guard.ticket.as_deref().unwrap_or(""),
|
client_guard.ticket.as_deref().unwrap_or(""),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to list firewall rules: {}", e))?;
|
.map_err(|e| format!("Failed to list firewall rules: {}", e))?;
|
||||||
|
|
||||||
|
// Normalize to match what FirewallRuleList component expects:
|
||||||
|
// rule (position number), action, protocol, source, destination, port, status
|
||||||
let json_rules: Vec<serde_json::Value> = rules
|
let json_rules: Vec<serde_json::Value> = rules
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|r| serde_json::to_value(r).map_err(|e| format!("Failed to serialize rule: {}", e)))
|
.map(|r| {
|
||||||
.collect::<Result<Vec<_>, _>>()?;
|
serde_json::json!({
|
||||||
|
"id": r.rule_num.to_string(),
|
||||||
|
"rule": r.rule_num,
|
||||||
|
"action": r.action,
|
||||||
|
"protocol": r.protocol,
|
||||||
|
"source": r.source,
|
||||||
|
"destination": r.destination,
|
||||||
|
"port": r.port,
|
||||||
|
"status": if r.enabled { "enabled" } else { "disabled" },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
Ok(json_rules)
|
Ok(json_rules)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add firewall rule
|
/// Add firewall rule
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
pub async fn add_firewall_rule(
|
pub async fn add_firewall_rule(
|
||||||
cluster_id: String,
|
cluster_id: String,
|
||||||
node: String,
|
node_id: String,
|
||||||
action: String,
|
rule: serde_json::Value,
|
||||||
protocol: String,
|
|
||||||
source: String,
|
|
||||||
destination: String,
|
|
||||||
port: Option<String>,
|
|
||||||
enabled: bool,
|
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
|
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
|
||||||
let client_guard = client.lock().await;
|
let client_guard = client.lock().await;
|
||||||
|
|
||||||
let rule = crate::proxmox::firewall::FirewallRule {
|
let firewall_rule = crate::proxmox::firewall::FirewallRule {
|
||||||
rule_num: 0,
|
rule_num: 0,
|
||||||
action,
|
action: rule
|
||||||
protocol,
|
.get("action")
|
||||||
source,
|
.and_then(|v| v.as_str())
|
||||||
destination,
|
.unwrap_or("ACCEPT")
|
||||||
port,
|
.to_string(),
|
||||||
enabled,
|
protocol: rule
|
||||||
|
.get("proto")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("tcp")
|
||||||
|
.to_string(),
|
||||||
|
source: rule
|
||||||
|
.get("source")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string(),
|
||||||
|
destination: rule
|
||||||
|
.get("dest")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string(),
|
||||||
|
port: rule
|
||||||
|
.get("dport")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(|s| s.to_string()),
|
||||||
|
enabled: rule
|
||||||
|
.get("enable")
|
||||||
|
.and_then(|v| {
|
||||||
|
v.as_bool()
|
||||||
|
.or_else(|| v.as_i64().map(|n| n != 0))
|
||||||
|
.or_else(|| v.as_str().map(|s| s == "1" || s == "true"))
|
||||||
|
})
|
||||||
|
.unwrap_or(true),
|
||||||
};
|
};
|
||||||
|
|
||||||
crate::proxmox::firewall::add_rule(
|
crate::proxmox::firewall::add_rule(
|
||||||
&client_guard,
|
&client_guard,
|
||||||
&node,
|
&node_id,
|
||||||
&rule,
|
&firewall_rule,
|
||||||
client_guard.ticket.as_deref().unwrap_or(""),
|
client_guard.ticket.as_deref().unwrap_or(""),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@ -1447,7 +1520,7 @@ pub async fn add_firewall_rule(
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn delete_firewall_rule(
|
pub async fn delete_firewall_rule(
|
||||||
cluster_id: String,
|
cluster_id: String,
|
||||||
node: String,
|
node_id: String,
|
||||||
rule_num: u32,
|
rule_num: u32,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
@ -1456,7 +1529,7 @@ pub async fn delete_firewall_rule(
|
|||||||
|
|
||||||
crate::proxmox::firewall::delete_rule(
|
crate::proxmox::firewall::delete_rule(
|
||||||
&client_guard,
|
&client_guard,
|
||||||
&node,
|
&node_id,
|
||||||
rule_num,
|
rule_num,
|
||||||
client_guard.ticket.as_deref().unwrap_or(""),
|
client_guard.ticket.as_deref().unwrap_or(""),
|
||||||
)
|
)
|
||||||
@ -1594,7 +1667,7 @@ pub async fn get_ceph_cluster_status(
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn migrate_vm(
|
pub async fn migrate_vm(
|
||||||
cluster_id: String,
|
cluster_id: String,
|
||||||
node: String,
|
node_id: String,
|
||||||
vm_id: u32,
|
vm_id: u32,
|
||||||
target_node: String,
|
target_node: String,
|
||||||
target_cluster: String,
|
target_cluster: String,
|
||||||
@ -1605,7 +1678,7 @@ pub async fn migrate_vm(
|
|||||||
|
|
||||||
let task = crate::proxmox::migration::migrate_vm(
|
let task = crate::proxmox::migration::migrate_vm(
|
||||||
&client_guard,
|
&client_guard,
|
||||||
&node,
|
&node_id,
|
||||||
vm_id,
|
vm_id,
|
||||||
&target_node,
|
&target_node,
|
||||||
&target_cluster,
|
&target_cluster,
|
||||||
@ -1621,7 +1694,7 @@ pub async fn migrate_vm(
|
|||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn list_migration_status(
|
pub async fn list_migration_status(
|
||||||
cluster_id: String,
|
cluster_id: String,
|
||||||
node: String,
|
node_id: String,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<Vec<serde_json::Value>, String> {
|
) -> Result<Vec<serde_json::Value>, String> {
|
||||||
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
|
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
|
||||||
@ -1629,7 +1702,7 @@ pub async fn list_migration_status(
|
|||||||
|
|
||||||
let tasks = crate::proxmox::migration::list_migration_status(
|
let tasks = crate::proxmox::migration::list_migration_status(
|
||||||
&client_guard,
|
&client_guard,
|
||||||
&node,
|
&node_id,
|
||||||
client_guard.ticket.as_deref().unwrap_or(""),
|
client_guard.ticket.as_deref().unwrap_or(""),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
|||||||
@ -23,7 +23,8 @@ pub struct FirewallStatus {
|
|||||||
pub rule_count: u32,
|
pub rule_count: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List firewall rules
|
/// List firewall rules — returns normalized Vec<FirewallRule> using correct PVE field names.
|
||||||
|
/// PVE uses: pos (position), proto (protocol), enable (0/1 integer), src (source), dest (destination).
|
||||||
pub async fn list_firewall_rules(
|
pub async fn list_firewall_rules(
|
||||||
client: &crate::proxmox::client::ProxmoxClient,
|
client: &crate::proxmox::client::ProxmoxClient,
|
||||||
node: &str,
|
node: &str,
|
||||||
@ -35,44 +36,60 @@ pub async fn list_firewall_rules(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to list firewall rules: {}", e))?;
|
.map_err(|e| format!("Failed to list firewall rules: {}", e))?;
|
||||||
|
|
||||||
if let Some(rules) = response.as_array() {
|
let rules = response.as_array().ok_or("Invalid response format")?;
|
||||||
let rule_list: Vec<FirewallRule> = rules
|
|
||||||
.iter()
|
|
||||||
.filter_map(|rule| {
|
|
||||||
let rule_num = rule.get("rule_num")?.as_u64()? as u32;
|
|
||||||
let action = rule.get("action")?.as_str()?.to_string();
|
|
||||||
let protocol = rule.get("protocol")?.as_str().unwrap_or("").to_string();
|
|
||||||
let source = rule.get("source")?.as_str().unwrap_or("").to_string();
|
|
||||||
let destination = rule.get("dest")?.as_str().unwrap_or("").to_string();
|
|
||||||
let port = rule
|
|
||||||
.get("dport")
|
|
||||||
.or(rule.get("sport"))
|
|
||||||
.and_then(|p| p.as_str())
|
|
||||||
.map(|s| s.to_string());
|
|
||||||
let enabled = rule
|
|
||||||
.get("enabled")
|
|
||||||
.and_then(|e| e.as_bool())
|
|
||||||
.unwrap_or(true);
|
|
||||||
|
|
||||||
Some(FirewallRule {
|
let rule_list: Vec<FirewallRule> = rules
|
||||||
rule_num,
|
.iter()
|
||||||
action,
|
.filter_map(|rule| {
|
||||||
protocol,
|
// PVE uses "pos" for the rule position number
|
||||||
source,
|
let rule_num = rule.get("pos").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
|
||||||
destination,
|
let action = rule.get("action").and_then(|v| v.as_str())?.to_string();
|
||||||
port,
|
// PVE uses "proto" not "protocol"
|
||||||
enabled,
|
let protocol = rule
|
||||||
})
|
.get("proto")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
// source and dest are optional fields
|
||||||
|
let source = rule
|
||||||
|
.get("source")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
let destination = rule
|
||||||
|
.get("dest")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
let port = rule
|
||||||
|
.get("dport")
|
||||||
|
.or_else(|| rule.get("sport"))
|
||||||
|
.and_then(|p| p.as_str())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
// PVE uses "enable" as integer (1=enabled, 0=disabled), not "enabled" bool
|
||||||
|
let enabled = rule
|
||||||
|
.get("enable")
|
||||||
|
.and_then(|e| e.as_i64().map(|n| n != 0).or_else(|| e.as_bool()))
|
||||||
|
.unwrap_or(true);
|
||||||
|
|
||||||
|
Some(FirewallRule {
|
||||||
|
rule_num,
|
||||||
|
action,
|
||||||
|
protocol,
|
||||||
|
source,
|
||||||
|
destination,
|
||||||
|
port,
|
||||||
|
enabled,
|
||||||
})
|
})
|
||||||
.collect();
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
Ok(rule_list)
|
Ok(rule_list)
|
||||||
} else {
|
|
||||||
Err("Invalid response format".to_string())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add firewall rule
|
/// Add firewall rule — uses correct PVE API field names (proto, enable, dest).
|
||||||
|
/// `rule.rule_num` is intentionally not sent: PVE assigns the position (pos) automatically
|
||||||
|
/// on creation. rule_num is only used for update/delete operations on existing rules.
|
||||||
pub async fn add_rule(
|
pub async fn add_rule(
|
||||||
client: &crate::proxmox::client::ProxmoxClient,
|
client: &crate::proxmox::client::ProxmoxClient,
|
||||||
node: &str,
|
node: &str,
|
||||||
@ -80,15 +97,29 @@ pub async fn add_rule(
|
|||||||
ticket: &str,
|
ticket: &str,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let path = format!("nodes/{}/firewall/rules", node);
|
let path = format!("nodes/{}/firewall/rules", node);
|
||||||
let config = serde_json::json!({
|
|
||||||
|
let mut config = serde_json::json!({
|
||||||
"action": rule.action,
|
"action": rule.action,
|
||||||
"protocol": rule.protocol,
|
"type": "in",
|
||||||
"source": rule.source,
|
"enable": if rule.enabled { 1 } else { 0 }
|
||||||
"dest": rule.destination,
|
|
||||||
"dport": rule.port,
|
|
||||||
"enabled": rule.enabled
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Only include optional fields when non-empty
|
||||||
|
if !rule.protocol.is_empty() {
|
||||||
|
config["proto"] = serde_json::Value::String(rule.protocol.clone());
|
||||||
|
}
|
||||||
|
if !rule.source.is_empty() {
|
||||||
|
config["source"] = serde_json::Value::String(rule.source.clone());
|
||||||
|
}
|
||||||
|
if !rule.destination.is_empty() {
|
||||||
|
config["dest"] = serde_json::Value::String(rule.destination.clone());
|
||||||
|
}
|
||||||
|
if let Some(ref port) = rule.port {
|
||||||
|
if !port.is_empty() {
|
||||||
|
config["dport"] = serde_json::Value::String(port.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let _response: serde_json::Value = client
|
let _response: serde_json::Value = client
|
||||||
.post(&path, &config, Some(ticket))
|
.post(&path, &config, Some(ticket))
|
||||||
.await
|
.await
|
||||||
@ -200,19 +231,31 @@ pub async fn get_firewall_status(
|
|||||||
.unwrap_or(&Vec::new())
|
.unwrap_or(&Vec::new())
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|rule| {
|
.filter_map(|rule| {
|
||||||
let rule_num = rule.get("rule_num")?.as_u64()? as u32;
|
let rule_num = rule.get("pos").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
|
||||||
let action = rule.get("action")?.as_str()?.to_string();
|
let action = rule.get("action").and_then(|v| v.as_str())?.to_string();
|
||||||
let protocol = rule.get("protocol")?.as_str().unwrap_or("").to_string();
|
let protocol = rule
|
||||||
let source = rule.get("source")?.as_str().unwrap_or("").to_string();
|
.get("proto")
|
||||||
let destination = rule.get("dest")?.as_str().unwrap_or("").to_string();
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
let source = rule
|
||||||
|
.get("source")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
let destination = rule
|
||||||
|
.get("dest")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
let port = rule
|
let port = rule
|
||||||
.get("dport")
|
.get("dport")
|
||||||
.or(rule.get("sport"))
|
.or_else(|| rule.get("sport"))
|
||||||
.and_then(|p| p.as_str())
|
.and_then(|p| p.as_str())
|
||||||
.map(|s| s.to_string());
|
.map(|s| s.to_string());
|
||||||
let enabled = rule
|
let enabled = rule
|
||||||
.get("enabled")
|
.get("enable")
|
||||||
.and_then(|e| e.as_bool())
|
.and_then(|e| e.as_i64().map(|n| n != 0).or_else(|| e.as_bool()))
|
||||||
.unwrap_or(true);
|
.unwrap_or(true);
|
||||||
|
|
||||||
Some(FirewallRule {
|
Some(FirewallRule {
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { clsx } from 'clsx';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/index';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/index';
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/index';
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/index';
|
||||||
import { Button } from '@/components/ui/index';
|
import { Button } from '@/components/ui/index';
|
||||||
@ -14,6 +15,7 @@ import { Checkbox as UICheckbox } from '@/components/ui/index';
|
|||||||
import { Input } from '@/components/ui/index';
|
import { Input } from '@/components/ui/index';
|
||||||
import { AlertCircle } from 'lucide-react';
|
import { AlertCircle } from 'lucide-react';
|
||||||
import { Alert, AlertDescription } from '@/components/ui/index';
|
import { Alert, AlertDescription } from '@/components/ui/index';
|
||||||
|
import type { ClusterInfo } from '@/lib/domain';
|
||||||
|
|
||||||
interface VMInfo {
|
interface VMInfo {
|
||||||
id: string;
|
id: string;
|
||||||
@ -50,6 +52,8 @@ interface RawVMInfo {
|
|||||||
|
|
||||||
interface VMListProps {
|
interface VMListProps {
|
||||||
vms: RawVMInfo[];
|
vms: RawVMInfo[];
|
||||||
|
clusterId: string;
|
||||||
|
clusters?: ClusterInfo[];
|
||||||
onRefresh?: () => void;
|
onRefresh?: () => void;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
onSnapshotAction?: (vm: VMInfo, action: 'create' | 'list' | 'rollback' | 'delete') => void;
|
onSnapshotAction?: (vm: VMInfo, action: 'create' | 'list' | 'rollback' | 'delete') => void;
|
||||||
@ -62,16 +66,16 @@ interface VMListProps {
|
|||||||
|
|
||||||
function formatUptime(seconds: number): string {
|
function formatUptime(seconds: number): string {
|
||||||
if (seconds <= 0) return '-';
|
if (seconds <= 0) return '-';
|
||||||
|
|
||||||
const days = Math.floor(seconds / 86400);
|
const days = Math.floor(seconds / 86400);
|
||||||
const hours = Math.floor((seconds % 86400) / 3600);
|
const hours = Math.floor((seconds % 86400) / 3600);
|
||||||
const minutes = Math.floor((seconds % 3600) / 60);
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||||||
|
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
if (days > 0) parts.push(`${days}d`);
|
if (days > 0) parts.push(`${days}d`);
|
||||||
if (hours > 0) parts.push(`${hours}h`);
|
if (hours > 0) parts.push(`${hours}h`);
|
||||||
if (minutes > 0 || parts.length === 0) parts.push(`${minutes}m`);
|
if (minutes > 0 || parts.length === 0) parts.push(`${minutes}m`);
|
||||||
|
|
||||||
return parts.join(' ');
|
return parts.join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -85,22 +89,23 @@ function formatBytes(bytes: number): string {
|
|||||||
|
|
||||||
export function VMList({
|
export function VMList({
|
||||||
vms: rawVms,
|
vms: rawVms,
|
||||||
|
clusterId,
|
||||||
|
clusters = [],
|
||||||
onRefresh,
|
onRefresh,
|
||||||
isLoading,
|
isLoading,
|
||||||
onSnapshotAction,
|
onSnapshotAction: _onSnapshotAction,
|
||||||
onMigrate,
|
onMigrate: _onMigrate,
|
||||||
onClone,
|
onClone: _onClone,
|
||||||
onDelete,
|
onDelete: _onDelete,
|
||||||
selectedVMs = new Set<string>(),
|
selectedVMs = new Set<string>(),
|
||||||
onToggleSelect,
|
onToggleSelect,
|
||||||
}: VMListProps) {
|
}: VMListProps) {
|
||||||
const [clusterId, setClusterId] = useState<string>('');
|
|
||||||
const [migrationVM, setMigrationVM] = useState<VMInfo | null>(null);
|
const [migrationVM, setMigrationVM] = useState<VMInfo | null>(null);
|
||||||
const [targetNode, setTargetNode] = useState<string>('');
|
const [targetNode, setTargetNode] = useState<string>('');
|
||||||
|
const [targetCluster, setTargetCluster] = useState<string>('');
|
||||||
const [onlineMigration, setOnlineMigration] = useState(true);
|
const [onlineMigration, setOnlineMigration] = useState(true);
|
||||||
const [maxDowntime, setMaxDowntime] = useState(30);
|
const [maxDowntime, setMaxDowntime] = useState(30);
|
||||||
|
|
||||||
// Transform raw VM data to VMInfo format
|
|
||||||
const vms: VMInfo[] = React.useMemo(() => {
|
const vms: VMInfo[] = React.useMemo(() => {
|
||||||
return rawVms.map((vm) => ({
|
return rawVms.map((vm) => ({
|
||||||
id: String(vm.id || vm.vmid),
|
id: String(vm.id || vm.vmid),
|
||||||
@ -118,17 +123,13 @@ export function VMList({
|
|||||||
}));
|
}));
|
||||||
}, [rawVms]);
|
}, [rawVms]);
|
||||||
|
|
||||||
useEffect(() => {
|
// clusterId comes from props (not captured via closure over state), so it is always
|
||||||
invoke<string[]>('list_proxmox_clusters')
|
// current when an action fires even if the user switches clusters mid-session.
|
||||||
.then((clusters: any[]) => {
|
|
||||||
if (clusters.length > 0) {
|
|
||||||
setClusterId(clusters[0].id);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleVMAction = useCallback(async (vm: VMInfo, action: string) => {
|
const handleVMAction = useCallback(async (vm: VMInfo, action: string) => {
|
||||||
|
if (!clusterId) {
|
||||||
|
toast.error('No cluster selected');
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'start':
|
case 'start':
|
||||||
@ -204,7 +205,8 @@ export function VMList({
|
|||||||
.map((v) => v.node)
|
.map((v) => v.node)
|
||||||
.filter((node, index, self) => self.indexOf(node) === index && node !== vm.node);
|
.filter((node, index, self) => self.indexOf(node) === index && node !== vm.node);
|
||||||
setTargetNode(availableNodes[0] || '');
|
setTargetNode(availableNodes[0] || '');
|
||||||
}, [vms]);
|
setTargetCluster(clusterId);
|
||||||
|
}, [vms, clusterId]);
|
||||||
|
|
||||||
const submitMigration = useCallback(async () => {
|
const submitMigration = useCallback(async () => {
|
||||||
if (!migrationVM || !targetNode) {
|
if (!migrationVM || !targetNode) {
|
||||||
@ -212,27 +214,34 @@ export function VMList({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sourceCluster = clusterId;
|
||||||
|
const destCluster = targetCluster || clusterId;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await invoke('migrate_vm', {
|
await invoke('migrate_vm', {
|
||||||
clusterId,
|
clusterId: sourceCluster,
|
||||||
nodeId: migrationVM.node,
|
nodeId: migrationVM.node,
|
||||||
vmId: migrationVM.vmid,
|
vmId: migrationVM.vmid,
|
||||||
targetNode,
|
targetNode,
|
||||||
online: onlineMigration,
|
targetCluster: destCluster,
|
||||||
max_downtime: maxDowntime,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
toast.success(`VM ${migrationVM.name} migration started to ${targetNode}`);
|
toast.success(`VM ${migrationVM.name} migration started to ${targetNode}${destCluster !== sourceCluster ? ` (cluster: ${destCluster})` : ''}`);
|
||||||
setMigrationVM(null);
|
setMigrationVM(null);
|
||||||
setTargetNode('');
|
setTargetNode('');
|
||||||
|
setTargetCluster('');
|
||||||
onRefresh?.();
|
onRefresh?.();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to migrate VM:', error);
|
console.error('Failed to migrate VM:', error);
|
||||||
toast.error(`Failed to migrate VM ${migrationVM.name}: ${error}`);
|
toast.error(`Failed to migrate VM ${migrationVM.name}: ${error}`);
|
||||||
}
|
}
|
||||||
}, [migrationVM, targetNode, onlineMigration, maxDowntime, clusterId, onRefresh]);
|
}, [migrationVM, targetNode, targetCluster, clusterId, onRefresh]);
|
||||||
|
|
||||||
const handleClone = useCallback(async (vm: VMInfo) => {
|
const handleClone = useCallback(async (vm: VMInfo) => {
|
||||||
|
if (!clusterId) {
|
||||||
|
toast.error('No cluster selected');
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const nextVmid = Math.max(...vms.map((v) => v.vmid), 100) + 1;
|
const nextVmid = Math.max(...vms.map((v) => v.vmid), 100) + 1;
|
||||||
const newVmidStr = window.prompt(`Enter new VM ID for ${vm.name}:`, `${nextVmid}`);
|
const newVmidStr = window.prompt(`Enter new VM ID for ${vm.name}:`, `${nextVmid}`);
|
||||||
@ -250,7 +259,7 @@ export function VMList({
|
|||||||
toast.info('Clone cancelled');
|
toast.info('Clone cancelled');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await invoke('clone_vm', {
|
await invoke('clone_vm', {
|
||||||
clusterId,
|
clusterId,
|
||||||
nodeId: vm.node,
|
nodeId: vm.node,
|
||||||
@ -258,7 +267,7 @@ export function VMList({
|
|||||||
newVmid,
|
newVmid,
|
||||||
name: newName,
|
name: newName,
|
||||||
});
|
});
|
||||||
|
|
||||||
toast.success(`VM ${vm.name} cloned successfully to VM ${newVmid}`);
|
toast.success(`VM ${vm.name} cloned successfully to VM ${newVmid}`);
|
||||||
onRefresh?.();
|
onRefresh?.();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -268,11 +277,15 @@ 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',
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!confirmed) {
|
if (!confirmed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -283,7 +296,7 @@ export function VMList({
|
|||||||
nodeId: vm.node,
|
nodeId: vm.node,
|
||||||
vmId: vm.vmid,
|
vmId: vm.vmid,
|
||||||
});
|
});
|
||||||
|
|
||||||
toast.success(`VM ${vm.name} deleted successfully`);
|
toast.success(`VM ${vm.name} deleted successfully`);
|
||||||
onRefresh?.();
|
onRefresh?.();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -309,7 +322,7 @@ export function VMList({
|
|||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="w-[40px]">
|
<TableHead className="w-[40px]">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={vms.every((vm) => selectedVMs.has(vm.id))}
|
checked={vms.length > 0 && vms.every((vm) => selectedVMs.has(vm.id))}
|
||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked) => {
|
||||||
if (checked) {
|
if (checked) {
|
||||||
vms.forEach((vm) => selectedVMs.add(vm.id));
|
vms.forEach((vm) => selectedVMs.add(vm.id));
|
||||||
@ -335,7 +348,7 @@ export function VMList({
|
|||||||
const cpuPercent = vm.cpu > 0 ? Math.min(vm.cpu * 100, 100) : 0;
|
const cpuPercent = vm.cpu > 0 ? Math.min(vm.cpu * 100, 100) : 0;
|
||||||
const memoryPercent = vm.memoryTotal > 0 ? (vm.memory / vm.memoryTotal) * 100 : 0;
|
const memoryPercent = vm.memoryTotal > 0 ? (vm.memory / vm.memoryTotal) * 100 : 0;
|
||||||
const diskPercent = vm.diskTotal > 0 ? (vm.disk / vm.diskTotal) * 100 : 0;
|
const diskPercent = vm.diskTotal > 0 ? (vm.disk / vm.diskTotal) * 100 : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow key={vm.id}>
|
<TableRow key={vm.id}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
@ -413,11 +426,15 @@ export function VMList({
|
|||||||
<MigrationDialog
|
<MigrationDialog
|
||||||
vm={migrationVM}
|
vm={migrationVM}
|
||||||
isOpen={!!migrationVM}
|
isOpen={!!migrationVM}
|
||||||
onClose={() => setMigrationVM(null)}
|
onClose={() => { setMigrationVM(null); setTargetNode(''); setTargetCluster(''); }}
|
||||||
onSubmit={submitMigration}
|
onSubmit={submitMigration}
|
||||||
availableNodes={vms}
|
availableNodes={vms}
|
||||||
|
clusters={clusters}
|
||||||
|
currentClusterId={clusterId}
|
||||||
targetNode={targetNode}
|
targetNode={targetNode}
|
||||||
onTargetNodeChange={setTargetNode}
|
onTargetNodeChange={setTargetNode}
|
||||||
|
targetCluster={targetCluster}
|
||||||
|
onTargetClusterChange={setTargetCluster}
|
||||||
online={onlineMigration}
|
online={onlineMigration}
|
||||||
onOnlineChange={setOnlineMigration}
|
onOnlineChange={setOnlineMigration}
|
||||||
maxDowntime={maxDowntime}
|
maxDowntime={maxDowntime}
|
||||||
@ -445,12 +462,13 @@ function VMActionMenu({
|
|||||||
onDelete,
|
onDelete,
|
||||||
}: VMActionMenuProps) {
|
}: VMActionMenuProps) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
const [flipUpward, setFlipUpward] = useState(false);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const menuContentRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Close menu when clicking outside
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleClickOutside(event: MouseEvent) {
|
function handleClickOutside(event: MouseEvent) {
|
||||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -464,6 +482,14 @@ function VMActionMenu({
|
|||||||
};
|
};
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// After the menu renders, check whether it overflows the viewport bottom and flip if needed.
|
||||||
|
// Done in useEffect (not during render) to avoid the react-hooks/refs ESLint violation.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen || !menuContentRef.current) return;
|
||||||
|
const rect = menuContentRef.current.getBoundingClientRect();
|
||||||
|
setFlipUpward(window.innerHeight - rect.bottom < 20);
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
const toggleMenu = (e: React.MouseEvent) => {
|
const toggleMenu = (e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setIsOpen(!isOpen);
|
setIsOpen(!isOpen);
|
||||||
@ -471,45 +497,12 @@ function VMActionMenu({
|
|||||||
|
|
||||||
const handleAction = (action: () => void) => (e: React.MouseEvent) => {
|
const handleAction = (action: () => void) => (e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
action();
|
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
|
action();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Calculate menu position to avoid overflow
|
|
||||||
const getMenuPosition = () => {
|
|
||||||
const viewportHeight = window.innerHeight;
|
|
||||||
const viewportWidth = window.innerWidth;
|
|
||||||
const buttonRect = menuRef.current?.querySelector('button')?.getBoundingClientRect();
|
|
||||||
|
|
||||||
if (!buttonRect) return { top: '100%', left: 0 };
|
|
||||||
|
|
||||||
const menuHeight = 400; // approximate menu height
|
|
||||||
const menuWidth = 192; // approximate menu width (w-48 = 12rem = 192px)
|
|
||||||
const spaceBelow = viewportHeight - buttonRect.bottom;
|
|
||||||
const spaceAbove = buttonRect.top;
|
|
||||||
const spaceRight = viewportWidth - buttonRect.right;
|
|
||||||
|
|
||||||
// Vertical positioning
|
|
||||||
let verticalPos: { top?: string; bottom?: string } = { top: '100%' };
|
|
||||||
if (spaceBelow >= menuHeight) {
|
|
||||||
verticalPos = { top: '100%' };
|
|
||||||
} else if (spaceAbove >= menuHeight) {
|
|
||||||
verticalPos = { bottom: '100%' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Horizontal positioning - account for overflow on the right
|
|
||||||
let horizontalPos: { left?: number; right?: number } = { left: 0 };
|
|
||||||
if (spaceRight < menuWidth) {
|
|
||||||
horizontalPos = { right: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ...verticalPos, ...horizontalPos };
|
|
||||||
};
|
|
||||||
|
|
||||||
const position = getMenuPosition();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative" ref={menuRef}>
|
<div className="relative" ref={containerRef}>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@ -520,16 +513,17 @@ function VMActionMenu({
|
|||||||
</Button>
|
</Button>
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div
|
<div
|
||||||
className={`absolute z-50 w-48 rounded-md border bg-background shadow-md ${
|
ref={menuContentRef}
|
||||||
position.bottom ? 'bottom-full mb-2' : 'top-full mt-2'
|
className={clsx(
|
||||||
} ${position.right ? 'right-0' : ''}`}
|
'absolute right-0 z-50 w-48 rounded-md border bg-background shadow-md',
|
||||||
style={{ left: position.left ?? undefined, right: position.right ?? undefined }}
|
flipUpward ? 'bottom-full mb-2' : 'top-full mt-2',
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<div className="space-y-1 p-1">
|
<div className="space-y-1 p-1">
|
||||||
{vm.status === 'stopped' && (
|
{vm.status === 'stopped' && (
|
||||||
<button
|
<button
|
||||||
className="flex w-full items-center rounded-md px-2 py-1.5 text-sm hover:bg-accent"
|
className="flex w-full items-center rounded-md px-2 py-1.5 text-sm hover:bg-accent"
|
||||||
onClick={() => onVMAction(vm, 'start')}
|
onClick={handleAction(() => onVMAction(vm, 'start'))}
|
||||||
>
|
>
|
||||||
<Play className="mr-2 h-4 w-4" />
|
<Play className="mr-2 h-4 w-4" />
|
||||||
Start
|
Start
|
||||||
@ -539,28 +533,28 @@ function VMActionMenu({
|
|||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
className="flex w-full items-center rounded-md px-2 py-1.5 text-sm hover:bg-accent"
|
className="flex w-full items-center rounded-md px-2 py-1.5 text-sm hover:bg-accent"
|
||||||
onClick={() => onVMAction(vm, 'stop')}
|
onClick={handleAction(() => onVMAction(vm, 'stop'))}
|
||||||
>
|
>
|
||||||
<Square className="mr-2 h-4 w-4" />
|
<Square className="mr-2 h-4 w-4" />
|
||||||
Stop
|
Stop
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="flex w-full items-center rounded-md px-2 py-1.5 text-sm hover:bg-accent"
|
className="flex w-full items-center rounded-md px-2 py-1.5 text-sm hover:bg-accent"
|
||||||
onClick={() => onVMAction(vm, 'reboot')}
|
onClick={handleAction(() => onVMAction(vm, 'reboot'))}
|
||||||
>
|
>
|
||||||
<RotateCcw className="mr-2 h-4 w-4" />
|
<RotateCcw className="mr-2 h-4 w-4" />
|
||||||
Reboot
|
Reboot
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="flex w-full items-center rounded-md px-2 py-1.5 text-sm hover:bg-accent"
|
className="flex w-full items-center rounded-md px-2 py-1.5 text-sm hover:bg-accent"
|
||||||
onClick={() => onVMAction(vm, 'shutdown')}
|
onClick={handleAction(() => onVMAction(vm, 'shutdown'))}
|
||||||
>
|
>
|
||||||
<Power className="mr-2 h-4 w-4" />
|
<Power className="mr-2 h-4 w-4" />
|
||||||
Shutdown
|
Shutdown
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="flex w-full items-center rounded-md px-2 py-1.5 text-sm hover:bg-accent"
|
className="flex w-full items-center rounded-md px-2 py-1.5 text-sm hover:bg-accent"
|
||||||
onClick={() => onVMAction(vm, 'suspend')}
|
onClick={handleAction(() => onVMAction(vm, 'suspend'))}
|
||||||
>
|
>
|
||||||
<Pause className="mr-2 h-4 w-4" />
|
<Pause className="mr-2 h-4 w-4" />
|
||||||
Suspend
|
Suspend
|
||||||
@ -570,7 +564,7 @@ function VMActionMenu({
|
|||||||
{vm.status === 'paused' && (
|
{vm.status === 'paused' && (
|
||||||
<button
|
<button
|
||||||
className="flex w-full items-center rounded-md px-2 py-1.5 text-sm hover:bg-accent"
|
className="flex w-full items-center rounded-md px-2 py-1.5 text-sm hover:bg-accent"
|
||||||
onClick={() => onVMAction(vm, 'resume')}
|
onClick={handleAction(() => onVMAction(vm, 'resume'))}
|
||||||
>
|
>
|
||||||
<PlayCircle className="mr-2 h-4 w-4" />
|
<PlayCircle className="mr-2 h-4 w-4" />
|
||||||
Resume
|
Resume
|
||||||
@ -579,27 +573,27 @@ function VMActionMenu({
|
|||||||
<div className="h-px bg-border my-1" />
|
<div className="h-px bg-border my-1" />
|
||||||
<button
|
<button
|
||||||
className="flex w-full items-center rounded-md px-2 py-1.5 text-sm hover:bg-accent"
|
className="flex w-full items-center rounded-md px-2 py-1.5 text-sm hover:bg-accent"
|
||||||
onClick={() => onSnapshotAction(vm, 'create')}
|
onClick={handleAction(() => onSnapshotAction(vm, 'create'))}
|
||||||
>
|
>
|
||||||
📸 Create Snapshot
|
📸 Create Snapshot
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="flex w-full items-center rounded-md px-2 py-1.5 text-sm hover:bg-accent"
|
className="flex w-full items-center rounded-md px-2 py-1.5 text-sm hover:bg-accent"
|
||||||
onClick={() => onSnapshotAction(vm, 'list')}
|
onClick={handleAction(() => onSnapshotAction(vm, 'list'))}
|
||||||
>
|
>
|
||||||
📋 List Snapshots
|
📋 List Snapshots
|
||||||
</button>
|
</button>
|
||||||
<div className="h-px bg-border my-1" />
|
<div className="h-px bg-border my-1" />
|
||||||
<button
|
<button
|
||||||
className="flex w-full items-center rounded-md px-2 py-1.5 text-sm hover:bg-accent"
|
className="flex w-full items-center rounded-md px-2 py-1.5 text-sm hover:bg-accent"
|
||||||
onClick={() => onMigrate(vm)}
|
onClick={handleAction(() => onMigrate(vm))}
|
||||||
>
|
>
|
||||||
<MoveRight className="mr-2 h-4 w-4" />
|
<MoveRight className="mr-2 h-4 w-4" />
|
||||||
Migrate
|
Migrate
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="flex w-full items-center rounded-md px-2 py-1.5 text-sm hover:bg-accent"
|
className="flex w-full items-center rounded-md px-2 py-1.5 text-sm hover:bg-accent"
|
||||||
onClick={() => onClone(vm)}
|
onClick={handleAction(() => onClone(vm))}
|
||||||
>
|
>
|
||||||
<Copy className="mr-2 h-4 w-4" />
|
<Copy className="mr-2 h-4 w-4" />
|
||||||
Clone
|
Clone
|
||||||
@ -607,7 +601,7 @@ function VMActionMenu({
|
|||||||
<div className="h-px bg-border my-1" />
|
<div className="h-px bg-border my-1" />
|
||||||
<button
|
<button
|
||||||
className="flex w-full items-center rounded-md px-2 py-1.5 text-sm hover:bg-red-100 hover:text-red-600"
|
className="flex w-full items-center rounded-md px-2 py-1.5 text-sm hover:bg-red-100 hover:text-red-600"
|
||||||
onClick={() => onDelete(vm)}
|
onClick={handleAction(() => onDelete(vm))}
|
||||||
>
|
>
|
||||||
<X className="mr-2 h-4 w-4" />
|
<X className="mr-2 h-4 w-4" />
|
||||||
Delete
|
Delete
|
||||||
@ -625,8 +619,12 @@ interface MigrationDialogProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSubmit: () => void;
|
onSubmit: () => void;
|
||||||
availableNodes: VMInfo[];
|
availableNodes: VMInfo[];
|
||||||
|
clusters: ClusterInfo[];
|
||||||
|
currentClusterId: string;
|
||||||
targetNode: string;
|
targetNode: string;
|
||||||
onTargetNodeChange: (node: string) => void;
|
onTargetNodeChange: (node: string) => void;
|
||||||
|
targetCluster: string;
|
||||||
|
onTargetClusterChange: (clusterId: string) => void;
|
||||||
online: boolean;
|
online: boolean;
|
||||||
onOnlineChange: (online: boolean) => void;
|
onOnlineChange: (online: boolean) => void;
|
||||||
maxDowntime: number;
|
maxDowntime: number;
|
||||||
@ -639,8 +637,12 @@ function MigrationDialog({
|
|||||||
onClose,
|
onClose,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
availableNodes,
|
availableNodes,
|
||||||
|
clusters,
|
||||||
|
currentClusterId,
|
||||||
targetNode,
|
targetNode,
|
||||||
onTargetNodeChange,
|
onTargetNodeChange,
|
||||||
|
targetCluster,
|
||||||
|
onTargetClusterChange,
|
||||||
online,
|
online,
|
||||||
onOnlineChange,
|
onOnlineChange,
|
||||||
maxDowntime,
|
maxDowntime,
|
||||||
@ -648,10 +650,18 @@ function MigrationDialog({
|
|||||||
}: MigrationDialogProps) {
|
}: MigrationDialogProps) {
|
||||||
if (!vm) return null;
|
if (!vm) return null;
|
||||||
|
|
||||||
|
const isCrossCluster = targetCluster && targetCluster !== currentClusterId;
|
||||||
|
|
||||||
const availableTargets = availableNodes
|
const availableTargets = availableNodes
|
||||||
.map((v) => v.node)
|
.map((v) => v.node)
|
||||||
.filter((node, index, self) => self.indexOf(node) === index && node !== vm.node);
|
.filter((node, index, self) => self.indexOf(node) === index && node !== vm.node);
|
||||||
|
|
||||||
|
const canSubmitMigration = () => {
|
||||||
|
if (!targetNode) return false;
|
||||||
|
if (isCrossCluster) return true;
|
||||||
|
return availableTargets.length > 0;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
@ -665,25 +675,64 @@ function MigrationDialog({
|
|||||||
Live migration requires the same hardware configuration on both nodes. Ensure storage is accessible from both nodes.
|
Live migration requires the same hardware configuration on both nodes. Ensure storage is accessible from both nodes.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
|
{clusters.length > 1 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="targetCluster">Target Remote</Label>
|
||||||
|
<Select value={targetCluster || currentClusterId} onValueChange={onTargetClusterChange}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select target cluster" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{clusters.map((c) => (
|
||||||
|
<SelectItem key={c.id} value={c.id}>
|
||||||
|
{c.name}{c.id === currentClusterId ? ' (current)' : ''}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{isCrossCluster && (
|
||||||
|
<p className="text-xs text-amber-600">
|
||||||
|
Cross-cluster migration — VM will be moved to a different datacenter.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="targetNode">Target Node</Label>
|
<Label htmlFor="targetNode">Target Node</Label>
|
||||||
<Select value={targetNode} onValueChange={onTargetNodeChange}>
|
{isCrossCluster ? (
|
||||||
<SelectTrigger>
|
<>
|
||||||
<SelectValue placeholder="Select target node" />
|
<Input
|
||||||
</SelectTrigger>
|
id="targetNode"
|
||||||
<SelectContent>
|
value={targetNode}
|
||||||
{availableTargets.map((node) => (
|
onChange={(e) => onTargetNodeChange(e.target.value)}
|
||||||
<SelectItem key={node} value={node}>
|
placeholder="Enter target node name"
|
||||||
{node}
|
/>
|
||||||
</SelectItem>
|
<p className="text-xs text-muted-foreground">
|
||||||
))}
|
Enter the node name on the destination cluster
|
||||||
</SelectContent>
|
</p>
|
||||||
</Select>
|
</>
|
||||||
{availableTargets.length === 0 && (
|
) : (
|
||||||
<p className="text-xs text-muted-foreground">
|
<>
|
||||||
No other nodes available for migration
|
<Select value={targetNode} onValueChange={onTargetNodeChange}>
|
||||||
</p>
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select target node" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{availableTargets.map((node) => (
|
||||||
|
<SelectItem key={node} value={node}>
|
||||||
|
{node}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{availableTargets.length === 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
No other nodes available in this cluster
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -722,7 +771,10 @@ function MigrationDialog({
|
|||||||
<Button variant="outline" onClick={onClose}>
|
<Button variant="outline" onClick={onClose}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={onSubmit} disabled={!targetNode || availableTargets.length === 0}>
|
<Button
|
||||||
|
onClick={onSubmit}
|
||||||
|
disabled={!canSubmitMigration()}
|
||||||
|
>
|
||||||
Start Migration
|
Start Migration
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@ -8,7 +8,6 @@ import { toast } from 'sonner';
|
|||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/index';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/index';
|
||||||
import { Input } from '@/components/ui/index';
|
import { Input } from '@/components/ui/index';
|
||||||
import { Label } from '@/components/ui/index';
|
import { Label } from '@/components/ui/index';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/index';
|
|
||||||
|
|
||||||
export function ProxmoxBackupPage() {
|
export function ProxmoxBackupPage() {
|
||||||
const [clusters, setClusters] = useState<ClusterInfo[]>([]);
|
const [clusters, setClusters] = useState<ClusterInfo[]>([]);
|
||||||
@ -40,8 +39,34 @@ export function ProxmoxBackupPage() {
|
|||||||
if (!clusterId) return;
|
if (!clusterId) return;
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await listProxmoxBackupJobs(clusterId, '');
|
const raw = await listProxmoxBackupJobs(clusterId, '');
|
||||||
setJobs(data);
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const normalized = (raw as any[]).map((job) => {
|
||||||
|
const enabledRaw = job.enabled ?? job.enable ?? 1;
|
||||||
|
const isEnabled = enabledRaw === 1 || enabledRaw === true || enabledRaw === '1';
|
||||||
|
const nextRunRaw = job['next-run'] ?? job.next_run ?? job.nextRun;
|
||||||
|
const nextRunStr = nextRunRaw
|
||||||
|
? new Date(Number(nextRunRaw) * 1000).toLocaleString()
|
||||||
|
: undefined;
|
||||||
|
return {
|
||||||
|
id: job.id || String(job.jobid || ''),
|
||||||
|
name: job.id || job.comment || `job-${job.jobid || '?'}`,
|
||||||
|
node: job.node || 'all',
|
||||||
|
schedule: job.schedule || '-',
|
||||||
|
status: isEnabled ? ('idle' as const) : ('idle' as const),
|
||||||
|
lastRun: undefined,
|
||||||
|
nextRun: nextRunStr,
|
||||||
|
size: undefined,
|
||||||
|
count: undefined,
|
||||||
|
enabled: isEnabled,
|
||||||
|
vmid: job.vmid,
|
||||||
|
storage: job.storage,
|
||||||
|
mode: job.mode,
|
||||||
|
compress: job.compress,
|
||||||
|
comment: job.comment,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setJobs(normalized);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load backup jobs:', err);
|
console.error('Failed to load backup jobs:', err);
|
||||||
toast.error('Failed to load backup jobs');
|
toast.error('Failed to load backup jobs');
|
||||||
@ -55,26 +80,14 @@ export function ProxmoxBackupPage() {
|
|||||||
}, [selectedClusterId, loadJobs]);
|
}, [selectedClusterId, loadJobs]);
|
||||||
|
|
||||||
const handleNewJob = () => {
|
const handleNewJob = () => {
|
||||||
setJobName('');
|
toast.warning(
|
||||||
setJobNode('');
|
'Backup job creation requires additional backend implementation (POST cluster/backup) and is not yet available.',
|
||||||
setJobSchedule('');
|
);
|
||||||
setJobVms('');
|
|
||||||
setShowNewJobDialog(true);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmitNewJob = async () => {
|
const handleSubmitNewJob = async () => {
|
||||||
if (!jobName || !jobNode || !jobSchedule) {
|
toast.warning('Backup job creation is not yet available.');
|
||||||
toast.error('Job name, node, and schedule are required');
|
setShowNewJobDialog(false);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
toast.info(`Creating backup job ${jobName} - implementation pending`);
|
|
||||||
setShowNewJobDialog(false);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to create backup job:', error);
|
|
||||||
toast.error(`Failed to create backup job: ${error}`);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (clusters.length === 0 && !isLoading) {
|
if (clusters.length === 0 && !isLoading) {
|
||||||
|
|||||||
@ -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() {
|
||||||
@ -16,7 +17,7 @@ export function ProxmoxNetworkPage() {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [showAddDialog, setShowAddDialog] = useState(false);
|
const [showAddDialog, setShowAddDialog] = useState(false);
|
||||||
const [editingInterface, setEditingInterface] = useState<NetworkInterface | null>(null);
|
const [editingInterface] = useState<NetworkInterface | null>(null);
|
||||||
|
|
||||||
// Form state
|
// Form state
|
||||||
const [ifaceName, setIfaceName] = useState('');
|
const [ifaceName, setIfaceName] = useState('');
|
||||||
@ -51,58 +52,24 @@ export function ProxmoxNetworkPage() {
|
|||||||
.catch(console.error);
|
.catch(console.error);
|
||||||
}, [loadInterfaces, nodeId]);
|
}, [loadInterfaces, nodeId]);
|
||||||
|
|
||||||
|
const NOT_IMPLEMENTED_MSG =
|
||||||
|
'Network interface management requires additional backend implementation (POST/PUT/DELETE nodes/{node}/network) and is not yet available.';
|
||||||
|
|
||||||
const handleAddInterface = () => {
|
const handleAddInterface = () => {
|
||||||
setEditingInterface(null);
|
toast.warning(NOT_IMPLEMENTED_MSG);
|
||||||
setIfaceName('');
|
|
||||||
setIfaceType('eth');
|
|
||||||
setAddress('');
|
|
||||||
setNetmask('');
|
|
||||||
setGateway('');
|
|
||||||
setActive(true);
|
|
||||||
setShowAddDialog(true);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEditInterface = (iface: NetworkInterface) => {
|
const handleEditInterface = (_iface: NetworkInterface) => {
|
||||||
setEditingInterface(iface);
|
toast.warning(NOT_IMPLEMENTED_MSG);
|
||||||
setIfaceName(iface.iface);
|
|
||||||
setIfaceType(iface.type);
|
|
||||||
setAddress(iface.address || '');
|
|
||||||
setNetmask(iface.netmask || '');
|
|
||||||
setGateway(iface.gateway || '');
|
|
||||||
setActive(iface.active);
|
|
||||||
setShowAddDialog(true);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!ifaceName || !ifaceType) {
|
toast.warning(NOT_IMPLEMENTED_MSG);
|
||||||
toast.error('Interface name and type are required');
|
setShowAddDialog(false);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (editingInterface) {
|
|
||||||
toast.info(`Updating interface ${ifaceName} - implementation pending`);
|
|
||||||
} else {
|
|
||||||
toast.info(`Creating interface ${ifaceName} - implementation pending`);
|
|
||||||
}
|
|
||||||
setShowAddDialog(false);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to save interface:', error);
|
|
||||||
toast.error(`Failed to save interface: ${error}`);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteInterface = async (iface: NetworkInterface) => {
|
const handleDeleteInterface = async (_iface: NetworkInterface) => {
|
||||||
if (!confirm(`Are you sure you want to delete interface ${iface.iface}?`)) {
|
toast.warning(NOT_IMPLEMENTED_MSG);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
toast.info(`Deleting interface ${iface.iface} - implementation pending`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to delete interface:', error);
|
|
||||||
toast.error(`Failed to delete interface: ${error}`);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -216,12 +183,21 @@ export function ProxmoxNetworkPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="type">Interface Type</Label>
|
<Label htmlFor="type">Interface Type</Label>
|
||||||
<Input
|
<Select value={ifaceType} onValueChange={setIfaceType}>
|
||||||
id="type"
|
<SelectTrigger>
|
||||||
value={ifaceType}
|
<SelectValue placeholder="Select interface type" />
|
||||||
onChange={(e) => setIfaceType(e.target.value)}
|
</SelectTrigger>
|
||||||
placeholder="eth, bond, bridge, vlan"
|
<SelectContent>
|
||||||
/>
|
<SelectItem value="eth">eth — Ethernet</SelectItem>
|
||||||
|
<SelectItem value="bond">bond — Network Bond</SelectItem>
|
||||||
|
<SelectItem value="bridge">bridge — Linux Bridge</SelectItem>
|
||||||
|
<SelectItem value="vlan">vlan — VLAN</SelectItem>
|
||||||
|
<SelectItem value="OVSBridge">OVSBridge — Open vSwitch Bridge</SelectItem>
|
||||||
|
<SelectItem value="OVSBond">OVSBond — Open vSwitch Bond</SelectItem>
|
||||||
|
<SelectItem value="OVSIntPort">OVSIntPort — OVS Internal Port</SelectItem>
|
||||||
|
<SelectItem value="OVSPort">OVSPort — OVS Port</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="address">IP Address</Label>
|
<Label htmlFor="address">IP Address</Label>
|
||||||
|
|||||||
@ -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) => {
|
||||||
|
|||||||
79
tickets/fix-proxmox-and-ai-issues.md
Normal file
79
tickets/fix-proxmox-and-ai-issues.md
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
# Fix: Proxmox Dashboard and AI Chat Issues
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
Resolved 7 bugs across the Proxmox management dashboard and the AI triage chat that rendered several features non-functional. Issues were verified against a live Proxmox VE host at `172.0.0.18`.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [x] VM action menu items (start, stop, reboot, shutdown, suspend, resume, migrate, clone, delete) execute and close the menu
|
||||||
|
- [x] Migration dialog presents a "Target Remote" dropdown listing all configured clusters for cross-datacenter migration
|
||||||
|
- [x] Storage page displays actual storage from PVE (cluster-wide, with correct size/used/available figures)
|
||||||
|
- [x] Network interface "Type" field is a dropdown of all valid PVE interface types, not a free-text input
|
||||||
|
- [x] Firewall "New Rule" button opens a dialog with action/protocol/source/dest/port fields that submits to PVE
|
||||||
|
- [x] Backup page renders all backup jobs configured in PVE with their storage target, schedule, VM list, and next-run time
|
||||||
|
- [x] AI chat with Qwen 3.5 (via LiteLLM) no longer throws `BadRequestError: System message must be at the beginning`
|
||||||
|
- [x] `cargo check`, `cargo clippy -D warnings`, `tsc --noEmit`, `eslint --max-warnings 0`, all Rust tests (432), all frontend tests (386) pass
|
||||||
|
|
||||||
|
## Work Implemented
|
||||||
|
|
||||||
|
### Issue 1 — VM Actions: Tauri param name mismatch + menu state
|
||||||
|
|
||||||
|
**Root cause**: `VMList` internally re-fetched cluster data on its own, causing a race condition; all VM action Tauri commands used `node: String` but the frontend sent `nodeId` (Tauri 2.x maps camelCase `nodeId` → snake_case `node_id`, so no value arrived); action menu buttons had no `onClick` handlers.
|
||||||
|
|
||||||
|
**Fix**:
|
||||||
|
- `src/pages/Proxmox/VMsPage.tsx`: pass `clusterId` and `clusters` props to `<VMList>`.
|
||||||
|
- `src/components/Proxmox/VMList.tsx`: accept those props, remove internal cluster `useEffect`, wire all action buttons through `handleAction()` wrapper (closes menu, stops propagation).
|
||||||
|
- `src-tauri/src/commands/proxmox.rs`: renamed `node: String` → `node_id: String` (and body usages) in `get_proxmox_vm`, `start_proxmox_vm`, `stop_proxmox_vm`, `reboot_proxmox_vm`, `shutdown_proxmox_vm`, `resume_proxmox_vm`, `suspend_proxmox_vm`, `clone_vm`, `delete_vm`.
|
||||||
|
|
||||||
|
### Issue 2 — Migration: no Target Remote option
|
||||||
|
|
||||||
|
**Fix**: `VMList.tsx` `MigrationDialog` now receives `clusters` and `currentClusterId` props; when more than one cluster is configured a "Target Remote" `<Select>` appears. Selecting a different cluster switches the node input to free-text (cross-cluster node names can't be enumerated locally). `submitMigration` passes `targetCluster` to `migrate_vm`. Rust `migrate_vm` had `node: String` renamed to `node_id: String` as part of the bulk rename above.
|
||||||
|
|
||||||
|
### Issue 3 — Storage: fields mismatch, empty display
|
||||||
|
|
||||||
|
**Root cause**: `list_proxmox_datastores` made N per-node requests and returned raw PVE fields (`avail`, `total`, `plugintype`); `StorageList` expected (`available`, `size`, `type`).
|
||||||
|
|
||||||
|
**Fix** (`src-tauri/src/commands/proxmox.rs`): replaced the N-request per-node loop with a single `cluster/resources?type=storage` call. Response normalization maps `plugintype` → `type`, `disk`/`maxdisk` → `used`/`size`, computes `available = maxdisk.saturating_sub(disk)`.
|
||||||
|
|
||||||
|
### Issue 4 — Network: Interface Type free-text
|
||||||
|
|
||||||
|
**Fix** (`src/pages/Proxmox/NetworkPage.tsx`): replaced `<Input>` with a `<Select>` listing all PVE network interface types: `eth`, `bond`, `bridge`, `vlan`, `OVSBridge`, `OVSBond`, `OVSIntPort`, `OVSPort`.
|
||||||
|
|
||||||
|
### Issue 5 — Firewall: "New Rule" button did nothing
|
||||||
|
|
||||||
|
**Root cause**: Button had no `onClick`. `FirewallRuleListProps` had no `onNewRule` prop.
|
||||||
|
|
||||||
|
**Fix**:
|
||||||
|
- `src/components/Proxmox/FirewallRuleList.tsx`: added `onNewRule?: () => void` to props interface; wired button.
|
||||||
|
- `src/pages/Proxmox/FirewallPage.tsx`: added full new-rule dialog (action, protocol, source, dest, dport, comment fields); calls `addFirewallRule(clusterId, nodeId, ruleObject)` on submit; refreshes list.
|
||||||
|
- `src-tauri/src/commands/proxmox.rs` `add_firewall_rule`: rewrote signature from 6 flat params to `rule: serde_json::Value` (matching what the frontend sends as a single object) plus `node_id: String` rename.
|
||||||
|
|
||||||
|
### Issue 6 — Backup: empty display
|
||||||
|
|
||||||
|
**Root cause**: PVE `cluster/backup` returns `{ id, storage, schedule, enabled, next-run }` but `BackupJobInfo` expected `{ name, node, status, lastRun, nextRun }` — no fields matched.
|
||||||
|
|
||||||
|
**Fix**:
|
||||||
|
- `src/pages/Proxmox/BackupPage.tsx`: added normalizer that maps `id` → `name`, derives `enabled` (0/1/bool), converts `next-run` unix timestamp to locale string.
|
||||||
|
- `src/components/Proxmox/BackupJobList.tsx`: added `storage`, `vmid`, `mode`, `comment` optional fields to interface; updated table columns to show ID, Storage, VMs, Node, Schedule, Enabled, Next Run, Mode.
|
||||||
|
|
||||||
|
### Issue 7 — AI chat: system message ordering / Qwen 3.5 rejection
|
||||||
|
|
||||||
|
**Root cause**:
|
||||||
|
1. `chat_message` in `src-tauri/src/commands/ai.rs` pushed 4–5 consecutive `system` role messages before history. Qwen 3.5 (and LiteLLM's OpenAI compatibility layer) rejects anything but a single system message at position 0.
|
||||||
|
2. The tool-calling loop pushed `tool` role messages without first emitting the assistant message that contains `tool_calls` — violating the OpenAI API contract.
|
||||||
|
|
||||||
|
**Fix**:
|
||||||
|
1. All system prompt sections (agent prompt, domain prompt, tool instructions, integration context) are now collected into a `Vec<String>` and merged with `"\n\n---\n\n"` into a single `Message { role: "system" }` before history.
|
||||||
|
2. When tool calls are present, the assistant's response (with `tool_calls` populated) is pushed to the message history before any tool result messages.
|
||||||
|
|
||||||
|
## Testing Needed
|
||||||
|
|
||||||
|
- [ ] Start the app against Proxmox host `172.0.0.18`; verify all VM action menu items execute on a running VM
|
||||||
|
- [ ] Trigger a migration from one node to another (same cluster); verify the dialog lists nodes and submits
|
||||||
|
- [ ] If multiple clusters configured: verify "Target Remote" dropdown appears in Migration dialog
|
||||||
|
- [ ] Navigate to Storage page; verify all storage volumes appear with correct used/total/available figures
|
||||||
|
- [ ] Open Network → Add Interface; verify Type field is a dropdown with all interface types
|
||||||
|
- [ ] Open Firewall → New Rule; fill in action/protocol/port; verify rule is created in PVE
|
||||||
|
- [ ] Open Backup page; verify backup jobs configured in PVE appear with storage target and next-run time
|
||||||
|
- [ ] Start an AI chat session using a Qwen 3.5 model via LiteLLM; verify no `BadRequestError` and tool calls work correctly
|
||||||
Loading…
Reference in New Issue
Block a user