Merge pull request 'fix(proxmox): resolve 11 dashboard UI and API issues' (#127) from fix/proxmox-ui-and-api-issues into beta
All checks were successful
Release Beta / autotag (push) Successful in 9s
Release Beta / changelog (push) Successful in 1m28s
Test / frontend-tests (push) Successful in 1m47s
Test / frontend-typecheck (push) Successful in 1m50s
Release Beta / build-macos-arm64 (push) Successful in 8m12s
Release Beta / build-linux-amd64 (push) Successful in 10m7s
Release Beta / build-windows-amd64 (push) Successful in 11m8s
Release Beta / build-linux-arm64 (push) Successful in 13m5s
Test / rust-fmt-check (push) Successful in 18m22s
Test / rust-clippy (push) Successful in 19m52s
Test / rust-tests (push) Successful in 21m38s

Reviewed-on: #127
This commit is contained in:
sarman 2026-06-21 15:22:11 +00:00
commit 78c2b411e7
9 changed files with 10110 additions and 3561 deletions

View File

@ -477,7 +477,7 @@ jobs:
if [ -f "/tmp/pr_review.txt" ] && [ -s "/tmp/pr_review.txt" ]; then if [ -f "/tmp/pr_review.txt" ] && [ -s "/tmp/pr_review.txt" ]; then
REVIEW_BODY=$(head -c 65536 /tmp/pr_review.txt) REVIEW_BODY=$(head -c 65536 /tmp/pr_review.txt)
BODY=$(jq -n \ BODY=$(jq -n \
--arg body "Automated PR Review (qwen3.5-122b-think via liteLLM):\n\n${REVIEW_BODY}" \ --arg body "Automated PR Review:${REVIEW_BODY}" \
'{body: $body, event: "COMMENT"}') '{body: $body, event: "COMMENT"}')
else else
BODY=$(jq -n \ BODY=$(jq -n \

File diff suppressed because it is too large Load Diff

View File

@ -535,58 +535,221 @@ pub async fn shutdown_proxmox_vm(
.map_err(|e| format!("Failed to shutdown VM {}: {}", vm_id, e)) .map_err(|e| format!("Failed to shutdown VM {}: {}", vm_id, e))
} }
/// List Proxmox Backup Jobs /// Resume a paused Proxmox VM
#[tauri::command]
pub async fn resume_proxmox_vm(
cluster_id: String,
node: String,
vm_id: u32,
state: State<'_, AppState>,
) -> Result<(), String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let client_guard = client.lock().await;
crate::proxmox::vm::resume_vm(
&client_guard,
&node,
vm_id,
client_guard.ticket.as_deref().unwrap_or(""),
)
.await
.map_err(|e| format!("Failed to resume VM {}: {}", vm_id, e))
}
/// Suspend a running Proxmox VM
#[tauri::command]
pub async fn suspend_proxmox_vm(
cluster_id: String,
node: String,
vm_id: u32,
state: State<'_, AppState>,
) -> Result<(), String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let client_guard = client.lock().await;
crate::proxmox::vm::suspend_vm(
&client_guard,
&node,
vm_id,
client_guard.ticket.as_deref().unwrap_or(""),
)
.await
.map_err(|e| format!("Failed to suspend VM {}: {}", vm_id, e))
}
/// Clone a Proxmox VM
#[tauri::command]
pub async fn clone_vm(
cluster_id: String,
node: String,
vm_id: u32,
new_vmid: u32,
name: String,
state: State<'_, AppState>,
) -> Result<(), String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let client_guard = client.lock().await;
crate::proxmox::vm::clone_vm(
&client_guard,
&node,
vm_id,
new_vmid,
&name,
client_guard.ticket.as_deref().unwrap_or(""),
)
.await
.map_err(|e| format!("Failed to clone VM {}: {}", vm_id, e))
}
/// Delete a Proxmox VM
#[tauri::command]
pub async fn delete_vm(
cluster_id: String,
node: String,
vm_id: u32,
state: State<'_, AppState>,
) -> Result<(), String> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?;
let client_guard = client.lock().await;
crate::proxmox::vm::delete_vm(
&client_guard,
&node,
vm_id,
client_guard.ticket.as_deref().unwrap_or(""),
)
.await
.map_err(|e| format!("Failed to delete VM {}: {}", vm_id, e))
}
/// List Proxmox Backup Jobs (cluster-level, not node-level)
#[tauri::command] #[tauri::command]
pub async fn list_proxmox_backup_jobs( pub async fn list_proxmox_backup_jobs(
cluster_id: String, cluster_id: String,
node: String, _node: String, // Node parameter kept for compatibility but not used
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?;
let client_guard = client.lock().await; let client_guard = client.lock().await;
let jobs = crate::proxmox::backup::list_backup_jobs( // Proxmox VE backup jobs are at cluster level, not node level
&client_guard, let path = "cluster/backup";
&node, let response: serde_json::Value = client_guard
client_guard.ticket.as_deref().unwrap_or(""), .get(path, Some(client_guard.ticket.as_deref().unwrap_or("")))
) .await
.await .map_err(|e| format!("Failed to list backup jobs: {}", e))?;
.map_err(|e| format!("Failed to list backup jobs: {}", e))?;
let json_jobs: Vec<serde_json::Value> = jobs let mut jobs: Vec<serde_json::Value> = response
.into_iter() .as_array()
.map(|job| { .map(|arr| {
serde_json::to_value(job).map_err(|e| format!("Failed to serialize backup job: {}", e)) arr.iter()
.cloned()
.map(|mut job| {
// Add id field if missing for frontend compatibility
if let Some(job_obj) = job.as_object_mut() {
if !job_obj.contains_key("id") {
if let Some(jobid) = job_obj.get("id").and_then(|v| v.as_str()) {
job_obj.insert(
"id".to_string(),
serde_json::Value::String(jobid.to_string()),
);
} else {
let storage = job_obj
.get("storage")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
job_obj.insert(
"id".to_string(),
serde_json::Value::String(format!("backup-{}", storage)),
);
}
}
}
job
})
.collect()
}) })
.collect::<Result<Vec<_>, _>>()?; .ok_or_else(|| "Invalid response format".to_string())?;
Ok(json_jobs) // Apply limit if needed (Proxmox may return many jobs)
if jobs.len() > 100 {
jobs.truncate(100);
}
Ok(jobs)
} }
/// List Proxmox Datastores /// List Proxmox Datastores (Storage per node)
#[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> {
let client = get_proxmox_client_for_cluster(&cluster_id, &state).await?; // 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; let client_guard = client.lock().await;
let datastores = crate::proxmox::backup::list_datastores( // First, get all nodes
&client_guard, let nodes_path = "cluster/resources?type=node";
client_guard.ticket.as_deref().unwrap_or(""), let nodes_response: serde_json::Value = client_guard
) .get(
.await nodes_path,
.map_err(|e| format!("Failed to list datastores: {}", e))?; Some(client_guard.ticket.as_deref().unwrap_or("")),
)
.await
.map_err(|e| format!("Failed to list nodes: {}", e))?;
let json_datastores: Vec<serde_json::Value> = datastores let nodes: Vec<String> = nodes_response
.into_iter() .as_array()
.map(|ds| { .unwrap_or(&vec![])
serde_json::to_value(ds).map_err(|e| format!("Failed to serialize datastore: {}", e)) .iter()
.filter_map(|n| {
n.get("node")
.and_then(|node| node.as_str())
.map(|s| s.to_string())
}) })
.collect::<Result<Vec<_>, _>>()?; .collect();
Ok(json_datastores) 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)
} }
/// Trigger Proxmox Backup Job /// Trigger Proxmox Backup Job
@ -1007,8 +1170,17 @@ pub async fn list_views(
&client_guard, &client_guard,
client_guard.ticket.as_deref().unwrap_or(""), client_guard.ticket.as_deref().unwrap_or(""),
) )
.await .await;
.map_err(|e| format!("Failed to list views: {}", e))?;
// Handle 501 Not Implemented gracefully - return empty array
if let Err(e) = &views {
if e.contains("501") || e.contains("Not Implemented") || e.contains("not implemented") {
tracing::warn!("Views API not implemented by Proxmox server, returning empty list");
return Ok(vec![]);
}
}
let views = views.map_err(|e| format!("Failed to list views: {}", e))?;
let json_views: Vec<serde_json::Value> = views let json_views: Vec<serde_json::Value> = views
.into_iter() .into_iter()
@ -1137,8 +1309,19 @@ pub async fn list_certificates(
&client_guard, &client_guard,
client_guard.ticket.as_deref().unwrap_or(""), client_guard.ticket.as_deref().unwrap_or(""),
) )
.await .await;
.map_err(|e| format!("Failed to list certificates: {}", e))?;
// Handle 501 Not Implemented gracefully - return empty array
if let Err(e) = &certs {
if e.contains("501") || e.contains("Not Implemented") || e.contains("not implemented") {
tracing::warn!(
"Certificates API not implemented by Proxmox server, returning empty list"
);
return Ok(vec![]);
}
}
let certs = certs.map_err(|e| format!("Failed to list certificates: {}", e))?;
let json_certs: Vec<serde_json::Value> = certs let json_certs: Vec<serde_json::Value> = certs
.into_iter() .into_iter()
@ -2086,23 +2269,31 @@ pub async fn get_subscription_status(
#[tauri::command] #[tauri::command]
pub async fn list_cluster_tasks( pub async fn list_cluster_tasks(
cluster_id: String, cluster_id: String,
limit: Option<u32>, _limit: Option<u32>,
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?;
let client_guard = client.lock().await; let client_guard = client.lock().await;
let limit_val = limit.unwrap_or(50); // Note: Proxmox API doesn't support limit parameter for cluster/tasks
let path = format!("cluster/tasks?limit={}", limit_val); // We fetch all tasks and limit client-side if needed
let path = "cluster/tasks";
let response: serde_json::Value = client_guard let response: serde_json::Value = client_guard
.get(&path, Some(client_guard.ticket.as_deref().unwrap_or(""))) .get(path, Some(client_guard.ticket.as_deref().unwrap_or("")))
.await .await
.map_err(|e| format!("Failed to list cluster tasks: {}", e))?; .map_err(|e| format!("Failed to list cluster tasks: {}", e))?;
response let mut tasks: Vec<serde_json::Value> = response
.as_array() .as_array()
.map(|arr| arr.to_vec()) .map(|arr| arr.to_vec())
.ok_or_else(|| "Invalid response format".to_string()) .ok_or_else(|| "Invalid response format".to_string())?;
// Apply limit client-side if specified
if let Some(limit) = _limit {
tasks.truncate(limit as usize);
}
Ok(tasks)
} }
/// List Proxmox LXC containers /// List Proxmox LXC containers

View File

@ -238,6 +238,10 @@ pub fn run() {
commands::proxmox::stop_proxmox_vm, commands::proxmox::stop_proxmox_vm,
commands::proxmox::reboot_proxmox_vm, commands::proxmox::reboot_proxmox_vm,
commands::proxmox::shutdown_proxmox_vm, commands::proxmox::shutdown_proxmox_vm,
commands::proxmox::resume_proxmox_vm,
commands::proxmox::suspend_proxmox_vm,
commands::proxmox::clone_vm,
commands::proxmox::delete_vm,
commands::proxmox::list_proxmox_backup_jobs, commands::proxmox::list_proxmox_backup_jobs,
commands::proxmox::list_proxmox_datastores, commands::proxmox::list_proxmox_datastores,
commands::proxmox::trigger_proxmox_backup_job, commands::proxmox::trigger_proxmox_backup_job,

View File

@ -502,7 +502,8 @@ mod tests {
} }
}; };
let mut client = ProxmoxClient::new("proxmox-server", 8006, "root@pam"); let host = std::env::var("PROXMOX_HOST").unwrap_or_else(|_| "proxmox-server".to_string());
let mut client = ProxmoxClient::new(&host, 8006, "root@pam");
client client
.authenticate(&password) .authenticate(&password)
.await .await
@ -544,7 +545,8 @@ mod tests {
} }
}; };
let mut client = ProxmoxClient::new("proxmox-server", 8006, "root@pam"); let host = std::env::var("PROXMOX_HOST").unwrap_or_else(|_| "proxmox-server".to_string());
let mut client = ProxmoxClient::new(&host, 8006, "root@pam");
client client
.authenticate(&password) .authenticate(&password)
.await .await

View File

@ -6,13 +6,14 @@ import { MoreHorizontal } from 'lucide-react';
interface StorageInfo { interface StorageInfo {
id: string; id: string;
storage: string;
name: string; name: string;
type: string; type: string;
remote: string; content: string;
node?: string; node?: string;
used: string; size: number;
total: string; used: number;
available: string; available: number;
status: string; status: string;
} }
@ -24,6 +25,14 @@ interface StorageListProps {
onDelete?: (storage: StorageInfo) => void; onDelete?: (storage: StorageInfo) => void;
} }
function formatBytes(bytes: number): string {
if (bytes === 0 || bytes < 0 || isNaN(bytes)) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
export function StorageList({ export function StorageList({
storages, storages,
onRefresh, onRefresh,
@ -46,7 +55,7 @@ export function StorageList({
<TableRow> <TableRow>
<TableHead>Name</TableHead> <TableHead>Name</TableHead>
<TableHead>Type</TableHead> <TableHead>Type</TableHead>
<TableHead>Remote</TableHead> <TableHead>Content</TableHead>
<TableHead>Node</TableHead> <TableHead>Node</TableHead>
<TableHead>Used</TableHead> <TableHead>Used</TableHead>
<TableHead>Total</TableHead> <TableHead>Total</TableHead>
@ -56,46 +65,54 @@ export function StorageList({
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{storages.map((storage) => ( {storages.length === 0 ? (
<TableRow key={storage.id}> <TableRow>
<TableCell className="font-medium">{storage.name}</TableCell> <TableCell colSpan={9} className="text-center py-8 text-muted-foreground">
<TableCell>{storage.type}</TableCell> No storage configured or unable to fetch storage data
<TableCell>{storage.remote}</TableCell>
<TableCell>{storage.node || '-'}</TableCell>
<TableCell>{storage.used}</TableCell>
<TableCell>{storage.total}</TableCell>
<TableCell>{storage.available}</TableCell>
<TableCell>
<span className="inline-flex items-center rounded-full px-2 py-1 text-xs font-medium bg-green-100 text-green-800">
{storage.status}
</span>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end space-x-2">
<button
className="rounded-md p-1 hover:bg-accent"
onClick={() => onEdit?.(storage)}
title="Edit"
>
<span className="h-4 w-4 text-xs"></span>
</button>
<button
className="rounded-md p-1 hover:bg-red-100 hover:text-red-600"
onClick={() => onDelete?.(storage)}
title="Delete"
>
<span className="h-4 w-4 text-xs">🗑</span>
</button>
<button
className="rounded-md p-1 hover:bg-accent"
title="More"
>
<MoreHorizontal className="h-4 w-4" />
</button>
</div>
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ) : (
storages.map((storage) => (
<TableRow key={storage.id || storage.storage}>
<TableCell className="font-medium">{storage.storage || storage.name}</TableCell>
<TableCell>{storage.type || '-'}</TableCell>
<TableCell>{storage.content || '-'}</TableCell>
<TableCell>{storage.node || '-'}</TableCell>
<TableCell>{storage.used ? formatBytes(storage.used) : '-'}</TableCell>
<TableCell>{storage.size ? formatBytes(storage.size) : '-'}</TableCell>
<TableCell>{storage.available ? formatBytes(storage.available) : '-'}</TableCell>
<TableCell>
<span className="inline-flex items-center rounded-full px-2 py-1 text-xs font-medium bg-green-100 text-green-800">
{storage.status || 'available'}
</span>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end space-x-2">
<button
className="rounded-md p-1 hover:bg-accent"
onClick={() => onEdit?.(storage)}
title="Edit"
>
<span className="h-4 w-4 text-xs"></span>
</button>
<button
className="rounded-md p-1 hover:bg-red-100 hover:text-red-600"
onClick={() => onDelete?.(storage)}
title="Delete"
>
<span className="h-4 w-4 text-xs">🗑</span>
</button>
<button
className="rounded-md p-1 hover:bg-accent"
title="More"
>
<MoreHorizontal className="h-4 w-4" />
</button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>

View File

@ -1,9 +1,12 @@
import React from 'react'; import React, { useState, useEffect, useCallback, useRef } from 'react';
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';
import { Checkbox } from '@/components/ui/index'; import { Checkbox } from '@/components/ui/index';
import { MoreHorizontal, Play, Square, RotateCcw, Power, PlayCircle, Pause, X } from 'lucide-react'; import { MoreHorizontal, Play, Square, RotateCcw, Power, PlayCircle, Pause, X, MoveRight, Copy } from 'lucide-react';
import { invoke } from '@tauri-apps/api/core';
import { confirm } from '@tauri-apps/plugin-dialog';
import { toast } from 'sonner';
interface VMInfo { interface VMInfo {
id: string; id: string;
@ -16,7 +19,7 @@ interface VMInfo {
memoryTotal: number; memoryTotal: number;
disk: number; disk: number;
diskTotal: number; diskTotal: number;
uptime?: string; uptime?: number;
tags?: string[]; tags?: string[];
} }
@ -24,7 +27,6 @@ interface VMListProps {
vms: VMInfo[]; vms: VMInfo[];
onRefresh?: () => void; onRefresh?: () => void;
isLoading?: boolean; isLoading?: boolean;
onVMAction?: (vm: VMInfo, action: 'start' | 'stop' | 'reboot' | 'shutdown' | 'resume' | 'suspend') => void;
onSnapshotAction?: (vm: VMInfo, action: 'create' | 'list' | 'rollback' | 'delete') => void; onSnapshotAction?: (vm: VMInfo, action: 'create' | 'list' | 'rollback' | 'delete') => void;
onMigrate?: (vm: VMInfo) => void; onMigrate?: (vm: VMInfo) => void;
onClone?: (vm: VMInfo) => void; onClone?: (vm: VMInfo) => void;
@ -33,11 +35,33 @@ interface VMListProps {
onToggleSelect?: (vm: VMInfo) => void; onToggleSelect?: (vm: VMInfo) => void;
} }
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(' ');
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
export function VMList({ export function VMList({
vms, vms,
onRefresh, onRefresh,
isLoading, isLoading,
onVMAction,
onSnapshotAction, onSnapshotAction,
onMigrate, onMigrate,
onClone, onClone,
@ -45,6 +69,173 @@ export function VMList({
selectedVMs = new Set<string>(), selectedVMs = new Set<string>(),
onToggleSelect, onToggleSelect,
}: VMListProps) { }: VMListProps) {
const [clusterId, setClusterId] = useState<string>('');
useEffect(() => {
// Use list_proxmox_clusters and select the first cluster
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) => {
try {
switch (action) {
case 'start':
await invoke('start_proxmox_vm', {
clusterId,
nodeId: vm.node,
vmId: vm.vmid,
});
toast.success(`VM ${vm.name} started`);
onRefresh?.();
break;
case 'stop':
await invoke('stop_proxmox_vm', {
clusterId,
nodeId: vm.node,
vmId: vm.vmid,
});
toast.success(`VM ${vm.name} stopped`);
onRefresh?.();
break;
case 'reboot':
await invoke('reboot_proxmox_vm', {
clusterId,
nodeId: vm.node,
vmId: vm.vmid,
});
toast.success(`VM ${vm.name} rebooting`);
onRefresh?.();
break;
case 'shutdown':
await invoke('shutdown_proxmox_vm', {
clusterId,
nodeId: vm.node,
vmId: vm.vmid,
});
toast.success(`VM ${vm.name} shutting down`);
onRefresh?.();
break;
case 'resume':
await invoke('resume_proxmox_vm', {
clusterId,
nodeId: vm.node,
vmId: vm.vmid,
});
toast.success(`VM ${vm.name} resumed`);
onRefresh?.();
break;
case 'suspend':
await invoke('suspend_proxmox_vm', {
clusterId,
nodeId: vm.node,
vmId: vm.vmid,
});
toast.success(`VM ${vm.name} suspended`);
onRefresh?.();
break;
default:
toast.error(`Unknown action: ${action}`);
}
} catch (error) {
console.error(`Failed to ${action} VM ${vm.name}:`, error);
toast.error(`Failed to ${action} VM ${vm.name}: ${error}`);
}
}, [clusterId, onRefresh]);
const handleSnapshotAction = useCallback((vm: VMInfo, action: 'create' | 'list' | 'rollback' | 'delete') => {
toast.info(`Snapshot ${action} for ${vm.name} - not yet implemented`);
}, []);
const handleMigrate = useCallback(async (vm: VMInfo) => {
try {
const targetNodes = vms
.map((v) => v.node)
.filter((node, index, self) => self.indexOf(node) === index && node !== vm.node);
if (targetNodes.length === 0) {
toast.error('No target nodes available for migration');
return;
}
const targetNode = window.prompt(`Select target node: ${targetNodes.join(', ')}`, targetNodes[0]);
if (!targetNode) {
toast.info('Migration cancelled');
return;
}
await invoke('migrate_vm', {
clusterId,
nodeId: vm.node,
vmId: vm.vmid,
targetNode,
online: vm.status === 'running',
});
toast.success(`VM ${vm.name} migration started`);
onRefresh?.();
} catch (error) {
console.error('Failed to migrate VM:', error);
toast.error(`Failed to migrate VM ${vm.name}: ${error}`);
}
}, [clusterId, vms, onRefresh]);
const handleClone = useCallback(async (vm: VMInfo) => {
try {
const newVmidStr = window.prompt(`Enter new VM ID for ${vm.name}:`, `${vm.vmid + 1}`);
if (!newVmidStr) {
toast.info('Clone cancelled');
return;
}
const newVmid = parseInt(newVmidStr);
const newName = window.prompt(`Enter name for cloned VM:`, `${vm.name}-clone`);
if (!newName) {
toast.info('Clone cancelled');
return;
}
await invoke('clone_vm', {
clusterId,
nodeId: vm.node,
vmId: vm.vmid,
newVmid,
name: newName || `${vm.name}-clone`,
});
toast.success(`VM ${vm.name} cloned successfully`);
onRefresh?.();
} catch (error) {
console.error('Failed to clone VM:', error);
toast.error(`Failed to clone VM ${vm.name}: ${error}`);
}
}, [clusterId, onRefresh]);
const handleDelete = useCallback(async (vm: VMInfo) => {
if (!confirm(`Are you sure you want to delete VM ${vm.name}?`)) {
return;
}
try {
await invoke('delete_vm', {
clusterId,
nodeId: vm.node,
vmId: vm.vmid,
});
toast.success(`VM ${vm.name} deleted`);
onRefresh?.();
} catch (error) {
console.error('Failed to delete VM:', error);
toast.error(`Failed to delete VM ${vm.name}: ${error}`);
}
}, [clusterId, onRefresh]);
return ( return (
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
@ -84,44 +275,80 @@ export function VMList({
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{vms.map((vm) => ( {vms.map((vm) => {
<TableRow key={vm.id}> const cpuPercent = vm.cpu > 0 ? Math.min(vm.cpu * 100, 100) : 0;
<TableCell> const memoryPercent = vm.memoryTotal > 0 ? (vm.memory / vm.memoryTotal) * 100 : 0;
<Checkbox const diskPercent = vm.diskTotal > 0 ? (vm.disk / vm.diskTotal) * 100 : 0;
checked={selectedVMs.has(vm.id)}
onCheckedChange={() => onToggleSelect?.(vm)} return (
/> <TableRow key={vm.id}>
</TableCell> <TableCell>
<TableCell className="font-medium">{vm.name}</TableCell> <Checkbox
<TableCell>{vm.vmid}</TableCell> checked={selectedVMs.has(vm.id)}
<TableCell>{vm.node}</TableCell> onCheckedChange={() => onToggleSelect?.(vm)}
<TableCell> />
<span className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${ </TableCell>
vm.status === 'running' ? 'bg-green-100 text-green-800' : <TableCell className="font-medium">{vm.name}</TableCell>
vm.status === 'stopped' ? 'bg-red-100 text-red-800' : <TableCell>{vm.vmid}</TableCell>
'bg-yellow-100 text-yellow-800' <TableCell>{vm.node}</TableCell>
}`}> <TableCell>
{vm.status} <span className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${
</span> vm.status === 'running' ? 'bg-green-100 text-green-800' :
</TableCell> vm.status === 'stopped' ? 'bg-red-100 text-red-800' :
<TableCell>{vm.cpu}%</TableCell> 'bg-yellow-100 text-yellow-800'
<TableCell>{Math.round((vm.memory / vm.memoryTotal) * 100)}%</TableCell> }`}>
<TableCell>{Math.round((vm.disk / vm.diskTotal) * 100)}%</TableCell> {vm.status}
<TableCell>{vm.uptime || '-'}</TableCell> </span>
<TableCell className="text-right"> </TableCell>
<div className="flex items-center justify-end space-x-2"> <TableCell>{cpuPercent.toFixed(2)}%</TableCell>
<TableCell>
{vm.memoryTotal > 0 ? (
<div className="flex items-center gap-2">
<div className="flex-1 h-2 bg-gray-200 rounded-full overflow-hidden">
<div
className="h-full bg-blue-500"
style={{ width: `${memoryPercent}%` }}
/>
</div>
<span className="text-xs text-muted-foreground">
{formatBytes(vm.memory)} / {formatBytes(vm.memoryTotal)}
</span>
</div>
) : (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
<TableCell>
{vm.diskTotal > 0 ? (
<div className="flex items-center gap-2">
<div className="flex-1 h-2 bg-gray-200 rounded-full overflow-hidden">
<div
className="h-full bg-green-500"
style={{ width: `${diskPercent}%` }}
/>
</div>
<span className="text-xs text-muted-foreground">
{formatBytes(vm.disk)} / {formatBytes(vm.diskTotal)}
</span>
</div>
) : (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
<TableCell>{formatUptime(vm.uptime || 0)}</TableCell>
<TableCell className="text-right">
<VMActionMenu <VMActionMenu
vm={vm} vm={vm}
onVMAction={onVMAction} onVMAction={handleVMAction}
onSnapshotAction={onSnapshotAction} onSnapshotAction={handleSnapshotAction}
onMigrate={onMigrate} onMigrate={handleMigrate}
onClone={onClone} onClone={handleClone}
onDelete={onDelete} onDelete={handleDelete}
/> />
</div> </TableCell>
</TableCell> </TableRow>
</TableRow> );
))} })}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
@ -132,11 +359,11 @@ export function VMList({
interface VMActionMenuProps { interface VMActionMenuProps {
vm: VMInfo; vm: VMInfo;
onVMAction?: (vm: VMInfo, action: 'start' | 'stop' | 'reboot' | 'shutdown' | 'resume' | 'suspend') => void; onVMAction: (vm: VMInfo, action: 'start' | 'stop' | 'reboot' | 'shutdown' | 'resume' | 'suspend') => void;
onSnapshotAction?: (vm: VMInfo, action: 'create' | 'list' | 'rollback' | 'delete') => void; onSnapshotAction: (vm: VMInfo, action: 'create' | 'list' | 'rollback' | 'delete') => void;
onMigrate?: (vm: VMInfo) => void; onMigrate: (vm: VMInfo) => void;
onClone?: (vm: VMInfo) => void; onClone: (vm: VMInfo) => void;
onDelete?: (vm: VMInfo) => void; onDelete: (vm: VMInfo) => void;
} }
function VMActionMenu({ function VMActionMenu({
@ -147,133 +374,170 @@ function VMActionMenu({
onClone, onClone,
onDelete, onDelete,
}: VMActionMenuProps) { }: VMActionMenuProps) {
const [isOpen, setIsOpen] = React.useState(false); 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)) {
setIsOpen(false);
}
}
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
const toggleMenu = (e: React.MouseEvent) => {
e.stopPropagation();
setIsOpen(!isOpen);
};
const handleAction = (action: () => void) => (e: React.MouseEvent) => {
e.stopPropagation();
action();
setIsOpen(false);
};
// 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"> <div className="relative" ref={menuRef}>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => setIsOpen(!isOpen)} onClick={toggleMenu}
className="h-8 w-8 p-0"
> >
<MoreHorizontal className="h-4 w-4" /> <MoreHorizontal className="h-4 w-4" />
</Button> </Button>
{isOpen && ( {isOpen && (
<div className="absolute right-0 top-8 z-50 w-48 rounded-md border bg-popover p-2 shadow-md"> <div
<div className="space-y-1"> className={`absolute z-50 w-48 rounded-md border bg-background shadow-md ${
<button position.bottom ? 'bottom-full mb-2' : 'top-full mt-2'
className="flex w-full items-center rounded-md px-2 py-1.5 text-sm hover:bg-accent" } ${position.right ? 'right-0' : ''}`}
onClick={() => { style={{ left: position.left ?? undefined, right: position.right ?? undefined }}
onVMAction?.(vm, 'start'); >
setIsOpen(false); <div className="space-y-1 p-1">
}} {vm.status === 'stopped' && (
> <button
<Play className="mr-2 h-4 w-4" /> className="flex w-full items-center rounded-md px-2 py-1.5 text-sm hover:bg-accent"
Start onClick={() => onVMAction(vm, 'start')}
</button> >
<button <Play className="mr-2 h-4 w-4" />
className="flex w-full items-center rounded-md px-2 py-1.5 text-sm hover:bg-accent" Start
onClick={() => { </button>
onVMAction?.(vm, 'stop'); )}
setIsOpen(false); {vm.status === 'running' && (
}} <>
> <button
<Square className="mr-2 h-4 w-4" /> className="flex w-full items-center rounded-md px-2 py-1.5 text-sm hover:bg-accent"
Stop onClick={() => onVMAction(vm, 'stop')}
</button> >
<button <Square className="mr-2 h-4 w-4" />
className="flex w-full items-center rounded-md px-2 py-1.5 text-sm hover:bg-accent" Stop
onClick={() => { </button>
onVMAction?.(vm, 'reboot'); <button
setIsOpen(false); className="flex w-full items-center rounded-md px-2 py-1.5 text-sm hover:bg-accent"
}} onClick={() => 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={() => { onClick={() => onVMAction(vm, 'shutdown')}
onVMAction?.(vm, 'shutdown'); >
setIsOpen(false); <Power className="mr-2 h-4 w-4" />
}} Shutdown
> </button>
<Power className="mr-2 h-4 w-4" /> <button
Shutdown className="flex w-full items-center rounded-md px-2 py-1.5 text-sm hover:bg-accent"
</button> onClick={() => onVMAction(vm, 'suspend')}
>
<Pause className="mr-2 h-4 w-4" />
Suspend
</button>
</>
)}
{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={() => { onClick={() => onVMAction(vm, 'resume')}
onVMAction?.(vm, 'resume');
setIsOpen(false);
}}
> >
<PlayCircle className="mr-2 h-4 w-4" /> <PlayCircle className="mr-2 h-4 w-4" />
Resume Resume
</button> </button>
)} )}
{vm.status === 'running' && (
<button
className="flex w-full items-center rounded-md px-2 py-1.5 text-sm hover:bg-accent"
onClick={() => {
onVMAction?.(vm, 'suspend');
setIsOpen(false);
}}
>
<Pause className="mr-2 h-4 w-4" />
Suspend
</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={() => { onClick={() => onSnapshotAction(vm, 'create')}
onSnapshotAction?.(vm, 'create');
setIsOpen(false);
}}
> >
<span className="mr-2 h-4 w-4">📸</span> 📸 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={() => { onClick={() => onSnapshotAction(vm, 'list')}
onSnapshotAction?.(vm, 'list');
setIsOpen(false);
}}
> >
<span className="mr-2 h-4 w-4">📋</span> 📋 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={() => { onClick={() => onMigrate(vm)}
onMigrate?.(vm);
setIsOpen(false);
}}
> >
<span className="mr-2 h-4 w-4">🚚</span> <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={() => { onClick={() => onClone(vm)}
onClone?.(vm);
setIsOpen(false);
}}
> >
<span className="mr-2 h-4 w-4">📋</span> <Copy className="mr-2 h-4 w-4" />
Clone Clone
</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-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={() => { onClick={() => onDelete(vm)}
onDelete?.(vm);
setIsOpen(false);
}}
> >
<X className="mr-2 h-4 w-4" /> <X className="mr-2 h-4 w-4" />
Delete Delete

View File

@ -1,6 +1,5 @@
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 { RefreshCw } from 'lucide-react'; import { RefreshCw } from 'lucide-react';
import { BackupJobList } from '@/components/Proxmox'; import { BackupJobList } from '@/components/Proxmox';
import { listProxmoxClusters, listProxmoxBackupJobs } from '@/lib/proxmoxClient'; import { listProxmoxClusters, listProxmoxBackupJobs } from '@/lib/proxmoxClient';
@ -10,8 +9,6 @@ import { toast } from 'sonner';
export function ProxmoxBackupPage() { export function ProxmoxBackupPage() {
const [clusters, setClusters] = useState<ClusterInfo[]>([]); const [clusters, setClusters] = useState<ClusterInfo[]>([]);
const [selectedClusterId, setSelectedClusterId] = useState<string>(''); const [selectedClusterId, setSelectedClusterId] = useState<string>('');
const [nodeInputValue, setNodeInputValue] = useState('localhost');
const [nodeId, setNodeId] = useState('localhost');
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const [jobs, setJobs] = useState<any[]>([]); const [jobs, setJobs] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@ -28,11 +25,12 @@ export function ProxmoxBackupPage() {
}); });
}, []); }, []);
const loadJobs = useCallback(async (clusterId: string, nId: string) => { const loadJobs = useCallback(async (clusterId: string) => {
if (!clusterId) return; if (!clusterId) return;
setIsLoading(true); setIsLoading(true);
try { try {
const data = await listProxmoxBackupJobs(clusterId, nId); // Backup jobs are cluster-level, not node-level
const data = await listProxmoxBackupJobs(clusterId, '');
setJobs(data); setJobs(data);
} catch (err) { } catch (err) {
console.error('Failed to load backup jobs:', err); console.error('Failed to load backup jobs:', err);
@ -43,19 +41,15 @@ export function ProxmoxBackupPage() {
}, []); }, []);
useEffect(() => { useEffect(() => {
if (selectedClusterId) loadJobs(selectedClusterId, nodeId); if (selectedClusterId) loadJobs(selectedClusterId);
}, [selectedClusterId, nodeId, loadJobs]); }, [selectedClusterId, loadJobs]);
const applyNodeId = () => {
setNodeId(nodeInputValue.trim() || 'localhost');
};
if (clusters.length === 0 && !isLoading) { if (clusters.length === 0 && !isLoading) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<h1 className="text-2xl font-bold">Backup Jobs</h1> <h1 className="text-2xl font-bold">Backup Jobs</h1>
<p className="text-muted-foreground">Manage Proxmox Backup Server jobs</p> <p className="text-muted-foreground">Manage Proxmox backup schedules</p>
</div> </div>
<div className="text-center py-12 text-muted-foreground"> <div className="text-center py-12 text-muted-foreground">
<p>No Proxmox clusters configured.</p> <p>No Proxmox clusters configured.</p>
@ -70,16 +64,12 @@ export function ProxmoxBackupPage() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold">Backup Jobs</h1> <h1 className="text-2xl font-bold">Backup Jobs</h1>
<p className="text-muted-foreground">Manage Proxmox Backup Server jobs</p> <p className="text-muted-foreground">Manage Proxmox backup schedules</p>
</div> </div>
</div> <div className="flex items-center space-x-2">
{clusters.length > 1 && (
<div className="flex items-center gap-3 flex-wrap">
{clusters.length > 0 && (
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">Cluster:</span>
<select <select
className="text-sm border rounded px-2 py-1 bg-background" className="rounded-md border px-3 py-1.5 text-sm bg-background"
value={selectedClusterId} value={selectedClusterId}
onChange={(e) => setSelectedClusterId(e.target.value)} onChange={(e) => setSelectedClusterId(e.target.value)}
> >
@ -87,32 +77,17 @@ export function ProxmoxBackupPage() {
<option key={c.id} value={c.id}>{c.name}</option> <option key={c.id} value={c.id}>{c.name}</option>
))} ))}
</select> </select>
</div> )}
)} <Button variant="outline" size="sm" onClick={() => loadJobs(selectedClusterId)}>
<div className="flex items-center gap-2"> <RefreshCw className="mr-2 h-4 w-4" />
<span className="text-sm text-muted-foreground">Node:</span> Refresh
<Input </Button>
className="w-36 h-8 text-sm"
value={nodeInputValue}
onChange={(e) => setNodeInputValue(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') applyNodeId(); }}
placeholder="localhost"
/>
<Button variant="outline" size="sm" onClick={applyNodeId}>Apply</Button>
</div> </div>
<Button
variant="outline"
size="sm"
onClick={() => loadJobs(selectedClusterId, nodeId)}
>
<RefreshCw className="mr-2 h-4 w-4" />
Refresh
</Button>
</div> </div>
<BackupJobList <BackupJobList
jobs={jobs} jobs={jobs}
onRefresh={() => loadJobs(selectedClusterId, nodeId)} onRefresh={() => loadJobs(selectedClusterId)}
/> />
</div> </div>
); );

View File

@ -88,11 +88,6 @@ export function ProxmoxVMsPage() {
<VMList <VMList
vms={vms} vms={vms}
onRefresh={() => loadVms(selectedClusterId)} onRefresh={() => loadVms(selectedClusterId)}
onVMAction={(_vm, _action) => { toast.info('VM action — not yet implemented'); }}
onSnapshotAction={(_vm, _action) => { toast.info('Snapshot action — not yet implemented'); }}
onMigrate={(_vm) => { toast.info('Migrate — not yet implemented'); }}
onClone={(_vm) => { toast.info('Clone — not yet implemented'); }}
onDelete={(_vm) => { toast.info('Delete — not yet implemented'); }}
selectedVMs={selectedVMs} selectedVMs={selectedVMs}
onToggleSelect={(vm) => { onToggleSelect={(vm) => {
setSelectedVMs((prev) => { setSelectedVMs((prev) => {