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

Merged
sarman merged 6 commits from fix/proxmox-issues-v2 into beta 2026-06-21 21:35:29 +00:00
9 changed files with 528 additions and 298 deletions
Showing only changes of commit 577512562b - Show all commits

View File

@ -351,37 +351,10 @@ pub async fn chat_message(
let agent_registry = create_agent_registry();
let devops_agent = agent_registry.get("devops-incident-responder");
// CRITICAL: Build messages array with ALL system messages FIRST, then history, then user message
// This ensures system messages are always at the beginning as required by most LLM APIs
let mut messages = Vec::new();
// 1. Inject devops-incident-responder as primary system prompt (always first)
if let Some(agent) = devops_agent {
messages.push(Message {
role: "system".into(),
content: agent.system_prompt.clone(),
tool_call_id: None,
tool_calls: None,
});
}
// 2. Inject domain system prompt if provided (second position)
if let Some(ref prompt) = system_prompt {
if !prompt.is_empty() {
messages.push(Message {
role: "system".into(),
content: prompt.clone(),
tool_call_id: None,
tool_calls: None,
});
}
}
// Tool execution configuration
const MAX_TOOL_ITERATIONS: usize = 20; // Allow sufficient iterations for complex diagnostics
const MAX_TOOL_ITERATIONS: usize = 20;
// Get available tools — static + MCP
// Only enable tools if the provider explicitly supports tool calling
let tools = if provider_config.supports_tool_calling.unwrap_or(false) {
let mut all_tools = crate::ai::tools::get_available_tools();
let mcp_tools = crate::ai::tools::get_enabled_mcp_tools(&state).await;
@ -395,9 +368,6 @@ pub async fn chat_message(
None
};
// If tools are available AND using OpenAI-compatible provider, add explicit JSON format instruction
// Only OpenAI-compatible providers (default case in create_provider) actually support tool calling.
// Others (anthropic, gemini, mistral, ollama) either ignore tools or use provider-specific formats.
let is_openai_compatible = {
let kind = if provider_config.provider_type.is_empty() {
provider_config.name.as_str()
@ -407,59 +377,71 @@ pub async fn chat_message(
!matches!(kind, "anthropic" | "gemini" | "mistral" | "ollama")
};
// 3. Tool-calling system messages — must come BEFORE history so all system messages are contiguous
// Collect all system prompt parts and merge into ONE message to satisfy providers
// like Qwen 3.5 that reject multiple consecutive system messages.
let mut system_parts: Vec<String> = Vec::new();
if let Some(agent) = devops_agent {
system_parts.push(agent.system_prompt.clone());
}
if let Some(ref prompt) = system_prompt {
if !prompt.is_empty() {
system_parts.push(prompt.clone());
}
}
if tools.is_some() && is_openai_compatible {
messages.push(Message {
role: "system".into(),
content: "CRITICAL: You have tools available. When calling tools, you MUST use the native JSON function calling format in your API response. DO NOT output XML tags like <tool_name>. DO NOT output text descriptions of tool calls. Use the structured tool_calls field in your response.".into(),
tool_call_id: None,
tool_calls: None,
});
messages.push(Message {
role: "system".into(),
content: format!(
"TOOL EXECUTION BUDGET: You have a maximum of {MAX_TOOL_ITERATIONS} rounds (each AI response counts as one round). \
You can call multiple tools in a single round. \
Plan your investigation efficiently:\n\
- Call multiple related tools in the same round when possible\n\
- Prioritize high-value diagnostic commands first\n\
- Use comprehensive output formats (e.g., kubectl --output=yaml) to gather more data per call\n\
- Reserve 1 round for your final summary/answer\n\
- If you exceed the budget, you'll be cut off mid-investigation\n\
Current round count is not visible to you, so plan conservatively."
),
tool_call_id: None,
tool_calls: None,
});
system_parts.push(
"CRITICAL: You have tools available. When calling tools, you MUST use the native \
JSON function calling format in your API response. DO NOT output XML tags like \
<tool_name>. DO NOT output text descriptions of tool calls. Use the structured \
tool_calls field in your response."
.to_string(),
);
system_parts.push(format!(
"TOOL EXECUTION BUDGET: You have a maximum of {MAX_TOOL_ITERATIONS} rounds (each \
AI response counts as one round). You can call multiple tools in a single round. \
Plan your investigation efficiently:\n\
- Call multiple related tools in the same round when possible\n\
- Prioritize high-value diagnostic commands first\n\
- Use comprehensive output formats (e.g., kubectl --output=yaml) to gather more data per call\n\
- Reserve 1 round for your final summary/answer\n\
- If you exceed the budget, you'll be cut off mid-investigation\n\
Current round count is not visible to you, so plan conservatively."
));
}
// 4. Integration context as system message — still before history
if !integration_context.is_empty() {
system_parts.push(format!(
"INTERNAL DOCUMENTATION SOURCES:\n\n{integration_context}\n\n\
Instructions: The above content is from internal company documentation systems \
(Confluence, ServiceNow, Azure DevOps). \
\n\n**IMPORTANT**: First determine if this documentation is RELEVANT to the user's question:\
\n- If the documentation directly addresses the question Use it and cite sources with URLs\
\n- If the documentation is tangentially related but doesn't answer the question Briefly mention what internal docs exist, then provide a complete answer using general knowledge\
\n- If the documentation is completely unrelated Ignore it and answer using general knowledge\
\n\nDo NOT force irrelevant internal documentation into your answer. The user needs accurate information, not forced citations."
));
}
let mut messages = Vec::new();
if !system_parts.is_empty() {
messages.push(Message {
role: "system".into(),
content: format!(
"INTERNAL DOCUMENTATION SOURCES:\n\n{integration_context}\n\n\
Instructions: The above content is from internal company documentation systems \
(Confluence, ServiceNow, Azure DevOps). \
\n\n**IMPORTANT**: First determine if this documentation is RELEVANT to the user's question:\
\n- If the documentation directly addresses the question Use it and cite sources with URLs\
\n- If the documentation is tangentially related but doesn't answer the question Briefly mention what internal docs exist, then provide a complete answer using general knowledge\
\n- If the documentation is completely unrelated Ignore it and answer using general knowledge\
\n\nDo NOT force irrelevant internal documentation into your answer. The user needs accurate information, not forced citations."
),
content: system_parts.join("\n\n---\n\n"),
tool_call_id: None,
tool_calls: None,
});
}
// 5. Filter out any system messages from history to avoid duplicates and maintain order
// Filter system messages from history (already merged above) and append
let filtered_history: Vec<Message> = history
.into_iter()
.filter(|msg| msg.role != "system")
.collect();
// 6. Add filtered history (user/assistant messages only) — all system messages are already above
messages.extend(filtered_history);
messages.push(Message {
@ -541,19 +523,25 @@ pub async fn chat_message(
if let Some(tool_calls) = &response.tool_calls {
tracing::info!("AI requested {} tool call(s)", tool_calls.len());
// Execute each tool call
// OpenAI API contract: push the assistant message WITH tool_calls BEFORE any tool results
messages.push(Message {
role: "assistant".into(),
content: response.content.clone(),
tool_call_id: None,
tool_calls: Some(tool_calls.clone()),
});
// Execute each tool call and append result messages
for tool_call in tool_calls {
tracing::info!("Executing tool: {}", tool_call.name);
let tool_result = execute_tool_call(tool_call, &app_handle, &state).await;
// Format result
let result_content = match tool_result {
Ok(result) => result,
Err(e) => format!("Error executing tool: {e}"),
};
// Add tool result as a message
messages.push(Message {
role: "tool".into(),
content: result_content,

View File

@ -432,7 +432,7 @@ pub async fn list_proxmox_vms(
#[tauri::command]
pub async fn get_proxmox_vm(
cluster_id: String,
node: String,
node_id: String,
vm_id: u32,
state: State<'_, AppState>,
) -> Result<serde_json::Value, String> {
@ -441,7 +441,7 @@ pub async fn get_proxmox_vm(
let vm = crate::proxmox::vm::get_vm(
&client_guard,
&node,
&node_id,
vm_id,
client_guard.ticket.as_deref().unwrap_or(""),
)
@ -455,7 +455,7 @@ pub async fn get_proxmox_vm(
#[tauri::command]
pub async fn start_proxmox_vm(
cluster_id: String,
node: String,
node_id: String,
vm_id: u32,
state: State<'_, AppState>,
) -> Result<(), String> {
@ -464,7 +464,7 @@ pub async fn start_proxmox_vm(
crate::proxmox::vm::start_vm(
&client_guard,
&node,
&node_id,
vm_id,
client_guard.ticket.as_deref().unwrap_or(""),
)
@ -476,7 +476,7 @@ pub async fn start_proxmox_vm(
#[tauri::command]
pub async fn stop_proxmox_vm(
cluster_id: String,
node: String,
node_id: String,
vm_id: u32,
state: State<'_, AppState>,
) -> Result<(), String> {
@ -485,7 +485,7 @@ pub async fn stop_proxmox_vm(
crate::proxmox::vm::stop_vm(
&client_guard,
&node,
&node_id,
vm_id,
client_guard.ticket.as_deref().unwrap_or(""),
)
@ -497,7 +497,7 @@ pub async fn stop_proxmox_vm(
#[tauri::command]
pub async fn reboot_proxmox_vm(
cluster_id: String,
node: String,
node_id: String,
vm_id: u32,
state: State<'_, AppState>,
) -> Result<(), String> {
@ -506,7 +506,7 @@ pub async fn reboot_proxmox_vm(
crate::proxmox::vm::reboot_vm(
&client_guard,
&node,
&node_id,
vm_id,
client_guard.ticket.as_deref().unwrap_or(""),
)
@ -518,7 +518,7 @@ pub async fn reboot_proxmox_vm(
#[tauri::command]
pub async fn shutdown_proxmox_vm(
cluster_id: String,
node: String,
node_id: String,
vm_id: u32,
state: State<'_, AppState>,
) -> Result<(), String> {
@ -527,7 +527,7 @@ pub async fn shutdown_proxmox_vm(
crate::proxmox::vm::shutdown_vm(
&client_guard,
&node,
&node_id,
vm_id,
client_guard.ticket.as_deref().unwrap_or(""),
)
@ -539,7 +539,7 @@ pub async fn shutdown_proxmox_vm(
#[tauri::command]
pub async fn resume_proxmox_vm(
cluster_id: String,
node: String,
node_id: String,
vm_id: u32,
state: State<'_, AppState>,
) -> Result<(), String> {
@ -548,7 +548,7 @@ pub async fn resume_proxmox_vm(
crate::proxmox::vm::resume_vm(
&client_guard,
&node,
&node_id,
vm_id,
client_guard.ticket.as_deref().unwrap_or(""),
)
@ -560,7 +560,7 @@ pub async fn resume_proxmox_vm(
#[tauri::command]
pub async fn suspend_proxmox_vm(
cluster_id: String,
node: String,
node_id: String,
vm_id: u32,
state: State<'_, AppState>,
) -> Result<(), String> {
@ -569,7 +569,7 @@ pub async fn suspend_proxmox_vm(
crate::proxmox::vm::suspend_vm(
&client_guard,
&node,
&node_id,
vm_id,
client_guard.ticket.as_deref().unwrap_or(""),
)
@ -581,7 +581,7 @@ pub async fn suspend_proxmox_vm(
#[tauri::command]
pub async fn clone_vm(
cluster_id: String,
node: String,
node_id: String,
vm_id: u32,
new_vmid: u32,
name: String,
@ -592,7 +592,7 @@ pub async fn clone_vm(
crate::proxmox::vm::clone_vm(
&client_guard,
&node,
&node_id,
vm_id,
new_vmid,
&name,
@ -606,7 +606,7 @@ pub async fn clone_vm(
#[tauri::command]
pub async fn delete_vm(
cluster_id: String,
node: String,
node_id: String,
vm_id: u32,
state: State<'_, AppState>,
) -> Result<(), String> {
@ -615,7 +615,7 @@ pub async fn delete_vm(
crate::proxmox::vm::delete_vm(
&client_guard,
&node,
&node_id,
vm_id,
client_guard.ticket.as_deref().unwrap_or(""),
)
@ -681,74 +681,99 @@ pub async fn list_proxmox_backup_jobs(
Ok(jobs)
}
/// List Proxmox Datastores (Storage per node)
/// List Proxmox Datastores (cluster-wide via cluster/resources)
#[tauri::command]
pub async fn list_proxmox_datastores(
cluster_id: String,
_state: State<'_, AppState>,
) -> Result<Vec<serde_json::Value>, String> {
// Note: Proxmox VE storage is per-node, not cluster-wide
// We need to get all nodes first, then fetch storage for each node
let client = get_proxmox_client_for_cluster(&cluster_id, &_state).await?;
let client_guard = client.lock().await;
// First, get all nodes
let nodes_path = "cluster/resources?type=node";
let nodes_response: serde_json::Value = client_guard
let response: serde_json::Value = client_guard
.get(
nodes_path,
"cluster/resources?type=storage",
Some(client_guard.ticket.as_deref().unwrap_or("")),
)
.await
.map_err(|e| format!("Failed to list nodes: {}", e))?;
.map_err(|e| format!("Failed to list cluster storage: {}", e))?;
let nodes: Vec<String> = nodes_response
.as_array()
.unwrap_or(&vec![])
let entries = response.as_array().ok_or("Invalid response format")?;
let all_storage: Vec<serde_json::Value> = entries
.iter()
.filter_map(|n| {
n.get("node")
.and_then(|node| node.as_str())
.map(|s| s.to_string())
.filter_map(|entry| {
let obj = entry.as_object()?;
let mut normalized = serde_json::Map::new();
let storage_name = obj.get("storage").and_then(|v| v.as_str()).unwrap_or("");
let node_name = obj.get("node").and_then(|v| v.as_str()).unwrap_or("");
normalized.insert(
"id".to_string(),
serde_json::Value::String(format!(
"storage/{}/{}",
node_name, storage_name
)),
);
normalized.insert(
"storage".to_string(),
serde_json::Value::String(storage_name.to_string()),
);
normalized.insert(
"name".to_string(),
serde_json::Value::String(storage_name.to_string()),
);
let plugin_type = obj
.get("plugintype")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
normalized.insert(
"type".to_string(),
serde_json::Value::String(plugin_type.to_string()),
);
let content = obj
.get("content")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
normalized.insert(
"content".to_string(),
serde_json::Value::String(content),
);
normalized.insert(
"node".to_string(),
serde_json::Value::String(node_name.to_string()),
);
// cluster/resources uses disk/maxdisk; normalize to used/available/size
let disk_used = obj.get("disk").and_then(|v| v.as_u64()).unwrap_or(0);
let disk_total = obj.get("maxdisk").and_then(|v| v.as_u64()).unwrap_or(0);
let disk_avail = disk_total.saturating_sub(disk_used);
normalized.insert("used".to_string(), serde_json::Value::Number(disk_used.into()));
normalized.insert("size".to_string(), serde_json::Value::Number(disk_total.into()));
normalized.insert("available".to_string(), serde_json::Value::Number(disk_avail.into()));
let status = obj
.get("status")
.and_then(|v| v.as_str())
.unwrap_or("available")
.to_string();
normalized.insert("status".to_string(), serde_json::Value::String(status));
// Preserve shared flag if present
if let Some(shared) = obj.get("shared") {
normalized.insert("shared".to_string(), shared.clone());
}
Some(serde_json::Value::Object(normalized))
})
.collect();
if nodes.is_empty() {
return Ok(vec![]);
}
// Fetch storage for each node
let mut all_storage: Vec<serde_json::Value> = vec![];
for node in nodes {
let storage_path = format!("nodes/{}/storage", node);
let storage_response: serde_json::Value = client_guard
.get(
&storage_path,
Some(client_guard.ticket.as_deref().unwrap_or("")),
)
.await
.map_err(|e| format!("Failed to list storage for node {}: {}", node, e))?;
if let Some(storage_array) = storage_response.as_array() {
for mut storage in storage_array.clone() {
// Add node information to each storage entry
if let Some(storage_obj) = storage.as_object_mut() {
storage_obj.insert("node".to_string(), serde_json::Value::String(node.clone()));
// Create a unique ID
if let Some(storage_name) = storage_obj.get("storage").and_then(|s| s.as_str())
{
storage_obj.insert(
"id".to_string(),
serde_json::Value::String(format!("storage/{}", storage_name)),
);
}
}
all_storage.push(storage);
}
}
}
Ok(all_storage)
}
@ -756,7 +781,7 @@ pub async fn list_proxmox_datastores(
#[tauri::command]
pub async fn trigger_proxmox_backup_job(
cluster_id: String,
node: String,
node_id: String,
job_id: u32,
state: State<'_, AppState>,
) -> Result<(), String> {
@ -765,7 +790,7 @@ pub async fn trigger_proxmox_backup_job(
crate::proxmox::backup::trigger_backup_job(
&client_guard,
&node,
&node_id,
job_id,
client_guard.ticket.as_deref().unwrap_or(""),
)
@ -1384,7 +1409,7 @@ pub async fn get_certificate(
#[tauri::command]
pub async fn list_firewall_rules(
cluster_id: String,
node: String,
node_id: String,
state: State<'_, AppState>,
) -> Result<Vec<serde_json::Value>, String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
@ -1392,7 +1417,7 @@ pub async fn list_firewall_rules(
let rules = crate::proxmox::firewall::list_firewall_rules(
&client_guard,
&node,
&node_id,
client_guard.ticket.as_deref().unwrap_or(""),
)
.await
@ -1408,35 +1433,56 @@ pub async fn list_firewall_rules(
/// Add firewall rule
#[tauri::command]
#[allow(clippy::too_many_arguments)]
pub async fn add_firewall_rule(
cluster_id: String,
node: String,
action: String,
protocol: String,
source: String,
destination: String,
port: Option<String>,
enabled: bool,
node_id: String,
rule: serde_json::Value,
state: State<'_, AppState>,
) -> Result<(), String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let client_guard = client.lock().await;
let rule = crate::proxmox::firewall::FirewallRule {
let firewall_rule = crate::proxmox::firewall::FirewallRule {
rule_num: 0,
action,
protocol,
source,
destination,
port,
enabled,
action: rule
.get("action")
.and_then(|v| v.as_str())
.unwrap_or("ACCEPT")
.to_string(),
protocol: rule
.get("proto")
.and_then(|v| v.as_str())
.unwrap_or("tcp")
.to_string(),
source: rule
.get("source")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
destination: rule
.get("dest")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
port: rule
.get("dport")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(|s| s.to_string()),
enabled: rule
.get("enable")
.and_then(|v| {
v.as_bool()
.or_else(|| v.as_i64().map(|n| n != 0))
.or_else(|| v.as_str().map(|s| s == "1" || s == "true"))
})
.unwrap_or(true),
};
crate::proxmox::firewall::add_rule(
&client_guard,
&node,
&rule,
&node_id,
&firewall_rule,
client_guard.ticket.as_deref().unwrap_or(""),
)
.await
@ -1447,7 +1493,7 @@ pub async fn add_firewall_rule(
#[tauri::command]
pub async fn delete_firewall_rule(
cluster_id: String,
node: String,
node_id: String,
rule_num: u32,
state: State<'_, AppState>,
) -> Result<(), String> {
@ -1456,7 +1502,7 @@ pub async fn delete_firewall_rule(
crate::proxmox::firewall::delete_rule(
&client_guard,
&node,
&node_id,
rule_num,
client_guard.ticket.as_deref().unwrap_or(""),
)
@ -1594,7 +1640,7 @@ pub async fn get_ceph_cluster_status(
#[tauri::command]
pub async fn migrate_vm(
cluster_id: String,
node: String,
node_id: String,
vm_id: u32,
target_node: String,
target_cluster: String,
@ -1605,7 +1651,7 @@ pub async fn migrate_vm(
let task = crate::proxmox::migration::migrate_vm(
&client_guard,
&node,
&node_id,
vm_id,
&target_node,
&target_cluster,
@ -1621,7 +1667,7 @@ pub async fn migrate_vm(
#[tauri::command]
pub async fn list_migration_status(
cluster_id: String,
node: String,
node_id: String,
state: State<'_, AppState>,
) -> Result<Vec<serde_json::Value>, String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
@ -1629,7 +1675,7 @@ pub async fn list_migration_status(
let tasks = crate::proxmox::migration::list_migration_status(
&client_guard,
&node,
&node_id,
client_guard.ticket.as_deref().unwrap_or(""),
)
.await

View File

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

View File

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

View File

@ -14,6 +14,7 @@ import { Checkbox as UICheckbox } from '@/components/ui/index';
import { Input } from '@/components/ui/index';
import { AlertCircle } from 'lucide-react';
import { Alert, AlertDescription } from '@/components/ui/index';
import type { ClusterInfo } from '@/lib/domain';
interface VMInfo {
id: string;
@ -50,6 +51,8 @@ interface RawVMInfo {
interface VMListProps {
vms: RawVMInfo[];
clusterId: string;
clusters?: ClusterInfo[];
onRefresh?: () => void;
isLoading?: boolean;
onSnapshotAction?: (vm: VMInfo, action: 'create' | 'list' | 'rollback' | 'delete') => void;
@ -62,16 +65,16 @@ interface VMListProps {
function formatUptime(seconds: number): string {
if (seconds <= 0) return '-';
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const parts: string[] = [];
if (days > 0) parts.push(`${days}d`);
if (hours > 0) parts.push(`${hours}h`);
if (minutes > 0 || parts.length === 0) parts.push(`${minutes}m`);
return parts.join(' ');
}
@ -85,22 +88,23 @@ function formatBytes(bytes: number): string {
export function VMList({
vms: rawVms,
clusterId,
clusters = [],
onRefresh,
isLoading,
onSnapshotAction,
onMigrate,
onClone,
onDelete,
onSnapshotAction: _onSnapshotAction,
onMigrate: _onMigrate,
onClone: _onClone,
onDelete: _onDelete,
selectedVMs = new Set<string>(),
onToggleSelect,
}: VMListProps) {
const [clusterId, setClusterId] = useState<string>('');
const [migrationVM, setMigrationVM] = useState<VMInfo | null>(null);
const [targetNode, setTargetNode] = useState<string>('');
const [targetCluster, setTargetCluster] = useState<string>('');
const [onlineMigration, setOnlineMigration] = useState(true);
const [maxDowntime, setMaxDowntime] = useState(30);
// Transform raw VM data to VMInfo format
const vms: VMInfo[] = React.useMemo(() => {
return rawVms.map((vm) => ({
id: String(vm.id || vm.vmid),
@ -118,17 +122,11 @@ export function VMList({
}));
}, [rawVms]);
useEffect(() => {
invoke<string[]>('list_proxmox_clusters')
.then((clusters: any[]) => {
if (clusters.length > 0) {
setClusterId(clusters[0].id);
}
})
.catch(() => {});
}, []);
const handleVMAction = useCallback(async (vm: VMInfo, action: string) => {
if (!clusterId) {
toast.error('No cluster selected');
return;
}
try {
switch (action) {
case 'start':
@ -204,7 +202,8 @@ export function VMList({
.map((v) => v.node)
.filter((node, index, self) => self.indexOf(node) === index && node !== vm.node);
setTargetNode(availableNodes[0] || '');
}, [vms]);
setTargetCluster(clusterId);
}, [vms, clusterId]);
const submitMigration = useCallback(async () => {
if (!migrationVM || !targetNode) {
@ -212,27 +211,34 @@ export function VMList({
return;
}
const sourceCluster = clusterId;
const destCluster = targetCluster || clusterId;
try {
await invoke('migrate_vm', {
clusterId,
clusterId: sourceCluster,
nodeId: migrationVM.node,
vmId: migrationVM.vmid,
targetNode,
online: onlineMigration,
max_downtime: maxDowntime,
targetCluster: destCluster,
});
toast.success(`VM ${migrationVM.name} migration started to ${targetNode}`);
toast.success(`VM ${migrationVM.name} migration started to ${targetNode}${destCluster !== sourceCluster ? ` (cluster: ${destCluster})` : ''}`);
setMigrationVM(null);
setTargetNode('');
setTargetCluster('');
onRefresh?.();
} catch (error) {
console.error('Failed to migrate VM:', error);
toast.error(`Failed to migrate VM ${migrationVM.name}: ${error}`);
}
}, [migrationVM, targetNode, onlineMigration, maxDowntime, clusterId, onRefresh]);
}, [migrationVM, targetNode, targetCluster, clusterId, onRefresh]);
const handleClone = useCallback(async (vm: VMInfo) => {
if (!clusterId) {
toast.error('No cluster selected');
return;
}
try {
const nextVmid = Math.max(...vms.map((v) => v.vmid), 100) + 1;
const newVmidStr = window.prompt(`Enter new VM ID for ${vm.name}:`, `${nextVmid}`);
@ -250,7 +256,7 @@ export function VMList({
toast.info('Clone cancelled');
return;
}
await invoke('clone_vm', {
clusterId,
nodeId: vm.node,
@ -258,7 +264,7 @@ export function VMList({
newVmid,
name: newName,
});
toast.success(`VM ${vm.name} cloned successfully to VM ${newVmid}`);
onRefresh?.();
} catch (error) {
@ -268,11 +274,15 @@ export function VMList({
}, [clusterId, vms, onRefresh]);
const handleDelete = useCallback(async (vm: VMInfo) => {
if (!clusterId) {
toast.error('No cluster selected');
return;
}
const confirmed = await confirm(`Are you sure you want to delete VM ${vm.name} (VMID: ${vm.vmid})? This action cannot be undone!`, {
title: 'Delete VM',
kind: 'warning',
});
if (!confirmed) {
return;
}
@ -283,7 +293,7 @@ export function VMList({
nodeId: vm.node,
vmId: vm.vmid,
});
toast.success(`VM ${vm.name} deleted successfully`);
onRefresh?.();
} catch (error) {
@ -309,7 +319,7 @@ export function VMList({
<TableRow>
<TableHead className="w-[40px]">
<Checkbox
checked={vms.every((vm) => selectedVMs.has(vm.id))}
checked={vms.length > 0 && vms.every((vm) => selectedVMs.has(vm.id))}
onCheckedChange={(checked) => {
if (checked) {
vms.forEach((vm) => selectedVMs.add(vm.id));
@ -335,7 +345,7 @@ export function VMList({
const cpuPercent = vm.cpu > 0 ? Math.min(vm.cpu * 100, 100) : 0;
const memoryPercent = vm.memoryTotal > 0 ? (vm.memory / vm.memoryTotal) * 100 : 0;
const diskPercent = vm.diskTotal > 0 ? (vm.disk / vm.diskTotal) * 100 : 0;
return (
<TableRow key={vm.id}>
<TableCell>
@ -413,11 +423,15 @@ export function VMList({
<MigrationDialog
vm={migrationVM}
isOpen={!!migrationVM}
onClose={() => setMigrationVM(null)}
onClose={() => { setMigrationVM(null); setTargetNode(''); setTargetCluster(''); }}
onSubmit={submitMigration}
availableNodes={vms}
clusters={clusters}
currentClusterId={clusterId}
targetNode={targetNode}
onTargetNodeChange={setTargetNode}
targetCluster={targetCluster}
onTargetClusterChange={setTargetCluster}
online={onlineMigration}
onOnlineChange={setOnlineMigration}
maxDowntime={maxDowntime}
@ -447,7 +461,6 @@ function VMActionMenu({
const [isOpen, setIsOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
// Close menu when clicking outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
@ -471,43 +484,10 @@ function VMActionMenu({
const handleAction = (action: () => void) => (e: React.MouseEvent) => {
e.stopPropagation();
action();
setIsOpen(false);
action();
};
// Calculate menu position to avoid overflow
const getMenuPosition = () => {
const viewportHeight = window.innerHeight;
const viewportWidth = window.innerWidth;
const buttonRect = menuRef.current?.querySelector('button')?.getBoundingClientRect();
if (!buttonRect) return { top: '100%', left: 0 };
const menuHeight = 400; // approximate menu height
const menuWidth = 192; // approximate menu width (w-48 = 12rem = 192px)
const spaceBelow = viewportHeight - buttonRect.bottom;
const spaceAbove = buttonRect.top;
const spaceRight = viewportWidth - buttonRect.right;
// Vertical positioning
let verticalPos: { top?: string; bottom?: string } = { top: '100%' };
if (spaceBelow >= menuHeight) {
verticalPos = { top: '100%' };
} else if (spaceAbove >= menuHeight) {
verticalPos = { bottom: '100%' };
}
// Horizontal positioning - account for overflow on the right
let horizontalPos: { left?: number; right?: number } = { left: 0 };
if (spaceRight < menuWidth) {
horizontalPos = { right: 0 };
}
return { ...verticalPos, ...horizontalPos };
};
const position = getMenuPosition();
return (
<div className="relative" ref={menuRef}>
<Button
@ -519,17 +499,12 @@ function VMActionMenu({
<MoreHorizontal className="h-4 w-4" />
</Button>
{isOpen && (
<div
className={`absolute z-50 w-48 rounded-md border bg-background shadow-md ${
position.bottom ? 'bottom-full mb-2' : 'top-full mt-2'
} ${position.right ? 'right-0' : ''}`}
style={{ left: position.left ?? undefined, right: position.right ?? undefined }}
>
<div className="absolute right-0 top-full z-50 mt-2 w-48 rounded-md border bg-background shadow-md">
<div className="space-y-1 p-1">
{vm.status === 'stopped' && (
<button
className="flex w-full items-center rounded-md px-2 py-1.5 text-sm hover:bg-accent"
onClick={() => onVMAction(vm, 'start')}
onClick={handleAction(() => onVMAction(vm, 'start'))}
>
<Play className="mr-2 h-4 w-4" />
Start
@ -539,28 +514,28 @@ function VMActionMenu({
<>
<button
className="flex w-full items-center rounded-md px-2 py-1.5 text-sm hover:bg-accent"
onClick={() => onVMAction(vm, 'stop')}
onClick={handleAction(() => onVMAction(vm, 'stop'))}
>
<Square className="mr-2 h-4 w-4" />
Stop
</button>
<button
className="flex w-full items-center rounded-md px-2 py-1.5 text-sm hover:bg-accent"
onClick={() => onVMAction(vm, 'reboot')}
onClick={handleAction(() => onVMAction(vm, 'reboot'))}
>
<RotateCcw className="mr-2 h-4 w-4" />
Reboot
</button>
<button
className="flex w-full items-center rounded-md px-2 py-1.5 text-sm hover:bg-accent"
onClick={() => onVMAction(vm, 'shutdown')}
onClick={handleAction(() => onVMAction(vm, 'shutdown'))}
>
<Power className="mr-2 h-4 w-4" />
Shutdown
</button>
<button
className="flex w-full items-center rounded-md px-2 py-1.5 text-sm hover:bg-accent"
onClick={() => onVMAction(vm, 'suspend')}
onClick={handleAction(() => onVMAction(vm, 'suspend'))}
>
<Pause className="mr-2 h-4 w-4" />
Suspend
@ -570,7 +545,7 @@ function VMActionMenu({
{vm.status === 'paused' && (
<button
className="flex w-full items-center rounded-md px-2 py-1.5 text-sm hover:bg-accent"
onClick={() => onVMAction(vm, 'resume')}
onClick={handleAction(() => onVMAction(vm, 'resume'))}
>
<PlayCircle className="mr-2 h-4 w-4" />
Resume
@ -579,27 +554,27 @@ function VMActionMenu({
<div className="h-px bg-border my-1" />
<button
className="flex w-full items-center rounded-md px-2 py-1.5 text-sm hover:bg-accent"
onClick={() => onSnapshotAction(vm, 'create')}
onClick={handleAction(() => onSnapshotAction(vm, 'create'))}
>
📸 Create Snapshot
</button>
<button
className="flex w-full items-center rounded-md px-2 py-1.5 text-sm hover:bg-accent"
onClick={() => onSnapshotAction(vm, 'list')}
onClick={handleAction(() => onSnapshotAction(vm, 'list'))}
>
📋 List Snapshots
</button>
<div className="h-px bg-border my-1" />
<button
className="flex w-full items-center rounded-md px-2 py-1.5 text-sm hover:bg-accent"
onClick={() => onMigrate(vm)}
onClick={handleAction(() => onMigrate(vm))}
>
<MoveRight className="mr-2 h-4 w-4" />
Migrate
</button>
<button
className="flex w-full items-center rounded-md px-2 py-1.5 text-sm hover:bg-accent"
onClick={() => onClone(vm)}
onClick={handleAction(() => onClone(vm))}
>
<Copy className="mr-2 h-4 w-4" />
Clone
@ -607,7 +582,7 @@ function VMActionMenu({
<div className="h-px bg-border my-1" />
<button
className="flex w-full items-center rounded-md px-2 py-1.5 text-sm hover:bg-red-100 hover:text-red-600"
onClick={() => onDelete(vm)}
onClick={handleAction(() => onDelete(vm))}
>
<X className="mr-2 h-4 w-4" />
Delete
@ -625,8 +600,12 @@ interface MigrationDialogProps {
onClose: () => void;
onSubmit: () => void;
availableNodes: VMInfo[];
clusters: ClusterInfo[];
currentClusterId: string;
targetNode: string;
onTargetNodeChange: (node: string) => void;
targetCluster: string;
onTargetClusterChange: (clusterId: string) => void;
online: boolean;
onOnlineChange: (online: boolean) => void;
maxDowntime: number;
@ -639,8 +618,12 @@ function MigrationDialog({
onClose,
onSubmit,
availableNodes,
clusters,
currentClusterId,
targetNode,
onTargetNodeChange,
targetCluster,
onTargetClusterChange,
online,
onOnlineChange,
maxDowntime,
@ -648,6 +631,8 @@ function MigrationDialog({
}: MigrationDialogProps) {
if (!vm) return null;
const isCrossCluster = targetCluster && targetCluster !== currentClusterId;
const availableTargets = availableNodes
.map((v) => v.node)
.filter((node, index, self) => self.indexOf(node) === index && node !== vm.node);
@ -665,25 +650,64 @@ function MigrationDialog({
Live migration requires the same hardware configuration on both nodes. Ensure storage is accessible from both nodes.
</AlertDescription>
</Alert>
{clusters.length > 1 && (
<div className="space-y-2">
<Label htmlFor="targetCluster">Target Remote</Label>
<Select value={targetCluster || currentClusterId} onValueChange={onTargetClusterChange}>
<SelectTrigger>
<SelectValue placeholder="Select target cluster" />
</SelectTrigger>
<SelectContent>
{clusters.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}{c.id === currentClusterId ? ' (current)' : ''}
</SelectItem>
))}
</SelectContent>
</Select>
{isCrossCluster && (
<p className="text-xs text-amber-600">
Cross-cluster migration VM will be moved to a different datacenter.
</p>
)}
</div>
)}
<div className="space-y-2">
<Label htmlFor="targetNode">Target Node</Label>
<Select value={targetNode} onValueChange={onTargetNodeChange}>
<SelectTrigger>
<SelectValue placeholder="Select target node" />
</SelectTrigger>
<SelectContent>
{availableTargets.map((node) => (
<SelectItem key={node} value={node}>
{node}
</SelectItem>
))}
</SelectContent>
</Select>
{availableTargets.length === 0 && (
<p className="text-xs text-muted-foreground">
No other nodes available for migration
</p>
{isCrossCluster ? (
<>
<Input
id="targetNode"
value={targetNode}
onChange={(e) => onTargetNodeChange(e.target.value)}
placeholder="Enter target node name"
/>
<p className="text-xs text-muted-foreground">
Enter the node name on the destination cluster
</p>
</>
) : (
<>
<Select value={targetNode} onValueChange={onTargetNodeChange}>
<SelectTrigger>
<SelectValue placeholder="Select target node" />
</SelectTrigger>
<SelectContent>
{availableTargets.map((node) => (
<SelectItem key={node} value={node}>
{node}
</SelectItem>
))}
</SelectContent>
</Select>
{availableTargets.length === 0 && (
<p className="text-xs text-muted-foreground">
No other nodes available in this cluster
</p>
)}
</>
)}
</div>
@ -722,7 +746,10 @@ function MigrationDialog({
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button onClick={onSubmit} disabled={!targetNode || availableTargets.length === 0}>
<Button
onClick={onSubmit}
disabled={!targetNode || (!isCrossCluster && availableTargets.length === 0)}
>
Start Migration
</Button>
</DialogFooter>

View File

@ -8,7 +8,6 @@ import { toast } from 'sonner';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/index';
import { Input } from '@/components/ui/index';
import { Label } from '@/components/ui/index';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/index';
export function ProxmoxBackupPage() {
const [clusters, setClusters] = useState<ClusterInfo[]>([]);
@ -40,8 +39,34 @@ export function ProxmoxBackupPage() {
if (!clusterId) return;
setIsLoading(true);
try {
const data = await listProxmoxBackupJobs(clusterId, '');
setJobs(data);
const raw = await listProxmoxBackupJobs(clusterId, '');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const normalized = (raw as any[]).map((job) => {
const enabledRaw = job.enabled ?? job.enable ?? 1;
const isEnabled = enabledRaw === 1 || enabledRaw === true || enabledRaw === '1';
const nextRunRaw = job['next-run'] ?? job.next_run ?? job.nextRun;
const nextRunStr = nextRunRaw
? new Date(Number(nextRunRaw) * 1000).toLocaleString()
: undefined;
return {
id: job.id || String(job.jobid || ''),
name: job.id || job.comment || `job-${job.jobid || '?'}`,
node: job.node || 'all',
schedule: job.schedule || '-',
status: isEnabled ? ('idle' as const) : ('idle' as const),
lastRun: undefined,
nextRun: nextRunStr,
size: undefined,
count: undefined,
enabled: isEnabled,
vmid: job.vmid,
storage: job.storage,
mode: job.mode,
compress: job.compress,
comment: job.comment,
};
});
setJobs(normalized);
} catch (err) {
console.error('Failed to load backup jobs:', err);
toast.error('Failed to load backup jobs');

View File

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

View File

@ -7,6 +7,7 @@ import { listNetworkInterfaces, listProxmoxClusters, NetworkInterface } from '@/
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/index';
import { Input } from '@/components/ui/index';
import { Label } from '@/components/ui/index';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/index';
import { toast } from 'sonner';
export function ProxmoxNetworkPage() {
@ -216,12 +217,21 @@ export function ProxmoxNetworkPage() {
</div>
<div className="space-y-2">
<Label htmlFor="type">Interface Type</Label>
<Input
id="type"
value={ifaceType}
onChange={(e) => setIfaceType(e.target.value)}
placeholder="eth, bond, bridge, vlan"
/>
<Select value={ifaceType} onValueChange={setIfaceType}>
<SelectTrigger>
<SelectValue placeholder="Select interface type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="eth">eth Ethernet</SelectItem>
<SelectItem value="bond">bond Network Bond</SelectItem>
<SelectItem value="bridge">bridge Linux Bridge</SelectItem>
<SelectItem value="vlan">vlan VLAN</SelectItem>
<SelectItem value="OVSBridge">OVSBridge Open vSwitch Bridge</SelectItem>
<SelectItem value="OVSBond">OVSBond Open vSwitch Bond</SelectItem>
<SelectItem value="OVSIntPort">OVSIntPort OVS Internal Port</SelectItem>
<SelectItem value="OVSPort">OVSPort OVS Port</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="address">IP Address</Label>

View File

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