From d336c4f5b72f814c88a83058e13f13d09f477bc7 Mon Sep 17 00:00:00 2001 From: MacRimi Date: Mon, 2 Feb 2026 17:29:14 +0100 Subject: [PATCH] Update modal vm --- AppImage/components/virtual-machines.tsx | 403 ++++++++++++++++++++++- AppImage/scripts/flask_server.py | 195 +++++++++++ 2 files changed, 593 insertions(+), 5 deletions(-) diff --git a/AppImage/components/virtual-machines.tsx b/AppImage/components/virtual-machines.tsx index c7e64115..f42c375a 100644 --- a/AppImage/components/virtual-machines.tsx +++ b/AppImage/components/virtual-machines.tsx @@ -8,7 +8,8 @@ import { Badge } from "./ui/badge" import { Progress } from "./ui/progress" import { Button } from "./ui/button" import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./ui/dialog" -import { Server, Play, Square, Cpu, MemoryStick, HardDrive, Network, Power, RotateCcw, StopCircle, Container, ChevronDown, ChevronUp, Terminal } from 'lucide-react' +import { Server, Play, Square, Cpu, MemoryStick, HardDrive, Network, Power, RotateCcw, StopCircle, Container, ChevronDown, ChevronUp, Terminal, Archive, Loader2, ChevronLeft, ChevronRight } from 'lucide-react' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select" import useSWR from "swr" import { MetricsView } from "./metrics-dialog" import { LxcTerminalModal } from "./lxc-terminal-modal" @@ -121,6 +122,28 @@ interface VMDetails extends VMData { } } +interface BackupInfo { + volid: string + storage: string + type: string + size: number + size_human: string + timestamp: number + date: string +} + +interface BackupStorage { + storage: string + type: string + content: string + total: number + used: number + avail: number + total_human: string + used_human: string + avail_human: string +} + const fetcher = async (url: string) => { return fetchApi(url) } @@ -271,6 +294,14 @@ export function VirtualMachines() { const [ipsLoaded, setIpsLoaded] = useState(false) const [loadingIPs, setLoadingIPs] = useState(false) const [networkUnit, setNetworkUnit] = useState<"Bytes" | "Bits">("Bytes") + + // Backup states + const [modalPage, setModalPage] = useState(0) // 0 = main, 1 = backups + const [vmBackups, setVmBackups] = useState([]) + const [backupStorages, setBackupStorages] = useState([]) + const [loadingBackups, setLoadingBackups] = useState(false) + const [creatingBackup, setCreatingBackup] = useState(false) + const [selectedBackupStorage, setSelectedBackupStorage] = useState("") useEffect(() => { const fetchLXCIPs = async () => { @@ -347,16 +378,78 @@ export function VirtualMachines() { setShowNotes(false) setIsEditingNotes(false) setEditedNotes("") + setModalPage(0) + setVmBackups([]) setDetailsLoading(true) try { - const details = await fetchApi(`/api/vms/${vm.vmid}`) + const [details, storagesData] = await Promise.all([ + fetchApi(`/api/vms/${vm.vmid}`), + fetchApi('/api/backup-storages') + ]) setVMDetails(details) + setBackupStorages(storagesData.storages || []) + if (storagesData.storages?.length > 0) { + setSelectedBackupStorage(storagesData.storages[0].storage) + } } catch (error) { console.error("Error fetching VM details:", error) } finally { setDetailsLoading(false) } } + + // Fetch backups for current VM + const fetchVmBackups = async (vmid: number) => { + setLoadingBackups(true) + try { + const [backupsData, storagesData] = await Promise.all([ + fetchApi(`/api/vms/${vmid}/backups`), + fetchApi('/api/backup-storages') + ]) + setVmBackups(backupsData.backups || []) + setBackupStorages(storagesData.storages || []) + if (storagesData.storages?.length > 0 && !selectedBackupStorage) { + setSelectedBackupStorage(storagesData.storages[0].storage) + } + } catch (error) { + console.error("Error fetching backups:", error) + } finally { + setLoadingBackups(false) + } + } + + // Create backup + const handleCreateBackup = async () => { + if (!selectedVM || !selectedBackupStorage) return + + setCreatingBackup(true) + try { + await fetchApi(`/api/vms/${selectedVM.vmid}/backup`, { + method: "POST", + body: JSON.stringify({ + storage: selectedBackupStorage, + mode: "snapshot", + compress: "zstd" + }), + }) + // Refresh backups list after a short delay + setTimeout(() => { + fetchVmBackups(selectedVM.vmid) + }, 2000) + } catch (error) { + console.error("Error creating backup:", error) + } finally { + setCreatingBackup(false) + } + } + + // Switch to backups page + const handleShowBackups = () => { + if (selectedVM) { + setModalPage(1) + fetchVmBackups(selectedVM.vmid) + } + } const handleMetricsClick = () => { setCurrentView("metrics") @@ -1144,13 +1237,230 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { -
-
+
+ {/* Mobile carousel container */} +
+
+ {/* Page 0: Main content */} +
+
+ {selectedVM && ( + <> +
+ + +
+
+
CPU Usage
+
+ {(selectedVM.cpu * 100).toFixed(1)}% +
+ +
+
+
Memory
+
+ {(selectedVM.mem / 1024 ** 3).toFixed(1)} / {(selectedVM.maxmem / 1024 ** 3).toFixed(1)} GB +
+ +
+
+
Disk
+
+ {(selectedVM.disk / 1024 ** 3).toFixed(1)} / {(selectedVM.maxdisk / 1024 ** 3).toFixed(1)} GB +
+ +
+
+
Disk I/O
+
+
+ + {((selectedVM.diskread || 0) / 1024 ** 2).toFixed(2)} MB +
+
+ + {((selectedVM.diskwrite || 0) / 1024 ** 2).toFixed(2)} MB +
+
+
+
+
Network I/O
+
+
+ + {formatNetworkTraffic(selectedVM.netin || 0, networkUnit)} +
+
+ + {formatNetworkTraffic(selectedVM.netout || 0, networkUnit)} +
+
+
+
+ {getOSIcon(vmDetails?.os_info, selectedVM.type)} +
+
+
+
+
+ + {detailsLoading ? ( +
Loading configuration...
+ ) : vmDetails?.config ? ( + + +
+

Resources

+
+ + +
+
+
+ {vmDetails.config.cores && ( +
+
CPU Cores
+
{vmDetails.config.cores}
+
+ )} + {vmDetails.config.memory && ( +
+
Memory
+
{vmDetails.config.memory} MB
+
+ )} + {vmDetails.config.swap && ( +
+
Swap
+
{vmDetails.config.swap} MB
+
+ )} +
+ {selectedVM?.type === "lxc" && vmDetails?.lxc_ip_info && ( +
+

IP Addresses

+
+ {vmDetails.lxc_ip_info.real_ips.map((ip, index) => ( + {ip} + ))} + {vmDetails.lxc_ip_info.docker_ips.map((ip, index) => ( + {ip} (Bridge) + ))} +
+
+ )} +
+
+ ) : null} + + )} +
+
+ + {/* Page 1: Backups */} +
+
+ + +

Create Backup

+
+
+ + +
+ +
+
+
+ + + +

+ Backups ({vmBackups.length}) +

+ {loadingBackups ? ( +
+ + Loading backups... +
+ ) : vmBackups.length === 0 ? ( +
+ No backups found +
+ ) : ( +
+ {vmBackups.map((backup, index) => ( +
+
+
+
{backup.date}
+
{backup.storage}
+
+ {backup.size_human} +
+
+ ))} +
+ )} +
+
+
+
+
+ + {/* Mobile pagination dots */} +
+
+
+ + {/* Desktop layout */} +
+
{selectedVM && ( <>
@@ -1765,12 +2075,95 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { )} + + {/* Desktop Backups Section */} + + +
+

+ Backups +

+ +
+ +
+ {/* Create Backup */} +
+ +
+ + +
+
+ + {/* Backup List */} +
+ + {loadingBackups ? ( +
+ + Loading... +
+ ) : vmBackups.length === 0 ? ( +
+ No backups found +
+ ) : ( +
+ {vmBackups.slice(0, 5).map((backup, index) => ( +
+ {backup.date} + {backup.size_human} +
+ ))} + {vmBackups.length > 5 && ( +
+ +{vmBackups.length - 5} more +
+ )} +
+ )} +
+
+
+
) : null} )}
+
{/* Terminal button for LXC containers - only when running */} diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py index ec5c0ffa..273c2f1f 100644 --- a/AppImage/scripts/flask_server.py +++ b/AppImage/scripts/flask_server.py @@ -5539,6 +5539,201 @@ def api_backups(): 'total': 0 }) +@app.route('/api/backup-storages', methods=['GET']) +@require_auth +def api_backup_storages(): + """Get list of storages available for backups""" + try: + storages = [] + + # Get all storages + result = subprocess.run(['pvesh', 'get', '/storage', '--output-format', 'json'], + capture_output=True, text=True, timeout=10) + + if result.returncode == 0: + all_storages = json.loads(result.stdout) + + for storage in all_storages: + storage_id = storage.get('storage', '') + content = storage.get('content', '') + storage_type = storage.get('type', '') + + # Only include storages that support backup content + if 'backup' in content or storage_type == 'pbs': + # Get storage status for space info + try: + status_result = subprocess.run( + ['pvesh', 'get', f'/storage/{storage_id}/status', '--output-format', 'json'], + capture_output=True, text=True, timeout=10 + ) + + total = 0 + used = 0 + avail = 0 + + if status_result.returncode == 0: + status = json.loads(status_result.stdout) + total = status.get('total', 0) + used = status.get('used', 0) + avail = status.get('avail', 0) + + storages.append({ + 'storage': storage_id, + 'type': storage_type, + 'content': content, + 'total': total, + 'used': used, + 'avail': avail, + 'total_human': format_bytes(total), + 'used_human': format_bytes(used), + 'avail_human': format_bytes(avail) + }) + except: + storages.append({ + 'storage': storage_id, + 'type': storage_type, + 'content': content, + 'total': 0, + 'used': 0, + 'avail': 0 + }) + + return jsonify({'storages': storages}) + + except Exception as e: + return jsonify({'error': str(e), 'storages': []}) + +@app.route('/api/vms//backup', methods=['POST']) +@require_auth +def api_create_backup(vmid): + """Create a backup for a VM or LXC container""" + try: + data = request.get_json() or {} + storage = data.get('storage', 'local') + mode = data.get('mode', 'snapshot') # snapshot, suspend, stop + compress = data.get('compress', 'zstd') # none, lzo, gzip, zstd + + # Get node for this VM + node = None + + # Try to find VM in qemu + try: + result = subprocess.run(['pvesh', 'get', '/cluster/resources', '--type', 'vm', '--output-format', 'json'], + capture_output=True, text=True, timeout=10) + if result.returncode == 0: + vms = json.loads(result.stdout) + for vm in vms: + if vm.get('vmid') == vmid: + node = vm.get('node') + break + except: + pass + + if not node: + return jsonify({'error': 'VM not found'}), 404 + + # Create backup using vzdump + cmd = [ + 'vzdump', str(vmid), + '--storage', storage, + '--mode', mode, + '--compress', compress, + '--node', node + ] + + # Run vzdump in background + result = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + + return jsonify({ + 'success': True, + 'message': f'Backup started for VM {vmid}', + 'storage': storage, + 'mode': mode, + 'compress': compress + }) + + except Exception as e: + return jsonify({'error': str(e)}), 500 + +@app.route('/api/vms//backups', methods=['GET']) +@require_auth +def api_vm_backups(vmid): + """Get list of backups for a specific VM/LXC""" + try: + backups = [] + + # Get list of storage locations + result = subprocess.run(['pvesh', 'get', '/storage', '--output-format', 'json'], + capture_output=True, text=True, timeout=10) + + if result.returncode == 0: + storages = json.loads(result.stdout) + + for storage in storages: + storage_id = storage.get('storage') + storage_type = storage.get('type') + content = storage.get('content', '') + + # Only check storages that can contain backups + if 'backup' in content or storage_type == 'pbs': + try: + content_result = subprocess.run( + ['pvesh', 'get', f'/nodes/$(hostname)/storage/{storage_id}/content', '--output-format', 'json'], + capture_output=True, text=True, timeout=15, shell=True + ) + + if content_result.returncode == 0: + contents = json.loads(content_result.stdout) + + for item in contents: + if item.get('content') == 'backup': + volid = item.get('volid', '') + + # Check if this backup belongs to the requested vmid + backup_vmid = None + backup_type = None + + if 'vzdump-qemu-' in volid: + backup_type = 'qemu' + try: + backup_vmid = int(volid.split('vzdump-qemu-')[1].split('-')[0]) + except: + pass + elif 'vzdump-lxc-' in volid: + backup_type = 'lxc' + try: + backup_vmid = int(volid.split('vzdump-lxc-')[1].split('-')[0]) + except: + pass + + if backup_vmid == vmid: + size = item.get('size', 0) + ctime = item.get('ctime', 0) + + backups.append({ + 'volid': volid, + 'storage': storage_id, + 'type': backup_type, + 'size': size, + 'size_human': format_bytes(size), + 'timestamp': ctime, + 'date': datetime.fromtimestamp(ctime).strftime('%Y-%m-%d %H:%M') if ctime else '' + }) + except: + continue + + # Sort by timestamp (newest first) + backups.sort(key=lambda x: x['timestamp'], reverse=True) + + return jsonify({ + 'backups': backups, + 'vmid': vmid, + 'total': len(backups) + }) + + except Exception as e: + return jsonify({'error': str(e), 'backups': [], 'total': 0}) + @app.route('/api/events', methods=['GET']) @require_auth def api_events():