"use client" import type React from "react" import { useState, useMemo, useEffect } from "react" import { Card, CardContent, CardHeader, CardTitle } from "./ui/card" import { Badge } from "./ui/badge" import { Progress } from "./ui/progress" import { Button } from "./ui/button" import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "./ui/dialog" import { Server, Play, Square, Cpu, MemoryStick, HardDrive, Network, Power, RotateCcw, StopCircle, Container, ChevronDown, ChevronUp, Terminal, Archive, Plus, Loader2, Clock, Database, Shield, Bell, FileText, Settings2, Activity } from 'lucide-react' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select" import { Checkbox } from "./ui/checkbox" import { Textarea } from "./ui/textarea" import { Label } from "./ui/label" import useSWR from "swr" import { MetricsView } from "./metrics-dialog" import { LxcTerminalModal } from "./lxc-terminal-modal" import { formatStorage } from "../lib/utils" import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network" import { fetchApi } from "../lib/api-config" interface VMData { vmid: number name: string status: string type: string cpu: number mem: number maxmem: number disk: number maxdisk: number uptime: number netin?: number netout?: number diskread?: number diskwrite?: number ip?: string } interface VMConfig { cores?: number memory?: number swap?: number rootfs?: string net0?: string net1?: string net2?: string nameserver?: string searchdomain?: string onboot?: number unprivileged?: number features?: string ostype?: string arch?: string hostname?: string // VM specific sockets?: number scsi0?: string ide0?: string boot?: string description?: string // Added for notes // Hardware specific numa?: boolean bios?: string machine?: string vga?: string agent?: boolean tablet?: boolean localtime?: boolean // Storage specific scsihw?: string efidisk0?: string tpmstate0?: string // Mount points for LXC mp0?: string mp1?: string mp2?: string mp3?: string mp4?: string mp5?: string // PCI Passthrough hostpci0?: string hostpci1?: string hostpci2?: string hostpci3?: string hostpci4?: string hostpci5?: string // USB Devices usb0?: string usb1?: string usb2?: string // Serial Devices serial0?: string serial1?: string // Advanced vmgenid?: string smbios1?: string meta?: string // CPU cpu?: string [key: string]: any } interface VMDetails extends VMData { config?: VMConfig node?: string vm_type?: string os_info?: { id?: string version_id?: string name?: string pretty_name?: string } hardware_info?: { privileged?: boolean | null gpu_passthrough?: string[] devices?: string[] } lxc_ip_info?: { all_ips: string[] real_ips: string[] docker_ips: string[] primary_ip: string } } interface BackupStorage { storage: string type: string content: string total: number used: number avail: number total_human?: string used_human?: string avail_human?: string } interface VMBackup { volid: string storage: string type: string size: number size_human: string timestamp: number date: string notes?: string } // Sprint 13.29: shape returned by /api/lxc//mount-points. Lives // next to VMBackup since both are LXC-modal data structures. interface LxcMountPoint { mp_index: string // "mp0", "mp1", "" for ad-hoc source: string target: string type: "pve_volume" | "pve_storage_bind" | "host_bind" | "ad_hoc" origin_storage: string origin_storage_type: string origin_label: string config_options: Record config_flags: string[] total_bytes: number | null used_bytes: number | null available_bytes: number | null runtime_mounted?: boolean | null runtime_source?: string runtime_fstype?: string runtime_options?: string runtime_readonly?: boolean runtime_reachable?: boolean runtime_error?: string | null } const fetcher = async (url: string) => { return fetchApi(url) } const formatBytes = (bytes: number | undefined, isNetwork: boolean = false): string => { if (!bytes || bytes === 0) return isNetwork ? "0 B/s" : "0 B" if (isNetwork) { const networkUnit = getNetworkUnit() return formatNetworkTraffic(bytes, networkUnit, 2) } // For non-network (disk), use standard bytes const k = 1024 const sizes = ["B", "KB", "MB", "GB", "TB"] const i = Math.floor(Math.log(bytes) / Math.log(k)) return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}` } const formatUptime = (seconds: number) => { const days = Math.floor(seconds / 86400) const hours = Math.floor((seconds % 86400) / 3600) const minutes = Math.floor((seconds % 3600) / 60) return `${days}d ${hours}h ${minutes}m` } const extractIPFromConfig = (config?: VMConfig, lxcIPInfo?: VMDetails["lxc_ip_info"]): string => { // Use primary IP from lxc-info if available if (lxcIPInfo?.primary_ip) { return lxcIPInfo.primary_ip } if (!config) return "DHCP" // Check net0, net1, net2, etc. for (let i = 0; i < 10; i++) { const netKey = `net${i}` const netConfig = config[netKey] if (netConfig && typeof netConfig === "string") { // Look for ip=x.x.x.x/xx or ip=x.x.x.x pattern const ipMatch = netConfig.match(/ip=([0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3})/) if (ipMatch) { return ipMatch[1] // Return just the IP without CIDR } // Check if it's explicitly DHCP if (netConfig.includes("ip=dhcp")) { return "DHCP" } } } return "DHCP" } // const formatStorage = (sizeInGB: number): string => { // if (sizeInGB < 1) { // // Less than 1 GB, show in MB // return `${(sizeInGB * 1024).toFixed(1)} MB` // } else if (sizeInGB < 1024) { // // Less than 1024 GB, show in GB // return `${sizeInGB.toFixed(1)} GB` // } else { // // 1024 GB or more, show in TB // return `${(sizeInGB / 1024).toFixed(1)} TB` // } // } const getUsageColor = (percent: number): string => { if (percent >= 95) return "text-red-500" if (percent >= 86) return "text-orange-500" if (percent >= 71) return "text-yellow-500" return "text-foreground" } // Generate consistent color for storage names const storageColors = [ { bg: "bg-blue-500/20", text: "text-blue-400", border: "border-blue-500/30" }, { bg: "bg-emerald-500/20", text: "text-emerald-400", border: "border-emerald-500/30" }, { bg: "bg-purple-500/20", text: "text-purple-400", border: "border-purple-500/30" }, { bg: "bg-amber-500/20", text: "text-amber-400", border: "border-amber-500/30" }, { bg: "bg-pink-500/20", text: "text-pink-400", border: "border-pink-500/30" }, { bg: "bg-cyan-500/20", text: "text-cyan-400", border: "border-cyan-500/30" }, { bg: "bg-rose-500/20", text: "text-rose-400", border: "border-rose-500/30" }, { bg: "bg-indigo-500/20", text: "text-indigo-400", border: "border-indigo-500/30" }, ] const getStorageColor = (storageName: string) => { // Generate a consistent hash from storage name let hash = 0 for (let i = 0; i < storageName.length; i++) { hash = storageName.charCodeAt(i) + ((hash << 5) - hash) } const index = Math.abs(hash) % storageColors.length return storageColors[index] } const getIconColor = (percent: number): string => { if (percent >= 95) return "text-red-500" if (percent >= 86) return "text-orange-500" if (percent >= 71) return "text-yellow-500" return "text-green-500" } const getProgressColor = (percent: number): string => { if (percent >= 95) return "[&>div]:bg-red-500" if (percent >= 86) return "[&>div]:bg-orange-500" if (percent >= 71) return "[&>div]:bg-yellow-500" return "[&>div]:bg-blue-500" } const getModalProgressColor = (percent: number): string => { if (percent >= 95) return "[&>div]:bg-red-500" if (percent >= 86) return "[&>div]:bg-orange-500" if (percent >= 71) return "[&>div]:bg-yellow-500" return "[&>div]:bg-blue-500" } const getOSIcon = (osInfo: VMDetails["os_info"] | undefined, vmType: string): React.ReactNode => { if (vmType !== "lxc" || !osInfo?.id) { return null } const osId = osInfo.id.toLowerCase() switch (osId) { case "debian": return Debian case "ubuntu": return Ubuntu case "alpine": return Alpine case "arch": return Arch default: return null } } // Sprint 13.29: render a single LXC mount point row. // Lifted out of the main component so the Mount Points tab renders // uniformly for both configured mpX entries and ad-hoc inside-CT // remote mounts. Capacity displays whatever the backend resolved — // PVE storage stats, `df` of host path, or n/a for ad-hoc. function MountPointCard({ mp }: { mp: LxcMountPoint }) { const isStale = mp.runtime_reachable === false const isReadonly = !isStale && mp.runtime_readonly === true const isDivergent = mp.runtime_mounted === false // configured but not actually mounted const cardClasses = isStale ? "border-red-500/50 bg-red-500/5" : isDivergent ? "border-amber-500/40 bg-amber-500/5" : isReadonly ? "border-amber-500/30 bg-amber-500/5" : "border border-white/10 sm:border-border bg-white/5 sm:bg-card" const typeBadgeClass: Record = { pve_volume: "bg-cyan-500/10 text-cyan-400 border-cyan-500/20", pve_storage_bind: "bg-blue-500/10 text-blue-400 border-blue-500/20", host_bind: "bg-purple-500/10 text-purple-400 border-purple-500/20", ad_hoc: "bg-amber-500/10 text-amber-400 border-amber-500/20", } const typeLabel: Record = { pve_volume: "PVE volume", pve_storage_bind: "bind from PVE storage", host_bind: "bind from host", ad_hoc: "ad-hoc inside CT", } const fmtBytes = (b: number | null | undefined) => { if (b == null) return "—" const gb = b / 1024 ** 3 if (gb < 1) return `${(gb * 1024).toFixed(1)} MB` if (gb >= 1000) return `${(gb / 1024).toFixed(2)} TB` return `${gb.toFixed(2)} GB` } const usedPct = mp.total_bytes && mp.used_bytes != null && mp.total_bytes > 0 ? Math.round((mp.used_bytes / mp.total_bytes) * 100) : null // Parse mount options (runtime if available, else config flags) into // flag chips + key=value pairs. Same UX as the Remote Mounts modal. const optsString = mp.runtime_options || (mp.config_flags || []).join(",") const optsEntries = (optsString || "") .split(",") .filter(Boolean) .map((o) => { const eq = o.indexOf("=") return eq === -1 ? { key: o, value: null as string | null } : { key: o.slice(0, eq), value: o.slice(eq + 1) } }) const flags = optsEntries.filter((o) => o.value === null).map((o) => o.key) const keyValues = optsEntries.filter((o) => o.value !== null) as Array<{ key: string; value: string }> return (

{mp.target}

{mp.mp_index && ( {mp.mp_index} )} {typeLabel[mp.type]} {mp.runtime_fstype && ( {mp.runtime_fstype} )}
{isStale ? "stale" : isDivergent ? "not mounted" : isReadonly ? "read-only" : mp.runtime_mounted === null ? "stopped" : "mounted"}
{/* Source / Mounted-at info — what host resource backs the mount, and where it shows up inside the CT. The header already shows the target but it's worth surfacing the source/target relationship explicitly here so the user gets the full host→container path at a glance. */}
Source (host):{" "} {mp.origin_label || mp.source} {mp.origin_storage && mp.origin_storage_type && ( ({mp.origin_storage_type} storage) )}
Mounted at (CT):{" "} {mp.target}
{/* Capacity — total/used/available with progress bar. Available even when CT is stopped because numbers come from the host. */} {mp.total_bytes != null && (
90 ? "[&>div]:bg-red-500" : (usedPct ?? 0) > 75 ? "[&>div]:bg-yellow-500" : "[&>div]:bg-blue-500" }`} />

Total

{fmtBytes(mp.total_bytes)}

Used

{fmtBytes(mp.used_bytes)} {usedPct != null && `(${usedPct}%)`}

Available

{fmtBytes(mp.available_bytes)}

)} {/* Mount attributes — config_options/flags from the mpX line in the LXC config (backup=0, shared=1, ro, replicate, etc.). Hidden when there's nothing to show. */} {(() => { const configEntries: Array<{ key: string; value: string | null }> = [] for (const k of Object.keys(mp.config_options || {})) { configEntries.push({ key: k, value: mp.config_options[k] }) } for (const f of mp.config_flags || []) { configEntries.push({ key: f, value: null }) } if (configEntries.length === 0) return null return (

Mount attributes (LXC config)

{configEntries.map((e) => ( {e.key}{e.value !== null ? `=${e.value}` : ""} ))}
) })()} {/* Runtime mount options — what the kernel actually uses (vers, rsize, hard, sec, ...). Only meaningful when the CT is running; for stopped CTs we hide this section because the values would just repeat the config flags above. Sprint 13.29 detail: we already render the runtime fstype as a badge in the header, so it's fine to leave this unlabelled-for-state — only show "(declared)" suffix in the rare case where there's no runtime data but flags do exist. */} {(mp.runtime_mounted === true) && (keyValues.length > 0 || flags.length > 0) && (

Runtime mount options

{flags.map((f) => ( {f} ))}
{keyValues.length > 0 && (
{keyValues.map((kv) => (
{kv.key} = {kv.value}
))}
)}
)} {/* Error / divergence note. */} {mp.runtime_error && (

{mp.runtime_error}

)}
) } export function VirtualMachines() { const { data: vmData, error, isLoading, mutate, } = useSWR("/api/vms", fetcher, { refreshInterval: 2500, revalidateOnFocus: true, revalidateOnReconnect: true, dedupingInterval: 1000, errorRetryCount: 2, }) const [selectedVM, setSelectedVM] = useState(null) const [vmDetails, setVMDetails] = useState(null) const [controlLoading, setControlLoading] = useState(false) // Destructive control confirmation. `Force Stop` and `Reboot` skip the OS // shutdown sequence and can corrupt running guests; gate them behind a // typed-VMID match prompt to prevent misclicks. See audit Tier 2 #17. const [confirmDestructive, setConfirmDestructive] = useState<{ action: "stop" | "reboot" vmid: number vmName: string } | null>(null) const [confirmDestructiveTyped, setConfirmDestructiveTyped] = useState("") const [detailsLoading, setDetailsLoading] = useState(false) const [terminalOpen, setTerminalOpen] = useState(false) const [terminalVmid, setTerminalVmid] = useState(null) const [terminalVmName, setTerminalVmName] = useState("") const [vmConfigs, setVmConfigs] = useState>({}) const [currentView, setCurrentView] = useState<"main" | "metrics">("main") const [showAdditionalInfo, setShowAdditionalInfo] = useState(false) const [showNotes, setShowNotes] = useState(false) const [isEditingNotes, setIsEditingNotes] = useState(false) const [editedNotes, setEditedNotes] = useState("") const [savingNotes, setSavingNotes] = useState(false) const [selectedMetric, setSelectedMetric] = useState(null) const [ipsLoaded, setIpsLoaded] = useState(false) const [loadingIPs, setLoadingIPs] = useState(false) const [networkUnit, setNetworkUnit] = useState<"Bytes" | "Bits">("Bytes") // Backup states const [vmBackups, setVmBackups] = useState([]) const [backupStorages, setBackupStorages] = useState([]) const [selectedBackupStorage, setSelectedBackupStorage] = useState("") const [loadingBackups, setLoadingBackups] = useState(false) const [creatingBackup, setCreatingBackup] = useState(false) // Backup modal states const [showBackupModal, setShowBackupModal] = useState(false) const [backupMode, setBackupMode] = useState("snapshot") const [backupProtected, setBackupProtected] = useState(false) const [backupNotification, setBackupNotification] = useState("auto") const [backupNotes, setBackupNotes] = useState("{{guestname}}") const [backupPbsChangeMode, setBackupPbsChangeMode] = useState("default") // Tab state for modal const [activeModalTab, setActiveModalTab] = useState<"status" | "mounts" | "backups">("status") // Sprint 13.29: per-LXC mount points lazy-loaded when the user opens // the LXC modal. We fetch alongside backups (one-shot) so switching // tabs is instantaneous; the cost is small (parses one config file // + pvesm status which the kernel already caches). const [mountPoints, setMountPoints] = useState([]) const [adHocMounts, setAdHocMounts] = useState([]) const [loadingMounts, setLoadingMounts] = useState(false) // Detect standalone mode (webapp vs browser) const [isStandalone, setIsStandalone] = useState(false) useEffect(() => { const checkStandalone = () => { const standalone = window.matchMedia('(display-mode: standalone)').matches || (window.navigator as Navigator & { standalone?: boolean }).standalone === true setIsStandalone(standalone) } checkStandalone() const mediaQuery = window.matchMedia('(display-mode: standalone)') mediaQuery.addEventListener('change', checkStandalone) return () => mediaQuery.removeEventListener('change', checkStandalone) }, []) useEffect(() => { // `cancelled` short-circuits setState calls if the component unmounts // mid-fetch (user navigates away while we're still iterating LXCs in // batches). Without it, React logs "state update on unmounted // component" and we leak the closure that holds the configs map. let cancelled = false const fetchLXCIPs = async () => { if (!vmData || ipsLoaded || loadingIPs) return const lxcs = vmData.filter((vm) => vm.type === "lxc") if (lxcs.length === 0) { if (!cancelled) setIpsLoaded(true) return } setLoadingIPs(true) const configs: Record = {} const batchSize = 5 for (let i = 0; i < lxcs.length; i += batchSize) { if (cancelled) return const batch = lxcs.slice(i, i + batchSize) await Promise.all( batch.map(async (lxc) => { try { const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), 10000) const details = await fetchApi(`/api/vms/${lxc.vmid}`) clearTimeout(timeoutId) if (details.lxc_ip_info?.primary_ip) { configs[lxc.vmid] = details.lxc_ip_info.primary_ip } else if (details.config) { configs[lxc.vmid] = extractIPFromConfig(details.config, details.lxc_ip_info) } } catch (error) { console.log(`[v0] Could not fetch IP for LXC ${lxc.vmid}`) configs[lxc.vmid] = "N/A" } }), ) if (cancelled) return setVmConfigs((prev) => ({ ...prev, ...configs })) } if (cancelled) return setLoadingIPs(false) setIpsLoaded(true) } fetchLXCIPs() return () => { cancelled = true } }, [vmData, ipsLoaded, loadingIPs]) // Load initial network unit and listen for changes useEffect(() => { setNetworkUnit(getNetworkUnit()) const handleNetworkUnitChange = () => { setNetworkUnit(getNetworkUnit()) } window.addEventListener("networkUnitChanged", handleNetworkUnitChange) window.addEventListener("storage", handleNetworkUnitChange) return () => { window.removeEventListener("networkUnitChanged", handleNetworkUnitChange) window.removeEventListener("storage", handleNetworkUnitChange) } }, []) // Keep the open modal's VM in sync with the /api/vms poll so CPU/RAM/I-O values // don't stay frozen at click-time. Single data source (/cluster/resources) shared // with the list — no source mismatch, no flicker. useEffect(() => { if (!selectedVM || !vmData) return const updated = vmData.find((v) => v.vmid === selectedVM.vmid) if (!updated || updated === selectedVM) return setSelectedVM(updated) }, [vmData]) const handleVMClick = async (vm: VMData) => { setSelectedVM(vm) setCurrentView("main") setShowAdditionalInfo(false) setShowNotes(false) setIsEditingNotes(false) setEditedNotes("") setDetailsLoading(true) setActiveModalTab("status") // Reset Sprint 13.29 mount-points state from any previous selection // so the new modal doesn't briefly flash data from another LXC. setMountPoints([]) setAdHocMounts([]) // Load backups immediately (independent of config) fetchBackupStorages() fetchVmBackups(vm.vmid) // Sprint 13.29: load LXC mount points alongside backups so // switching to that tab is instant. Only LXCs have mpX entries — // qemu VMs use disks, not mount points, so we skip the request // and simply hide the tab below. if (vm.type === "lxc") { fetchMountPoints(vm.vmid) } try { const details = await fetchApi(`/api/vms/${vm.vmid}`) setVMDetails(details) } catch (error) { console.error("Error fetching VM details:", error) } finally { setDetailsLoading(false) } } const fetchMountPoints = async (vmid: number) => { setLoadingMounts(true) try { const response = await fetchApi<{ ok: boolean running: boolean mount_points: LxcMountPoint[] ad_hoc: LxcMountPoint[] }>(`/api/lxc/${vmid}/mount-points`) if (response?.ok) { setMountPoints(response.mount_points || []) setAdHocMounts(response.ad_hoc || []) } else { setMountPoints([]) setAdHocMounts([]) } } catch (error) { console.error("Error fetching LXC mount points:", error) setMountPoints([]) setAdHocMounts([]) } finally { setLoadingMounts(false) } } const handleMetricsClick = () => { setCurrentView("metrics") } const handleBackToMain = () => { setCurrentView("main") } // Backup functions const fetchBackupStorages = async () => { try { const response = await fetchApi("/api/backup-storages") if (response.storages) { setBackupStorages(response.storages) if (response.storages.length > 0 && !selectedBackupStorage) { setSelectedBackupStorage(response.storages[0].storage) } } } catch (error) { console.error("Error fetching backup storages:", error) } } const fetchVmBackups = async (vmid: number) => { setLoadingBackups(true) try { const response = await fetchApi(`/api/vms/${vmid}/backups`) if (response.backups) { setVmBackups(response.backups) } } catch (error) { console.error("Error fetching VM backups:", error) setVmBackups([]) } finally { setLoadingBackups(false) } } const openBackupModal = () => { // Reset modal to defaults setBackupMode("snapshot") setBackupProtected(false) setBackupNotification("auto") setBackupNotes("{{guestname}}") setBackupPbsChangeMode("default") // Auto-select first storage if none selected if (!selectedBackupStorage && backupStorages.length > 0) { setSelectedBackupStorage(backupStorages[0].storage) } setShowBackupModal(true) } const handleCreateBackup = async () => { if (!selectedVM || !selectedBackupStorage) return setCreatingBackup(true) setShowBackupModal(false) try { await fetchApi(`/api/vms/${selectedVM.vmid}/backup`, { method: "POST", body: JSON.stringify({ storage: selectedBackupStorage, mode: backupMode, compress: "zstd", protected: backupProtected, notification: backupNotification, notes: backupNotes, pbs_change_detection: backupPbsChangeMode }), }) setTimeout(() => fetchVmBackups(selectedVM.vmid), 2000) } catch (error) { console.error("Error creating backup:", error) // Surface the failure to the user. Previous behaviour silently swallowed // backend errors so the user thought the backup started fine; in reality // the request had 4xx/5xx'd and nothing was scheduled. const msg = error instanceof Error ? error.message : "Unknown error" alert(`Failed to start backup: ${msg}`) } finally { setCreatingBackup(false) } } const handleVMControl = async (vmid: number, action: string) => { setControlLoading(true) try { await fetchApi(`/api/vms/${vmid}/control`, { method: "POST", body: JSON.stringify({ action }), }) mutate() setSelectedVM(null) setVMDetails(null) } catch (error) { console.error(`Failed to ${action} VM ${vmid}:`, error) // Same UX issue as handleCreateBackup: a silent console.error left the // user looking at a "Stop"/"Start" button that just never reacted. const msg = error instanceof Error ? error.message : "Unknown error" alert(`Failed to ${action} VM ${vmid}: ${msg}`) } finally { setControlLoading(false) } } // Open terminal for LXC container const openLxcTerminal = (vmid: number, vmName: string) => { setTerminalVmid(vmid) setTerminalVmName(vmName) setTerminalOpen(true) } const handleDownloadLogs = async (vmid: number, vmName: string) => { try { const data = await fetchApi(`/api/vms/${vmid}/logs`) // Format logs as plain text let logText = `=== Logs for ${vmName} (VMID: ${vmid}) ===\n` logText += `Node: ${data.node}\n` logText += `Type: ${data.type}\n` logText += `Total lines: ${data.log_lines}\n` logText += `Generated: ${new Date().toISOString()}\n` logText += `\n${"=".repeat(80)}\n\n` if (data.logs && Array.isArray(data.logs)) { data.logs.forEach((log: any) => { if (typeof log === "object" && log.t) { logText += `${log.t}\n` } else if (typeof log === "string") { logText += `${log}\n` } }) } const blob = new Blob([logText], { type: "text/plain" }) const url = URL.createObjectURL(blob) const a = document.createElement("a") a.href = url a.download = `${vmName}-${vmid}-logs.txt` a.click() URL.revokeObjectURL(url) } catch (error) { console.error("Error downloading logs:", error) } } const getStatusColor = (status: string) => { switch (status) { case "running": return "bg-green-500/10 text-green-500 border-green-500/20" case "stopped": return "bg-red-500/10 text-red-500 border-red-500/20" default: return "bg-yellow-500/10 text-yellow-500 border-yellow-500/20" } } const getStatusIcon = (status: string) => { switch (status) { case "running": return case "stopped": return default: return null } } const getTypeBadge = (type: string) => { if (type === "lxc") { return { color: "bg-cyan-500/10 text-cyan-500 border-cyan-500/20", label: "LXC", icon: , } } return { color: "bg-purple-500/10 text-purple-500 border-purple-500/20", label: "VM", icon: , } } // Ensure vmData is always an array (backend may return object on error) const safeVMData = Array.isArray(vmData) ? vmData : [] // Total allocated RAM for ALL VMs/LXCs (running + stopped) const totalAllocatedMemoryGB = useMemo(() => { return (safeVMData.reduce((sum, vm) => sum + (vm.maxmem || 0), 0) / 1024 ** 3).toFixed(1) }, [safeVMData]) // Allocated RAM only for RUNNING VMs/LXCs (this is what actually matters for overcommit) const runningAllocatedMemoryGB = useMemo(() => { return (safeVMData .filter((vm) => vm.status === "running") .reduce((sum, vm) => sum + (vm.maxmem || 0), 0) / 1024 ** 3).toFixed(1) }, [safeVMData]) const { data: systemData } = useSWR<{ memory_total: number; memory_used: number; memory_usage: number }>( "/api/system", fetcher, { refreshInterval: 37000, revalidateOnFocus: false, }, ) const physicalMemoryGB = systemData?.memory_total ?? null const usedMemoryGB = systemData?.memory_used ?? null const memoryUsagePercent = systemData?.memory_usage ?? null const allocatedMemoryGB = Number.parseFloat(totalAllocatedMemoryGB) const runningAllocatedGB = Number.parseFloat(runningAllocatedMemoryGB) // Overcommit warning should be based on RUNNING VMs allocation, not total const isMemoryOvercommit = physicalMemoryGB !== null && runningAllocatedGB > physicalMemoryGB const getMemoryUsageColor = (percent: number | null) => { if (percent === null) return "bg-blue-500" if (percent >= 95) return "bg-red-500" if (percent >= 86) return "bg-orange-500" if (percent >= 71) return "bg-yellow-500" return "bg-blue-500" } const getMemoryPercentTextColor = (percent: number | null) => { if (percent === null) return "text-muted-foreground" if (percent >= 95) return "text-red-500" if (percent >= 86) return "text-orange-500" if (percent >= 71) return "text-yellow-500" return "text-green-500" } if (isLoading) { return (
Loading virtual machines...

Fetching VM and LXC container status

) } if (error) { return (
Error loading virtual machines: {error.message}
) } // Single-pass decode. Proxmox URL-encodes notes exactly once when storing // them in `config.description`, so a single `decodeURIComponent` is the // correct round-trip. The previous loop decoded up to 5 times, which made // it possible to ship a payload like `%253Cscript%253E` past one-pass // filters (`%25` → `%` → second decode produces `