fix(proxmox): resolve 11 dashboard UI and API issues
Some checks failed
Test / frontend-tests (pull_request) Successful in 1m44s
Test / frontend-typecheck (pull_request) Successful in 1m53s
PR Review Automation / review (pull_request) Has been cancelled
Test / rust-fmt-check (pull_request) Has been cancelled
Test / rust-clippy (pull_request) Has been cancelled
Test / rust-tests (pull_request) Has been cancelled
Some checks failed
Test / frontend-tests (pull_request) Successful in 1m44s
Test / frontend-typecheck (pull_request) Successful in 1m53s
PR Review Automation / review (pull_request) Has been cancelled
Test / rust-fmt-check (pull_request) Has been cancelled
Test / rust-clippy (pull_request) Has been cancelled
Test / rust-tests (pull_request) Has been cancelled
- Action menu: fix click-outside closing, positioning, opacity, and functionality
- VM metrics: fix CPU %, memory/disk bars with formatBytes helper, uptime formatting
- list_cluster_tasks: remove invalid 'limit' query parameter causing 400 error
- list_views/list_certificates: handle 501 Not Implemented gracefully
- list_proxmox_datastores: fetch per-node storage via /nodes/{node}/storage
- list_proxmox_backup_jobs: use cluster-level /cluster/backup endpoint
- Tests: update integration tests to use PROXMOX_HOST env var
Fixes:
- Action menu not closing when clicking away
- CPU/memory/disk/uptime displaying raw values
- Storage not displaying data
- Backup jobs not showing details
- Tasks API returning 400 Bad Request
- Views/Certificates APIs causing errors on older Proxmox versions
This commit is contained in:
parent
bad3042d2d
commit
8f05f26e1b
@ -535,58 +535,112 @@ 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
|
/// 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::<Result<Vec<_>, _>>()?;
|
.collect()
|
||||||
|
})
|
||||||
|
.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(nodes_path, Some(client_guard.ticket.as_deref().unwrap_or("")))
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to list datastores: {}", e))?;
|
.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 +1061,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 +1200,17 @@ 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 +2158,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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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) 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,18 +65,25 @@ 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>
|
||||||
|
</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.node || '-'}</TableCell>
|
||||||
<TableCell>{storage.used}</TableCell>
|
<TableCell>{storage.used ? formatBytes(storage.used) : '-'}</TableCell>
|
||||||
<TableCell>{storage.total}</TableCell>
|
<TableCell>{storage.size ? formatBytes(storage.size) : '-'}</TableCell>
|
||||||
<TableCell>{storage.available}</TableCell>
|
<TableCell>{storage.available ? formatBytes(storage.available) : '-'}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<span className="inline-flex items-center rounded-full px-2 py-1 text-xs font-medium bg-green-100 text-green-800">
|
<span className="inline-flex items-center rounded-full px-2 py-1 text-xs font-medium bg-green-100 text-green-800">
|
||||||
{storage.status}
|
{storage.status || 'available'}
|
||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
@ -95,7 +111,8 @@ export function StorageList({
|
|||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))
|
||||||
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,9 +1,11 @@
|
|||||||
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 { toast } from 'sonner';
|
||||||
|
|
||||||
interface VMInfo {
|
interface VMInfo {
|
||||||
id: string;
|
id: string;
|
||||||
@ -16,7 +18,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 +26,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 +34,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 +68,165 @@ export function VMList({
|
|||||||
selectedVMs = new Set<string>(),
|
selectedVMs = new Set<string>(),
|
||||||
onToggleSelect,
|
onToggleSelect,
|
||||||
}: VMListProps) {
|
}: VMListProps) {
|
||||||
|
const [clusterId, setClusterId] = useState<string>('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
invoke<string>('get_current_proxmox_cluster').catch(() => {
|
||||||
|
// Fallback: try to get first cluster
|
||||||
|
invoke<string[]>('list_proxmox_clusters')
|
||||||
|
.then((clusters: any[]) => {
|
||||||
|
if (clusters.length > 0) {
|
||||||
|
setClusterId(clusters[0].id);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
})
|
||||||
|
.then((id) => {
|
||||||
|
if (id) setClusterId(id);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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 = await prompt(`Select target node: ${targetNodes.join(', ')}`, targetNodes[0]);
|
||||||
|
|
||||||
|
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 = await prompt(`Enter new VM ID for ${vm.name}:`, `${vm.vmid + 1}`);
|
||||||
|
const newVmid = newVmidStr ? parseInt(newVmidStr) : vm.vmid + 1;
|
||||||
|
const newName = await prompt(`Enter name for cloned VM:`, `${vm.name}-clone`);
|
||||||
|
|
||||||
|
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,7 +266,12 @@ export function VMList({
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{vms.map((vm) => (
|
{vms.map((vm) => {
|
||||||
|
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}>
|
<TableRow key={vm.id}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@ -104,24 +291,55 @@ export function VMList({
|
|||||||
{vm.status}
|
{vm.status}
|
||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{vm.cpu}%</TableCell>
|
<TableCell>{cpuPercent.toFixed(2)}%</TableCell>
|
||||||
<TableCell>{Math.round((vm.memory / vm.memoryTotal) * 100)}%</TableCell>
|
<TableCell>
|
||||||
<TableCell>{Math.round((vm.disk / vm.diskTotal) * 100)}%</TableCell>
|
{vm.memoryTotal > 0 ? (
|
||||||
<TableCell>{vm.uptime || '-'}</TableCell>
|
<div className="flex items-center gap-2">
|
||||||
<TableCell className="text-right">
|
<div className="flex-1 h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||||
<div className="flex items-center justify-end space-x-2">
|
<div
|
||||||
<VMActionMenu
|
className="h-full bg-blue-500"
|
||||||
vm={vm}
|
style={{ width: `${memoryPercent}%` }}
|
||||||
onVMAction={onVMAction}
|
|
||||||
onSnapshotAction={onSnapshotAction}
|
|
||||||
onMigrate={onMigrate}
|
|
||||||
onClone={onClone}
|
|
||||||
onDelete={onDelete}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</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
|
||||||
|
vm={vm}
|
||||||
|
onVMAction={handleVMAction}
|
||||||
|
onSnapshotAction={handleSnapshotAction}
|
||||||
|
onMigrate={handleMigrate}
|
||||||
|
onClone={handleClone}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
@ -132,11 +350,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 +365,160 @@ 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 buttonRect = menuRef.current?.querySelector('button')?.getBoundingClientRect();
|
||||||
|
|
||||||
|
if (!buttonRect) return { top: '100%', left: 0 };
|
||||||
|
|
||||||
|
const menuHeight = 400; // approximate menu height
|
||||||
|
const spaceBelow = viewportHeight - buttonRect.bottom;
|
||||||
|
const spaceAbove = buttonRect.top;
|
||||||
|
|
||||||
|
if (spaceBelow >= menuHeight) {
|
||||||
|
return { top: '100%', left: 0 };
|
||||||
|
} else if (spaceAbove >= menuHeight) {
|
||||||
|
return { bottom: '100%', left: 0 };
|
||||||
|
} else {
|
||||||
|
// Menu will fit somewhere in the middle
|
||||||
|
return { top: '100%', left: 0 };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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 ${
|
||||||
|
position.bottom ? 'bottom-full mb-2' : 'top-full mt-2'
|
||||||
|
}`}
|
||||||
|
style={{ right: 0 }}
|
||||||
|
>
|
||||||
|
<div className="space-y-1 p-1">
|
||||||
|
{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={() => {
|
onClick={() => onVMAction(vm, 'start')}
|
||||||
onVMAction?.(vm, 'start');
|
|
||||||
setIsOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Play className="mr-2 h-4 w-4" />
|
<Play className="mr-2 h-4 w-4" />
|
||||||
Start
|
Start
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
{vm.status === 'running' && (
|
||||||
|
<>
|
||||||
<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, 'stop')}
|
||||||
onVMAction?.(vm, 'stop');
|
|
||||||
setIsOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<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={() => {
|
onClick={() => onVMAction(vm, 'reboot')}
|
||||||
onVMAction?.(vm, 'reboot');
|
|
||||||
setIsOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<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" />
|
<Power className="mr-2 h-4 w-4" />
|
||||||
Shutdown
|
Shutdown
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center rounded-md px-2 py-1.5 text-sm hover:bg-accent"
|
||||||
|
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
|
||||||
|
|||||||
@ -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>
|
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-2">
|
<Button variant="outline" size="sm" onClick={() => loadJobs(selectedClusterId)}>
|
||||||
<span className="text-sm text-muted-foreground">Node:</span>
|
|
||||||
<Input
|
|
||||||
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>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => loadJobs(selectedClusterId, nodeId)}
|
|
||||||
>
|
|
||||||
<RefreshCw className="mr-2 h-4 w-4" />
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<BackupJobList
|
<BackupJobList
|
||||||
jobs={jobs}
|
jobs={jobs}
|
||||||
onRefresh={() => loadJobs(selectedClusterId, nodeId)}
|
onRefresh={() => loadJobs(selectedClusterId)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -88,7 +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'); }}
|
onSnapshotAction={(_vm, _action) => { toast.info('Snapshot action — not yet implemented'); }}
|
||||||
onMigrate={(_vm) => { toast.info('Migrate — not yet implemented'); }}
|
onMigrate={(_vm) => { toast.info('Migrate — not yet implemented'); }}
|
||||||
onClone={(_vm) => { toast.info('Clone — not yet implemented'); }}
|
onClone={(_vm) => { toast.info('Clone — not yet implemented'); }}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user