"use client" import type React from "react" import { useState, useMemo, useEffect, useRef } from "react" import { fetchLxcApps, getLxcAppsCached, invalidateLxcApps, seedLxcAppsCache, setLxcAppsCached } from "../lib/lxc-apps-cache" import { parseTags, stringifyTags, tagToColor } from "../lib/pve-tag-color" 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, ChevronRight, Terminal, Archive, Plus, PlusCircle, Loader2, Clock, Database, Shield, Bell, FileText, Settings2, Activity, Package, RefreshCw, EthernetPort, ArrowUpCircle, Info, CheckCircle2, EyeOff, Eye, Trash2, Check, X, AlertTriangle, AlertCircle, ExternalLink, Tag as TagIcon } from 'lucide-react' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select" import { Checkbox } from "./ui/checkbox" import { Switch } from "./ui/switch" import { Textarea } from "./ui/textarea" import { Input } from "./ui/input" import { Label } from "./ui/label" import useSWR from "swr" import { MetricsView } from "./metrics-dialog" import { LxcTerminalModal } from "./lxc-terminal-modal" import { ScriptTerminalModal } from "./script-terminal-modal" import { LxcAppPanel, ThemeAwareLogo } from "./lxc-app-panel" import { formatStorage } from "../lib/utils" import { formatNetworkTraffic, getNetworkUnit } from "../lib/format-network" import { fetchApi } from "../lib/api-config" import DOMPurify from "dompurify" import { marked } from "marked" import { useT } from "@/lib/i18n/provider" // Sent by /api/vms only for LXC rows, only when the user has enabled // `lxc_updates_available` notifications. The Monitor populates this // from managed_installs registry → frontend uses it to render the // inline update badge + the modal's "Pending updates" section. interface LxcPackageUpdate { name: string current: string latest: string security: boolean } interface LxcUpdateCheck { available: boolean count: number security_count: number last_check: string | null latest: string | null error: string | null packages: LxcPackageUpdate[] // Added Phase 2a/b — surfaced by managed_installs when the CT // originates from an OCI image (apt/apk detection is suppressed // for those) or when the community-scripts convention // /usr/bin/update is present in the CT. is_oci_lxc?: boolean app_updater_present?: boolean // ProxMenux-managed OCI app id (e.g. "secure-gateway") — when set, // this CT is driven by the OCI dashboard's own updater and the // Updates modal redirects there instead of running our apt flow. managed_oci_app?: string | null // Community-scripts identity + updateable-known flag. Backed by the // ProxMenux helpers_cache (46 apps flagged updateable:false at // last count). The modal renders three shapes: // • helper_updateable_known=true + app_updater_present=true → Apply button // • helper_updateable_known=true + app_updater_present=false → "not updateable" note // • helper_updateable_known=false → neutral hint (unknown/unlisted app) helper_slug?: string | null helper_slug_source?: "update_wrapper" | "tag_hostname" | null helper_app_name?: string | null helper_updateable_known?: boolean os_family?: string | null } // Summary attached to LXC rows in /api/vms when the user has // registered an application watch for the CT. Populates the header // badge + the Updates modal "App upstream" row. interface LxcAppPort { port: number description?: string scheme?: "http" | "https" web_path?: string logo_url?: string | null } interface LxcAppWatch { id: string name: string | null installed_via?: string | null ports?: LxcAppPort[] logo_url?: string | null health_path?: string | null installed_version: string | null latest_version: string | null update_available: boolean | null error: string | null checked_at: string | null has_repo?: boolean // Set for the synthetic entry that represents a ProxMenux-managed // OCI app (Secure Gateway). The frontend renders it read-only + // wires the Update action to /api/oci/installed//update. managed_oci_app_id?: string | null packages?: Array<{ name: string; current?: string; latest?: string }> // Updates tab: freeform bash the user wired up as the app's own // update method. When set, the Updates tab renders an "Apply {app}" // button that runs `pct exec vmid -- sh -c "$update_command"`. update_command?: string // Updates tab: per-app dismiss for the "no update method defined" // notice. Only hides the notice — the App tab still shows purple ⬆ // when an update is available upstream. hide_no_updater_notice?: boolean // Community-scripts slug set by the App tab Register flow. Lets the // Updates tab helper-scripts section find its matching registered // app to pull installed/upstream version data from. helper_slug?: string // Per-app opt-out of the CT's aggregate updates badge (default: // included). Independent from the app's notification toggle. exclude_from_badge?: boolean // Per-app opt-out for the `app_update_available` notification. notifications_enabled?: boolean } interface LxcDockerComposeTarget { kind: "compose" project: string services: string[] working_dir: string config_files: string[] update_command: string dependencies?: Record } interface LxcDockerUpdateUnit { id: string kind: "compose" | "standalone" project?: string | null primary_service?: string | null services: string[] dependent_services?: string[] references: string[] primary_reference?: string | null display_name?: string | null logo_url?: string | null working_dir?: string | null config_files?: string[] update_command?: string standalone_containers?: string[] update_available?: boolean | null } interface LxcDockerImageUpdate { reference: string tag: string display_name?: string | null logo_url?: string | null installed_version?: string | null available_version?: string | null local_digest: string | null remote_digest: string | null used_by: string[] update_targets?: LxcDockerComposeTarget[] standalone_containers?: string[] update_available: boolean | null error: string | null } interface LxcDockerInventory { available: boolean refreshing?: boolean engine_version: string | null images: LxcDockerImageUpdate[] compose_projects?: LxcDockerComposeTarget[] update_units?: LxcDockerUpdateUnit[] update_count: number checked_at: string | null error: string | null } interface VMData { vmid: number name: string status: string type: string cpu: number maxcpu?: number mem: number maxmem: number disk: number maxdisk: number uptime: number netin?: number netout?: number diskread?: number diskwrite?: number ip?: string update_check?: LxcUpdateCheck // Proxmox tags as a raw string ("prod;web;monitoring" — PVE // separator is ';' but ',' is also accepted). Rendered as // coloured dots next to the ID in the list cards and as full // pills inside the modal. tags?: string // List of registered apps (0..N). Managed entries (Secure Gateway) // always come first when present. app_watches?: LxcAppWatch[] // Read-only Docker image digest inventory. Separate from app_watches // because engine updates and image updates are independent lifecycles. docker_inventory?: LxcDockerInventory // Incremented by the server after it has rebuilt this guest's complete // modal/app/Docker snapshot following a start, reboot or restore. modal_cache_revision?: number } function buildRegisteredAppUrl(vm: VMData, port?: LxcAppPort): string | null { const rawIp = (vm.ip || "").trim().split("/")[0] if (!rawIp || rawIp === "DHCP" || !port?.port) return null const host = rawIp.includes(":") && !rawIp.startsWith("[") ? `[${rawIp}]` : rawIp const scheme = port.scheme || ([443, 8443, 9443].includes(port.port) ? "https" : "http") const path = port.web_path ? `/${port.web_path.replace(/^\/+/, "")}` : "" return `${scheme}://${host}:${port.port}${path}` } 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 // Sprint 14.x: host-side bind source state. Detects the case where the // CT still reports a bind as mounted even though the host already // umounted the source (Ignacio Seijo 11/05). Null = N/A (PVE volume, // not a host path). host_source_exists?: boolean | null host_source_is_mountpoint?: boolean | 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, t: (key: string, params?: Record) => string) => { const days = Math.floor(seconds / 86400) const hours = Math.floor((seconds % 86400) / 3600) const minutes = Math.floor((seconds % 3600) / 60) return t("vmLxc.duration.dhm", { days, hours, minutes }) } 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 t = useT() const isStale = mp.runtime_reachable === false const isReadonly = !isStale && mp.runtime_readonly === true const isDivergent = mp.runtime_mounted === false // configured but not actually mounted // "Zombie bind": the host removed the source (e.g. USB pulled, manual // umount) but the CT mount namespace still shows the bind as mounted. // Reported by Ignacio Seijo (11/05). Only flag host_bind / // pve_storage_bind sources — PVE volume sources have no host path // and `host_source_exists` comes back null for them. const isHostDetached = mp.runtime_mounted === true && (mp.type === "host_bind" || mp.type === "pve_storage_bind") && mp.host_source_exists === false const cardClasses = isStale ? "border-red-500/50 bg-red-500/5" : isDivergent || isHostDetached ? "border-amber-500/40 bg-amber-500/5" : isReadonly ? "border-amber-500/30 bg-amber-500/5" : "border border-border 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: t("vmLxc.details.mountTypes.pveVolume"), pve_storage_bind: t("vmLxc.details.mountTypes.pveStorageBind"), host_bind: t("vmLxc.details.mountTypes.hostBind"), ad_hoc: t("vmLxc.details.mountTypes.adHoc"), } 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 ? t("vmLxc.details.mountStatus.stale") : isDivergent ? t("vmLxc.details.mountStatus.notMounted") : isHostDetached ? t("vmLxc.details.mountStatus.hostDetached") : isReadonly ? t("vmLxc.details.mountStatus.readOnly") : mp.runtime_mounted === null ? t("vmLxc.details.mountStatus.stopped") : t("vmLxc.details.mountStatus.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. */}
{t("vmLxc.details.sourceHost")}:{" "} {mp.origin_label || mp.source} {mp.origin_storage && mp.origin_storage_type && ( ({t("vmLxc.details.storageType", { type: mp.origin_storage_type })}) )}
{t("vmLxc.details.mountedAtCt")}:{" "} {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" }`} />

{t("vmLxc.details.total")}

{fmtBytes(mp.total_bytes)}

{t("vmLxc.details.used")}

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

{t("vmLxc.details.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 (

{t("vmLxc.details.mountAttributes")}

{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) && (

{t("vmLxc.details.runtimeMountOptions")}

{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 t = useT() 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) // Cross-open cache: last-known payloads keyed by vmid, persisted // across modal open/close for the lifetime of this component. When // a user reopens the same guest, we prime the UI from this ref // instantly, so no tab shows "Loading…" between openings. A fresh // fetch still runs and refreshes both the state and the ref — the // backend's per-vmid TTL cache (see flask_server.py) makes that // revalidation cheap. const vmModalCacheRef = useRef({ details: new Map(), // Backups carry a `fetchedAt` millisecond timestamp alongside the // payload so `fetchVmBackups` can decide whether to add `?fresh=1` // on the wire. The backend cache is indefinite; the 6-hour gate // lives here on the client, matching the operator-facing rule // ("scheduled/cron backups can appear anywhere in the day, but a // 6-hour visibility lag is acceptable"). If a modal reopens // within 6 h of the last fetch, no re-scan on the server. backups: new Map(), // NOTE: apps payload lives in the shared module `lxc-apps-cache` // (dedup between this parent and LxcAppPanel — see fetchLxcApps). schedule: new Map(), // Only the static half of mount-points goes here. Runtime // (capacity/health/ad-hoc) is intentionally NOT cached — see // `mountPointsRuntime` state + `fetchMountPoints` for the // always-fresh side. `ad_hoc_hint_count` is the cheap remote-fs // count from /proc//mounts (server-side, no subprocess) // that lets us render the Mount Points tab header at open time // for CTs that only have NFS/CIFS mounted from inside. mountPoints: new Map(), // Firewall log is on-demand ONLY — do NOT seed it from the bulk // modal-cache endpoint or from any prewarmer. The log is a live // stream (new entries flow with every packet the firewall drops) // so cached data has no value beyond the current session; most // users never open the Firewall tab, so pre-scanning would waste // pvesh cycles for nothing. This Map fills only when // `fetchFirewallLog` runs (tab click or Refresh button) and // survives modal reopens within the same page load. firewall: new Map(), }) const dockerInventoryRequestedRef = useRef(new Map()) const lifecycleCacheRevisionsRef = useRef(new Map()) const [dockerInventoryRefreshingVmid, setDockerInventoryRefreshingVmid] = 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) // Status tab: inline editor for the "start on boot" toggle. // resourcesEditMode → true when the pencil is open, gates the // toggle so accidental clicks don't fire. // pendingOnboot → local edit value; null means "no pending // change, use whatever vmDetails.config // says". `qm/pct set --onboot` is hot, so // the change lands without a reboot. // savingOnboot → spinner state on Save. // savedOnboot → 2 s ack pill after successful save. const [resourcesEditMode, setResourcesEditMode] = useState(false) const [pendingOnboot, setPendingOnboot] = useState(null) const [pendingTags, setPendingTags] = useState(null) const [newTagDraft, setNewTagDraft] = useState("") // When set (in edit mode), the pill at this index renders as an // inline text input pre-filled with its current text. Enter/blur // commits the change; Escape reverts; empty commits removes the tag. const [editingTagIndex, setEditingTagIndex] = useState(null) const [editingTagDraft, setEditingTagDraft] = useState("") const [savingOnboot, setSavingOnboot] = useState(false) const [savedOnboot, setSavedOnboot] = useState(false) // Post-apply state for the Updates tab. When the script terminal // closes, the tab enters a "Comprobando resultado…" state until // a fresh /api/vms poll delivers the new update_check counts; // then it flashes a short success/warning banner. Prevents the // confusing window where stale "40 pending" numbers are still on // screen right after the user just ran the updater. // updatesRefreshing → shows a discreet spinner in the tab // updatesResult → { count, applied } drives the banner // updatesBaselineCount → snapshot of `count` at the moment the // apply started; the useEffect below considers the SWR poll // "settled" when the observed count differs from this baseline // (or after a 15 s safety timeout). const [updatesRefreshing, setUpdatesRefreshing] = useState(false) const [updatesResult, setUpdatesResult] = useState<{ pendingAfter: number; appliedCount: number } | null>(null) const [updatesBaselineCount, setUpdatesBaselineCount] = useState(null) 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" | "app" | "updates" | "firewall">("status") // Firewall log state — fetched only when the operator opens that tab // so a CT/VM without firewall use doesn't pay the pvesh cost on every // modal open. Issue #14554 from the helper-scripts discussions. interface FirewallLogEntry { n: number; t: string } const [firewallLogs, setFirewallLogs] = useState([]) const [loadingFirewallLog, setLoadingFirewallLog] = useState(false) const [firewallEnabled, setFirewallEnabled] = useState(true) const [firewallLogError, setFirewallLogError] = useState(null) // 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) // Runtime enrichment keyed by target — fetched fresh every open // (never cached). `mountPoints` cards read this to fill in usage // bars, reachability and runtime fstype without blocking their // initial render. Null while the runtime fetch is in flight; the // static cards still render (paths, types, storage origin) and // reveal usage/health when the fetch resolves. const [mountPointsRuntime, setMountPointsRuntime] = useState> | null>(null) // Cheap count of remote-fs (nfs/cifs/smb) entries in the CT's // /proc//mounts, delivered by the static endpoint so the // Mount Points tab can render its header IMMEDIATELY for CTs that // only have ad-hoc mounts inside the container. The full ad-hoc // list still arrives via the runtime fetch (with capacity/health); // this is just enough to know "should the tab show at all?". const [mountsAdHocHint, setMountsAdHocHint] = useState(0) // 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(() => { // Fetch IPs for LXCs that aren't in `vmConfigs` yet. Previously // gated on `ipsLoaded` (single latch) — with `forceMount` on the // parent the component never re-mounts, so a new LXC appearing // mid-session, or a fetch that failed the first time, stayed // without an IP forever. Now we compare `vmData` against // `vmConfigs` on every SWR poll and fetch only the delta. let cancelled = false const fetchLXCIPs = async () => { if (!vmData || loadingIPs) return const missing = vmData.filter( (vm) => vm.type === "lxc" && !(vm.vmid in vmConfigs), ) if (missing.length === 0) return setLoadingIPs(true) const configs: Record = {} const batchSize = 5 for (let i = 0; i < missing.length; i += batchSize) { if (cancelled) return const batch = missing.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) } else { configs[lxc.vmid] = "N/A" } } catch (error) { console.log(`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) } fetchLXCIPs() return () => { cancelled = true } // eslint-disable-next-line react-hooks/exhaustive-deps }, [vmData, 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]) // Backend lifecycle refreshes are asynchronous: a start response returns // immediately, then the server waits for the guest (and Docker, for LXCs) // before publishing a complete new snapshot. When its revision changes, // discard only this guest's browser-side data. The following SWR poll and // modal open then consume the warmed server cache without a stale flash. useEffect(() => { if (!vmData) return const revisions = lifecycleCacheRevisionsRef.current for (const vm of vmData) { const current = vm.modal_cache_revision ?? 0 const previous = revisions.get(vm.vmid) revisions.set(vm.vmid, current) if (previous === undefined || previous === current) continue const cache = vmModalCacheRef.current cache.details.delete(vm.vmid) cache.backups.delete(vm.vmid) cache.mountPoints.delete(vm.vmid) cache.schedule.delete(vm.vmid) cache.firewall.delete(vm.vmid) dockerInventoryRequestedRef.current.delete(vm.vmid) invalidateLxcApps(vm.vmid) setVmConfigs((existing) => { if (!(vm.vmid in existing)) return existing const next = { ...existing } delete next[vm.vmid] return next }) } }, [vmData]) // Settle the Updates-tab "Comprobando resultado…" state as soon as // the /api/vms poll delivers a post-apply count that differs from // the baseline captured when the terminal closed. Also drops the // spinner after 15 s of no observed change (backend hook already // force-refreshed managed_installs, so a still-equal count at that // point means either everything was a no-op or the scan hasn't // finished — either way the user shouldn't keep staring at a // loader). Sets `updatesResult` for the transient banner: green if // count is now 0, amber if some packages remain. useEffect(() => { if (!updatesRefreshing) return if (!selectedVM) return const currentCount = selectedVM.update_check?.count ?? 0 // A change from baseline (or landing at 0) means the fresh // post-apply snapshot is in. if (updatesBaselineCount !== null && currentCount !== updatesBaselineCount) { const applied = Math.max(0, updatesBaselineCount - currentCount) setUpdatesResult({ pendingAfter: currentCount, appliedCount: applied }) setUpdatesRefreshing(false) setUpdatesBaselineCount(null) return } // Safety timeout — never leave the spinner spinning forever. const safety = setTimeout(() => { setUpdatesResult({ pendingAfter: currentCount, appliedCount: Math.max(0, (updatesBaselineCount ?? 0) - currentCount), }) setUpdatesRefreshing(false) setUpdatesBaselineCount(null) }, 15000) return () => clearTimeout(safety) }, [selectedVM, updatesRefreshing, updatesBaselineCount]) // Auto-dismiss the post-apply banner after 6 s so it doesn't // clutter the tab forever. useEffect(() => { if (!updatesResult) return const t = setTimeout(() => setUpdatesResult(null), 6000) return () => clearTimeout(t) }, [updatesResult]) const handleVMClick = async (vm: VMData) => { setSelectedVM(vm) setBulkLoaded(null) setBulkConfigured(false) setBulkTargets(["os"]) setBulkPersistedTargets(["os"]) setBulkEditMode(false) setBulkError(null) setCurrentView("main") setShowAdditionalInfo(false) setShowNotes(false) setIsEditingNotes(false) setEditedNotes("") // Resources edit mode never carries across guests — always // start in view mode with no pending change. setResourcesEditMode(false) setPendingOnboot(null) setPendingTags(null) setNewTagDraft("") setEditingTagIndex(null) setEditingTagDraft("") setSavedOnboot(false) setActiveModalTab("status") // Reset firewall log state — fetched lazily when the user opens // that tab, since most operators won't visit it on every modal open. setFirewallLogs([]) setFirewallLogError(null) setFirewallEnabled(true) // Prime UI from last-known payloads so a reopened guest never // flashes "Loading…" — the backend and this cache both revalidate // in the background right after. const cache = vmModalCacheRef.current const seedDetails = cache.details.get(vm.vmid) as VMDetails | undefined const seedBackups = cache.backups.get(vm.vmid) const seedMounts = cache.mountPoints.get(vm.vmid) setVMDetails(seedDetails ?? null) setVmBackups(seedBackups?.backups ?? []) setMountPoints(seedMounts?.mount_points ?? []) // Seed the ad-hoc hint from the static payload so the Mount // Points tab header renders instantly even when the CT only // has NFS/CIFS mounts done from inside (nothing in .conf). // The full list arrives via the runtime fetch a moment later. setMountsAdHocHint(seedMounts?.ad_hoc_hint_count ?? 0) // Ad-hoc mounts only come from the runtime fetch — never seeded. // Reset to empty on each modal open; if the CT has any, they // appear as soon as the runtime response arrives. setAdHocMounts([]) // Runtime enrichment resets too — we never carry it across opens // because usage/reachability go stale within seconds. Fills in // when `fetchMountPoints` runtime response resolves. setMountPointsRuntime(null) setDetailsLoading(!seedDetails) setLoadingBackups(!seedBackups) if (vm.type === "lxc") setLoadingMounts(!seedMounts) // Load backups immediately (independent of config) fetchBackupStorages() fetchVmBackups(vm.vmid) // Fire every LXC-only tab payload in parallel too, so switching to // App / Updates never pays a fresh round-trip. Apps and schedule // are background: the App tab's own component still owns its fetch // (that request hits the backend TTL cache and returns in ~5 ms), // and loadSchedule reads the seeded ref cache when the user hits // Updates. Sprint 13.29 already did this for mount-points; this // extends the same idea to the two tabs still on lazy-fetch. if (vm.type === "lxc") { fetchMountPoints(vm.vmid) // Shared cache dedup: if a hover already fired this, we share // the same promise (no duplicate backend work). The App panel // reads from the same cache, so switching to that tab either // finds the data ready or awaits the SAME in-flight fetch. fetchLxcApps(vm.vmid) const cache = vmModalCacheRef.current fetchApi(`/api/vms/${vm.vmid}/schedule`) .then((s: any) => { if (s && typeof s === "object") cache.schedule.set(vm.vmid, s) }) .catch(() => { /* silent — Updates tab will retry on activation */ }) } try { const details = await fetchApi(`/api/vms/${vm.vmid}`) setVMDetails(details) cache.details.set(vm.vmid, details) } catch (error) { console.error("Error fetching VM details:", error) } finally { setDetailsLoading(false) } } // Hover-prefetch is a no-op now: every modal payload // (details / backups / apps / schedule / mount-points) is // already in the ref cache from the bulk hydration on page // load. Kept as an empty callback so the JSX handler stays // stable and the intent is documented — if a new lazy field // appears later, warming it on hover goes here. const prefetchVM = (_vm: VMData) => { /* no-op */ } // Stable identity for the guest list — only changes when a guest is // ADDED or REMOVED. Prevents the prefetch effect below from being // torn down every SWR poll (2.5 s), which used to cancel every // in-flight fetch before it could populate the cache (the cancel // flag flipped between the fetch dispatch and its .then, so the // Map.set never ran). With this key, running/stopped status changes // — the only thing that fluctuates poll-to-poll — no longer disturb // the prefetch queue. const vmidsKey = useMemo( () => (vmData ? vmData.map((v) => `${v.vmid}:${v.type}`).sort().join(",") : ""), [vmData], ) // Bulk hydration: one request to `/api/vms/modal-cache-all` // seeds the entire ref cache (details + backups + apps + schedule // for every guest) so opening any modal is instant. Replaces the // previous fan-out of ~4×N per-guest fetches that hit the server // on every page load. Fires again if guests are added/removed. // // Server serves this from its in-memory prewarmer cache (see // `_vm_modal_prewarmer_loop` in flask_server.py) — one dict read // per guest, entire response <20 ms typical. // // Fields returned `null` mean "not yet cached on the server" // (only happens in the 20-70 s window right after a service // restart). Individual modal handlers already fall back to a // dirigido fetch when the ref cache miss, so those guests // recover transparently. useEffect(() => { if (!vmData || vmData.length === 0) return let cancelled = false fetchApi<{ guests: Array<{ vmid: number type: "qemu" | "lxc" details: any | null backups: { backups?: VMBackup[] } | null apps?: { apps?: any[] } | null suggestions?: any | null schedule?: any | null mount_points?: { ok?: boolean; mount_points?: LxcMountPoint[]; ad_hoc_hint_count?: number } | null }> }>(`/api/vms/modal-cache-all`) .then((payload) => { if (cancelled || !payload?.guests) return const cache = vmModalCacheRef.current for (const g of payload.guests) { if (g.details) cache.details.set(g.vmid, g.details) if (g.backups?.backups) { cache.backups.set(g.vmid, { backups: g.backups.backups, fetchedAt: Date.now() }) } if (g.type === "lxc") { if (g.apps) { // Route lxc apps through the shared module so // LxcAppPanel and this component read the same object. seedLxcAppsCache(g.vmid, g.apps, g.suggestions) } if (g.schedule && typeof g.schedule === "object") { cache.schedule.set(g.vmid, g.schedule) } if (g.mount_points?.ok) { // Only the static half now — ad_hoc + runtime come // from the always-fresh /mount-points/runtime endpoint // and are not seeded from the bulk payload. The // ad_hoc_hint_count travels along so the tab header // can render at open time even when the CT only has // NFS/CIFS mounted from inside. cache.mountPoints.set(g.vmid, { mount_points: g.mount_points.mount_points || [], ad_hoc_hint_count: (g.mount_points as any).ad_hoc_hint_count ?? 0, }) } } } }) .catch(() => { // Silent — modal open handlers fall back to individual // fetches if the ref cache is empty. }) return () => { cancelled = true } // eslint-disable-next-line react-hooks/exhaustive-deps }, [vmidsKey]) const fetchMountPoints = async (vmid: number) => { // Two fetches in parallel: // 1) STATIC — configured mp entries + PVE classification. Backed // by the indefinite backend cache; cache-hit returns in ~5 ms // after the first load. Seeds the cards' identity + paths. // 2) RUNTIME — `df` capacity, `stat` reachability, ad-hoc // NFS/CIFS mounts done inside the CT. Never cached — always // hits `df`/`stat` fresh so the operator sees the live state // at click time. Takes 1-3s on a CT with many binds, but the // cards are already visible from the static payload so the // user perceives no lag. const hasSeed = mountPoints.length > 0 if (!hasSeed) setLoadingMounts(true) try { const [staticResp, runtimeResp] = await Promise.all([ fetchApi<{ ok: boolean mount_points: LxcMountPoint[] ad_hoc_hint_count?: number }>(`/api/lxc/${vmid}/mount-points`).catch((e) => { console.error("Error fetching static mount points:", e) return null }), fetchApi<{ ok: boolean running: boolean runtime: Record> ad_hoc: LxcMountPoint[] }>(`/api/lxc/${vmid}/mount-points/runtime`).catch((e) => { console.error("Error fetching runtime mount points:", e) return null }), ]) if (staticResp?.ok) { const mp = staticResp.mount_points || [] const hint = staticResp.ad_hoc_hint_count ?? 0 setMountPoints(mp) setMountsAdHocHint(hint) vmModalCacheRef.current.mountPoints.set(vmid, { mount_points: mp, ad_hoc_hint_count: hint }) } else if (!hasSeed) { setMountPoints([]) } if (runtimeResp?.ok) { setMountPointsRuntime(runtimeResp.runtime || {}) setAdHocMounts(runtimeResp.ad_hoc || []) } else { setMountPointsRuntime({}) setAdHocMounts([]) } } catch (error) { console.error("Error fetching LXC mount points:", error) if (!hasSeed) 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) => { // Stale-while-revalidate with a 6-hour freshness gate. // // 1. If we already have backups (bulk hydration or a previous // open), show them IMMEDIATELY — no spinner. React diffs by // volid so the list never blanks; new backups slide in at // the top when the fresh payload arrives (sorted newest-first // server-side). // 2. If the local cache is older than 6 h, add `?fresh=1` on // the wire so the server bypasses its indefinite cache and // re-scans every storage. Otherwise the server hands back // the cached snapshot instantly. This is the operator's // accepted lag for out-of-band backups (cron / scheduled / // PBS retention) that don't invalidate our cache. // 3. Loading spinner only on the true first load. const GATE_MS = 6 * 60 * 60 * 1000 const seed = vmModalCacheRef.current.backups.get(vmid) const isStale = !seed || (Date.now() - seed.fetchedAt) > GATE_MS if (!seed) setLoadingBackups(true) try { const url = isStale ? `/api/vms/${vmid}/backups?fresh=1` : `/api/vms/${vmid}/backups` const response = await fetchApi<{ backups?: VMBackup[] }>(url) if (response.backups) { setVmBackups(response.backups) vmModalCacheRef.current.backups.set(vmid, { backups: response.backups, fetchedAt: Date.now() }) } } catch (error) { console.error("Error fetching VM backups:", error) // Only clear the visible list if we had nothing to show // in the first place — a transient network hiccup must not // wipe the stale-but-useful view the user is looking at. if (!seed) setVmBackups([]) } finally { if (!seed) setLoadingBackups(false) } } // Firewall log fetcher — proxies the PVE per-VM/CT firewall log // endpoint. The backend returns `firewall_enabled: false` when PVE // says the firewall is OFF for that guest; in that case we render // a callout instead of an empty viewer. const fetchFirewallLog = async (vmid: number) => { // Seed from ref cache so tab-switching to Firewall doesn't flash // "Loading…" when the payload was already prefetched. Backend // revalidates on top so a fresh log line lands on the next poll. const seed = vmModalCacheRef.current.firewall.get(vmid) if (seed) { setFirewallEnabled(seed.firewall_enabled !== false) setFirewallLogs(Array.isArray(seed.logs) ? seed.logs : []) if (seed.error && seed.firewall_enabled !== false) { setFirewallLogError(seed.error) } else { setFirewallLogError(null) } } setLoadingFirewallLog(!seed) if (!seed) setFirewallLogError(null) try { const response = await fetchApi<{ logs?: FirewallLogEntry[] firewall_enabled?: boolean error?: string }>(`/api/vms/${vmid}/firewall/log?limit=500`) vmModalCacheRef.current.firewall.set(vmid, response) setFirewallEnabled(response.firewall_enabled !== false) setFirewallLogs(Array.isArray(response.logs) ? response.logs : []) if (response.error && response.firewall_enabled !== false) { setFirewallLogError(response.error) } else { setFirewallLogError(null) } } catch (error) { if (!seed) { setFirewallEnabled(true) setFirewallLogs([]) setFirewallLogError(error instanceof Error ? error.message : String(error)) } } finally { setLoadingFirewallLog(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 : t("vmLxc.errors.unknown") alert(t("vmLxc.errors.backupStartFailed", { message: msg })) } finally { setCreatingBackup(false) } } const handleCancelResourcesEdit = () => { setPendingOnboot(null) setPendingTags(null) setNewTagDraft("") setEditingTagIndex(null) setEditingTagDraft("") setResourcesEditMode(false) } const handleSaveResources = async () => { if (!selectedVM) return const currentOnboot = !!vmDetails?.config?.onboot const currentTags = parseTags(selectedVM.tags) // Build the smallest payload that reflects real changes — the // backend allow-list accepts onboot + tags together in one call. const payload: Record = {} if (pendingOnboot !== null && pendingOnboot !== currentOnboot) { payload.onboot = pendingOnboot ? 1 : 0 } if (pendingTags !== null && stringifyTags(pendingTags) !== stringifyTags(currentTags)) { payload.tags = pendingTags } if (Object.keys(payload).length === 0) { handleCancelResourcesEdit() return } setSavingOnboot(true) try { await fetchApi(`/api/vms/${selectedVM.vmid}/config`, { method: "POST", body: JSON.stringify(payload), }) // Optimistic local reflect so the UI doesn't wait a poll cycle. if (payload.onboot !== undefined) { setVMDetails((prev) => (prev ? { ...prev, config: { ...prev.config, onboot: payload.onboot as number } } : prev)) } if (payload.tags !== undefined) { const nextTagsStr = stringifyTags(payload.tags as string[]) setSelectedVM((prev) => (prev ? { ...prev, tags: nextTagsStr } : prev)) } vmModalCacheRef.current.details.delete(selectedVM.vmid) // Trigger a natural /api/vms revalidation so tags flow into the // list card too without waiting up to 2.5 s. void mutate() setPendingOnboot(null) setPendingTags(null) setNewTagDraft("") setEditingTagIndex(null) setEditingTagDraft("") setResourcesEditMode(false) setSavedOnboot(true) setTimeout(() => setSavedOnboot(false), 2000) } catch (err) { console.error("Failed to update resources:", err) } finally { setSavingOnboot(false) } } const handleVMControl = async (vmid: number, action: string) => { setControlLoading(true) try { await fetchApi(`/api/vms/${vmid}/control`, { method: "POST", body: JSON.stringify({ action }), }) // Any control action can change what the guest reports: a stop // hides runtime-only fields, a start may bring a new config that // the user edited on the Proxmox UI while the guest was down, // and a reboot re-runs LXC init which can change installed // versions of tracked apps. Drop every local + shared cache for // this vmid so the next open pulls a fresh scan across all tabs. const cache = vmModalCacheRef.current cache.details.delete(vmid) cache.backups.delete(vmid) cache.mountPoints.delete(vmid) cache.schedule.delete(vmid) cache.firewall.delete(vmid) invalidateLxcApps(vmid) 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 : t("vmLxc.errors.unknown") alert(t("vmLxc.errors.controlFailed", { action, vmid, message: 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 = `=== ${t("vmLxc.logs.header", { name: vmName, vmid })} ===\n` logText += `${t("vmLxc.logs.node")}: ${data.node}\n` logText += `${t("vmLxc.logs.type")}: ${data.type}\n` logText += `${t("vmLxc.logs.totalLines")}: ${data.log_lines}\n` logText += `${t("vmLxc.logs.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 getStatusLabel = (status: string, uppercase = true) => { const label = status === "running" ? t("vmLxc.running") : status === "stopped" ? t("vmLxc.stopped") : status return uppercase ? label.toUpperCase() : label } 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 : [] // Status filter for the "Virtual Machines & Containers" list. Persisted // to localStorage so a reload keeps the operator's last view. const [statusFilter, setStatusFilter] = useState<"all" | "running" | "stopped">(() => { if (typeof window === "undefined") return "all" const stored = window.localStorage.getItem("proxmenux.vmListFilter") return stored === "running" || stored === "stopped" ? stored : "all" }) useEffect(() => { if (typeof window !== "undefined") { window.localStorage.setItem("proxmenux.vmListFilter", statusFilter) } }, [statusFilter]) const statusCounts = useMemo(() => ({ all: safeVMData.length, running: safeVMData.filter((vm) => vm.status === "running").length, stopped: safeVMData.filter((vm) => vm.status === "stopped").length, }), [safeVMData]) const filteredVMs = useMemo(() => { if (statusFilter === "all") return safeVMData return safeVMData.filter((vm) => vm.status === statusFilter) }, [safeVMData, statusFilter]) // ── LXC update apply flow (Phase 2a/b) ──────────────────────────── // Users pick a target (OS, App, both) + backup / restart options, // click Apply, and the ScriptTerminalModal streams the apply run. // The backend owns finalization from the real process exit; the browser // callback below is an idempotent compatibility signal and immediate UI // revalidation, not the source of truth for notifications. const [applyOpen, setApplyOpen] = useState(false) const [applyVmid, setApplyVmid] = useState(null) const [applyTarget, setApplyTarget] = useState<"os" | "app" | "both">("os") // Opt-in, not opt-out. Snapshot backups take time and disk, and // for most routine apt updates the user doesn't want to trigger // a vzdump — should be a deliberate choice. If a persisted schedule // has `backup: true` (Options card), that value overrides this // default when the modal opens. const [applyBackup, setApplyBackup] = useState(false) const [applyBackupStorage, setApplyBackupStorage] = useState("") const [applyRestart, setApplyRestart] = useState(false) const [applyStartedAt, setApplyStartedAt] = useState(0) const [applyRunId, setApplyRunId] = useState("") const [applyTargetIds, setApplyTargetIds] = useState([]) const [applyTargetLabels, setApplyTargetLabels] = useState([]) // Extra state carried alongside applyTarget when the App branch is // driven by a user-defined `update_command` on a specific registered // app (not the CT-wide /usr/bin/update). Passed to the terminal // script as UPDATE_COMMAND. A custom command always replaces the // Helper-Scripts updater for that application. const [applyUpdateCommand, setApplyUpdateCommand] = useState("") const [applyAppName, setApplyAppName] = useState("") const [applyRunHelper, setApplyRunHelper] = useState(false) const [applyAllowHelperWithCustom, setApplyAllowHelperWithCustom] = useState(false) const [applyDockerStandaloneTargets, setApplyDockerStandaloneTargets] = useState("") const [applyDockerEngine, setApplyDockerEngine] = useState(false) const [applyDockerRefresh, setApplyDockerRefresh] = useState(false) // Updates tab — inline custom-command editor state. Keyed on app.id // so the user can open one editor at a time; opening a second closes // the first (kept in localState because there's never a need to edit // two at once). `showHiddenNotices` opts back into displaying the // Case-3a "no method" cards the user previously dismissed. const [customCmdEditingApp, setCustomCmdEditingApp] = useState(null) const [customCmdDraft, setCustomCmdDraft] = useState("") const [customCmdSaving, setCustomCmdSaving] = useState(false) const [showHiddenNotices, setShowHiddenNotices] = useState(false) const canonicalHelperUpdateCommand = (slug?: string | null) => { const cleanSlug = (slug || "").trim() if (!/^[A-Za-z0-9._-]+$/.test(cleanSlug)) return "" return `PHS_SILENT=1 bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/${cleanSlug}.sh)"` } // Docker Engine is updated by a protected host-side runner rather // than by an arbitrary command inside the CT. Surface the exact // command in the same editor used by every other app, but recognise // this canonical value as the integrated method if it is saved. const canonicalDockerEngineUpdateCommand = 'python3 /usr/local/share/proxmenux/monitor-app/usr/bin/update_docker_engine.py --vmid "$VMID"' const openCustomCmdEditor = (app: LxcAppWatch, initialCommand = "") => { setCustomCmdEditingApp(app.id) setCustomCmdDraft(app.update_command || initialCommand) } // ── Options card unified state ────────────────────────────────── // Single source of truth for apply preferences (backup + restart) // used by BOTH the manual "Apply update" buttons AND the scheduled // runs — persisted per-CT in the sidecar's schedule object. Also // holds the schedule config itself and any external host cron // detected via the community-scripts pattern. All loaded once when // the user opens the Updates tab of a specific LXC. const [scheduleLoaded, setScheduleLoaded] = useState(null) const [scheduleEnabled, setScheduleEnabled] = useState(false) const [scheduleCron, setScheduleCron] = useState("0 3 * * *") const [schedulePreset, setSchedulePreset] = useState("daily-3am") const [scheduleTarget, setScheduleTarget] = useState<"os" | "app" | "both">("both") const [scheduleTargets, setScheduleTargets] = useState(["os", "apps"]) const [scheduleReleaseDelayDays, setScheduleReleaseDelayDays] = useState(0) const [scheduleLastRunAt, setScheduleLastRunAt] = useState(null) const [scheduleLastRunStatus, setScheduleLastRunStatus] = useState(null) const [scheduleLastRunReason, setScheduleLastRunReason] = useState(null) const [scheduleSaving, setScheduleSaving] = useState(false) const [scheduleError, setScheduleError] = useState(null) const [externalCron, setExternalCron] = useState<{ source: string cron_line: string cron: string human_schedule: string type: string variant?: string scope?: string } | null>(null) // True when the server returned a schedule with a non-empty `cron` // field — lets the view mode distinguish "configured but disabled" // (Switch is off but a schedule exists) from "nothing scheduled". const [scheduleConfigured, setScheduleConfigured] = useState(false) // Options card edit-mode toggle. View mode shows persisted config // read-only; edit mode swaps to the sunken-input pattern per the // global card-contrast rule (see project memory). const [optionsEditMode, setOptionsEditMode] = useState(false) // Snapshot of state at the moment Edit is entered so Cancel can // fully restore. Save wipes it after PUTting. const [optionsSnapshot, setOptionsSnapshot] = useState(null) // Reusable manual bulk update. This intentionally does not share state // with scheduled updates: changing a one-click manual selection must never // rewrite the cron automation configured in Options. const [bulkLoaded, setBulkLoaded] = useState(null) const [bulkConfigured, setBulkConfigured] = useState(false) const [bulkTargets, setBulkTargets] = useState(["os"]) const [bulkPersistedTargets, setBulkPersistedTargets] = useState(["os"]) const [bulkEditMode, setBulkEditMode] = useState(false) const [bulkSaving, setBulkSaving] = useState(false) const [bulkApplying, setBulkApplying] = useState(false) const [bulkError, setBulkError] = useState(null) // Cron presets — every entry maps a friendly label to a real // 5-field cron expression the backend parser accepts. Order + slugs // stable so the Select value round-trips a saved schedule. const CRON_PRESETS: { value: string; label: string; cron: string }[] = [ { value: "hourly", label: t("vmLxc.cronPresets.hourly"), cron: "0 * * * *" }, { value: "daily-3am", label: t("vmLxc.cronPresets.dailyAt3"), cron: "0 3 * * *" }, { value: "daily-noon", label: t("vmLxc.cronPresets.dailyAtNoon"), cron: "0 12 * * *" }, { value: "weekly-sun-3am", label: t("vmLxc.cronPresets.weeklySun3"), cron: "0 3 * * 0" }, { value: "monthly-1st-3am", label: t("vmLxc.cronPresets.monthly1st3"), cron: "0 3 1 * *" }, { value: "custom", label: t("vmLxc.cronPresets.custom"), cron: "" }, ] const applySchedulePayload = (s: any) => { if (!s || typeof s !== "object") return setScheduleEnabled(!!s.enabled) setScheduleConfigured(!!s.cron) const cron = s.cron || "0 3 * * *" setScheduleCron(cron) const matched = CRON_PRESETS.find((p) => p.value !== "custom" && p.cron === cron) setSchedulePreset(matched ? matched.value : "custom") const legacyTarget = (s.target || "both") as "os" | "app" | "both" setScheduleTarget(legacyTarget) setScheduleTargets(Array.isArray(s.targets) && s.targets.length ? s.targets.map((value: any) => String(value)) : ([...(legacyTarget !== "app" ? ["os"] : []), ...(legacyTarget !== "os" ? ["apps"] : [])])) setScheduleReleaseDelayDays(Number.isInteger(Number(s.release_delay_days)) ? Number(s.release_delay_days) : 0) if (s.backup !== undefined) setApplyBackup(!!s.backup) if (s.backup_storage) setApplyBackupStorage(s.backup_storage) if (s.restart !== undefined) setApplyRestart(!!s.restart) setScheduleLastRunAt(s.last_run_at || null) setScheduleLastRunStatus(s.last_run_status || null) setScheduleLastRunReason(s.last_run_reason || null) setExternalCron(s.external_cron || null) } const loadSchedule = async (vmid: number) => { setScheduleError(null) // Seed from ref cache so tab-switching to Updates isn't a "Loading…" // moment when the payload was already fetched this session. const cachedSched = vmModalCacheRef.current.schedule.get(vmid) if (cachedSched) applySchedulePayload(cachedSched) try { const s: any = await fetchApi(`/api/vms/${vmid}/schedule`) if (s && typeof s === "object") { vmModalCacheRef.current.schedule.set(vmid, s) applySchedulePayload(s) } } catch (e: any) { setScheduleError(e?.message || "Could not load schedule") } finally { setScheduleLoaded(vmid) } } const loadBulkUpdate = async (vmid: number) => { setBulkError(null) setBulkLoaded(null) setBulkEditMode(false) try { const config: any = await fetchApi(`/api/vms/${vmid}/bulk-update`) const targets = Array.isArray(config?.targets) ? Array.from(new Set(config.targets.map((value: any) => String(value)))) as string[] : [] const configured = targets.includes("os") && targets.some((value) => value !== "os") const next = configured ? targets : ["os"] setBulkConfigured(configured) setBulkTargets(next) setBulkPersistedTargets(next) } catch (e: any) { setBulkConfigured(false) setBulkTargets(["os"]) setBulkPersistedTargets(["os"]) setBulkError(e?.message || "Could not load bulk update") } finally { setBulkLoaded(vmid) } } const saveBulkUpdate = async (vmid: number) => { setBulkSaving(true) setBulkError(null) try { const config: any = await fetchApi(`/api/vms/${vmid}/bulk-update`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ targets: bulkTargets }), }) const targets = Array.isArray(config?.targets) ? config.targets.map((value: any) => String(value)) : bulkTargets setBulkTargets(targets) setBulkPersistedTargets(targets) setBulkConfigured(true) setBulkEditMode(false) } catch (e: any) { setBulkError(e?.message || t("vmLxc.bulkUpdate.saveFailed")) } finally { setBulkSaving(false) } } const deleteBulkUpdate = async (vmid: number) => { if (!confirm(t("vmLxc.bulkUpdate.deleteConfirm"))) return setBulkSaving(true) setBulkError(null) try { await fetchApi(`/api/vms/${vmid}/bulk-update`, { method: "DELETE" }) setBulkTargets(["os"]) setBulkPersistedTargets(["os"]) setBulkConfigured(false) setBulkEditMode(false) } catch (e: any) { setBulkError(e?.message || t("vmLxc.bulkUpdate.deleteFailed")) } finally { setBulkSaving(false) } } const applyBulkUpdate = async (vmid: number) => { setBulkApplying(true) setBulkError(null) try { const plan: any = await fetchApi(`/api/vms/${vmid}/bulk-update/plan`, { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}", }) openApplyTerminal(vmid, "both", { updateCommand: plan.update_command || "", appName: plan.app_name || "", runHelper: plan.run_helper === true, allowHelperWithCustom: plan.allow_helper_with_custom === true, dockerStandaloneTargets: Array.isArray(plan.docker_standalone_targets) ? plan.docker_standalone_targets : [], dockerEngine: plan.update_docker_engine === true, dockerRefresh: plan.refresh_docker_inventory === true, targetIds: Array.isArray(plan.targets) ? plan.targets : [], targetLabels: Array.isArray(plan.labels) ? plan.labels : [], }) } catch (e: any) { setBulkError(e?.message || t("vmLxc.bulkUpdate.planFailed")) } finally { setBulkApplying(false) } } const saveSchedule = async (vmid: number) => { setScheduleSaving(true) setScheduleError(null) // Only persist a cron when the user actually wants a schedule. // Prevents the delete-then-save recreation bug: after Delete we // leave scheduleConfigured=false and Save PUTs cron="" so the // backend doesn't resurrect the schedule. const cronToSave = scheduleEnabled || scheduleConfigured ? scheduleCron : "" const hasOsTarget = scheduleTargets.includes("os") const hasAppTarget = scheduleTargets.some((value) => value !== "os") const derivedTarget: "os" | "app" | "both" = hasOsTarget && hasAppTarget ? "both" : hasOsTarget ? "os" : "app" try { await fetchApi(`/api/vms/${vmid}/schedule`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ enabled: scheduleEnabled, cron: cronToSave, target: derivedTarget, targets: scheduleTargets, release_delay_days: scheduleReleaseDelayDays, backup: applyBackup, backup_storage: applyBackupStorage || selectedBackupStorage || "", restart: applyRestart, }), }) if (cronToSave.trim()) setScheduleConfigured(true) } catch (e: any) { setScheduleError(e?.message || "Save failed") } finally { setScheduleSaving(false) } } const enterOptionsEdit = () => { setOptionsSnapshot({ backup: applyBackup, backup_storage: applyBackupStorage, restart: applyRestart, scheduleEnabled: scheduleEnabled, scheduleCron: scheduleCron, schedulePreset: schedulePreset, scheduleTarget: scheduleTarget, scheduleTargets: [...scheduleTargets], scheduleReleaseDelayDays: scheduleReleaseDelayDays, }) setOptionsEditMode(true) } const cancelOptionsEdit = () => { if (optionsSnapshot) { setApplyBackup(optionsSnapshot.backup) setApplyBackupStorage(optionsSnapshot.backup_storage) setApplyRestart(optionsSnapshot.restart) setScheduleEnabled(optionsSnapshot.scheduleEnabled) setScheduleCron(optionsSnapshot.scheduleCron) setSchedulePreset(optionsSnapshot.schedulePreset) setScheduleTarget(optionsSnapshot.scheduleTarget) setScheduleTargets(optionsSnapshot.scheduleTargets || ["os", "apps"]) setScheduleReleaseDelayDays(optionsSnapshot.scheduleReleaseDelayDays) } setOptionsSnapshot(null) setOptionsEditMode(false) } const saveOptionsEdit = async () => { if (!selectedVM) return await saveSchedule(selectedVM.vmid) setOptionsSnapshot(null) setOptionsEditMode(false) } const deleteScheduleFromOptions = async () => { if (!selectedVM) return if (!confirm(t("vmLxc.scheduled.deleteConfirm"))) return setScheduleSaving(true) try { await fetchApi(`/api/vms/${selectedVM.vmid}/schedule`, { method: "DELETE" }) setScheduleEnabled(false) setScheduleConfigured(false) setScheduleCron("0 3 * * *") setSchedulePreset("daily-3am") setScheduleLastRunAt(null) setScheduleLastRunStatus(null) setScheduleLastRunReason(null) setScheduleReleaseDelayDays(0) } catch (e: any) { setScheduleError(e?.message || "Delete failed") } finally { setScheduleSaving(false) } } // Turn a 5-field cron into a plain-English label — mirrors the // backend's _humanise_cron so view mode matches the picker's // preset labels. const humanCron = (expr: string): string => { if (!expr) return "" const parts = expr.trim().split(/\s+/) if (parts.length !== 5) return expr const [m, h, d, mo, w] = parts const hhmm = () => { const hn = parseInt(h, 10), mn = parseInt(m, 10) if (isNaN(hn) || isNaN(mn)) return `${h}:${m}` return `${String(hn).padStart(2, "0")}:${String(mn).padStart(2, "0")}` } if (d === "*" && mo === "*" && w === "*" && /^\d+$/.test(m) && /^\d+$/.test(h)) return `Daily at ${hhmm()}` if (d === "*" && mo === "*" && /^\d+$/.test(w) && /^\d+$/.test(m) && /^\d+$/.test(h)) { const wdays = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"] const wn = parseInt(w, 10) const wname = (wn >= 0 && wn <= 6) ? wdays[wn] : w return `Weekly (${wname} ${hhmm()})` } if (mo === "*" && w === "*" && /^\d+$/.test(d) && /^\d+$/.test(m) && /^\d+$/.test(h)) return `Monthly (day ${parseInt(d, 10)} at ${hhmm()})` if (h === "*" && d === "*" && mo === "*" && w === "*" && m === "0") return "Hourly" return expr } // Load the schedule once whenever the user opens the Updates tab // of a specific LXC. Keying on vmid keeps us from re-fetching on // every render but also refetches after switching CTs. useEffect(() => { if (activeModalTab !== "updates") return if (!selectedVM || selectedVM.type !== "lxc") return if (scheduleLoaded !== selectedVM.vmid) loadSchedule(selectedVM.vmid) if (bulkLoaded !== selectedVM.vmid) loadBulkUpdate(selectedVM.vmid) // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeModalTab, selectedVM?.vmid]) // Docker drift is opt-in: read it only after Docker has been registered and // only when the user opens Updates. This request deliberately DOES NOT use // force=1: the process-memory inventory belongs to the startup/lifecycle and // daily rolling collectors. A normal open consumes that value; if a backend // restart briefly leaves the browser ahead of the new process cache, allow a // bounded retry instead of permanently marking this CT as already requested. useEffect(() => { if (activeModalTab !== "updates" || !selectedVM || selectedVM.type !== "lxc") return const registered = (selectedVM.app_watches || []).some((app) => app.helper_slug === "docker") if (!registered || selectedVM.docker_inventory?.available) return const lastRequest = dockerInventoryRequestedRef.current.get(selectedVM.vmid) || 0 if (Date.now() - lastRequest < 30_000) return dockerInventoryRequestedRef.current.set(selectedVM.vmid, Date.now()) fetchApi(`/api/vms/${selectedVM.vmid}/docker/inventory`) .then(() => mutate()) .catch(() => dockerInventoryRequestedRef.current.delete(selectedVM.vmid)) }, [activeModalTab, selectedVM?.vmid, selectedVM?.app_watches, selectedVM?.docker_inventory, mutate]) const refreshDockerInventory = async (vmid: number) => { setDockerInventoryRefreshingVmid(vmid) try { const inventory = await fetchApi( `/api/vms/${vmid}/docker/inventory?force=1`, ) // The force endpoint already returns the complete authoritative Docker // snapshot. Publish it directly into SWR and the open modal instead of // waiting for an unrelated full /api/vms revalidation. Lifecycle app // discovery can still be running after a restore; it must never keep // this button spinning once the Docker comparison itself has finished. await mutate( (current) => Array.isArray(current) ? current.map((vm) => vm.vmid === vmid ? { ...vm, docker_inventory: inventory } : vm) : current, { revalidate: false }, ) setSelectedVM((current) => current?.vmid === vmid ? { ...current, docker_inventory: inventory } : current) } catch (error) { console.error(`Failed to refresh Docker inventory for CT ${vmid}:`, error) } finally { setDockerInventoryRefreshingVmid(null) } } const closeCustomCmdEditor = () => { setCustomCmdEditingApp(null) setCustomCmdDraft("") } // Persists a partial update to /api/vms//apps/. The // update_app validator on the backend performs a full-config // replace, so we hydrate the current app payload with the patch // before PUTting to preserve every other field the user set. const patchAppWatch = async ( vmid: number, app: LxcAppWatch, patch: Record, ) => { // Fetch the full current config for this app so we can echo it // back with the patch applied — the backend replaces the whole // record and would drop any field we omitted. const full: any = await fetchApi(`/api/vms/${vmid}/apps`) const current = (full?.apps || []).find((a: any) => a.id === app.id) if (!current) throw new Error("app not found in sidecar") const { id: _id, state: _state, created_at: _created, ...rest } = current const payload = { ...rest, ...patch } const updated: any = await fetchApi(`/api/vms/${vmid}/apps/${app.id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }) // The PUT response is the complete sidecar. Publish it directly // instead of evicting newer data and forcing the App tab through a // cold-loading state when the user switches tabs. setLxcAppsCached(vmid, updated) mutate() } const saveCustomCommand = async (vmid: number, app: LxcAppWatch) => { setCustomCmdSaving(true) try { await patchAppWatch(vmid, app, { update_command: customCmdDraft.trim(), // Saving a command implicitly re-enables the notice (moot — // the notice only shows when there is no command). hide_no_updater_notice: false, }) closeCustomCmdEditor() } catch (e) { alert(`Could not save custom command: ${(e as any)?.message || e}`) } finally { setCustomCmdSaving(false) } } const removeCustomCommand = async (vmid: number, app: LxcAppWatch) => { if (!confirm(`Remove the custom update command for "${app.name}"?`)) return setCustomCmdSaving(true) try { await patchAppWatch(vmid, app, { update_command: "" }) closeCustomCmdEditor() } catch (e) { alert(`Could not remove custom command: ${(e as any)?.message || e}`) } finally { setCustomCmdSaving(false) } } const hideNoUpdaterNotice = async (vmid: number, app: LxcAppWatch) => { try { await patchAppWatch(vmid, app, { hide_no_updater_notice: true }) } catch (e) { alert(`Could not hide notice: ${(e as any)?.message || e}`) } } const openApplyTerminal = ( vmid: number, target: "os" | "app" | "both", opts?: { updateCommand?: string; appName?: string; runHelper?: boolean; allowHelperWithCustom?: boolean; dockerStandaloneTargets?: string[]; dockerEngine?: boolean; dockerRefresh?: boolean; targetIds?: string[]; targetLabels?: string[] }, ) => { const fallbackTargets = target === "os" ? ["os"] : target === "both" ? ["os", "apps"] : opts?.dockerEngine ? ["docker-engine"] : opts?.dockerStandaloneTargets?.length ? opts.dockerStandaloneTargets.map((name) => `docker-container:${name}`) : opts?.dockerRefresh ? ["docker-images"] : ["apps"] const fallbackLabels = target === "os" ? ["OS"] : target === "both" ? ["OS", "Applications"] : [opts?.appName || (opts?.dockerEngine ? "Docker Engine" : "Application")] const runId = typeof window !== "undefined" && typeof window.crypto?.randomUUID === "function" ? window.crypto.randomUUID() : `manual-${Date.now()}-${Math.random().toString(36).slice(2, 10)}` setApplyVmid(vmid) setApplyTarget(target) setApplyRunId(runId) setApplyTargetIds(opts?.targetIds?.length ? opts.targetIds : fallbackTargets) setApplyTargetLabels(opts?.targetLabels?.length ? opts.targetLabels : fallbackLabels) setApplyUpdateCommand(opts?.updateCommand || "") setApplyAppName(opts?.appName || "") setApplyRunHelper(opts?.runHelper === true) setApplyAllowHelperWithCustom(opts?.allowHelperWithCustom === true) setApplyDockerStandaloneTargets((opts?.dockerStandaloneTargets || []).join(",")) setApplyDockerEngine(opts?.dockerEngine === true) setApplyDockerRefresh(opts?.dockerRefresh === true || opts?.dockerEngine === true || !!opts?.dockerStandaloneTargets?.length) // Default storage to the same one the manual backup modal picked // (already resolved to the first vzdump-capable storage). if (!applyBackupStorage && selectedBackupStorage) { setApplyBackupStorage(selectedBackupStorage) } else if (!applyBackupStorage && backupStorages.length > 0) { setApplyBackupStorage(backupStorages[0].storage) } setApplyStartedAt(Date.now()) setApplyOpen(true) } const handleApplyComplete = async (exitCode = 0) => { if (applyVmid == null) return const duration = Math.max(0, Math.round((Date.now() - applyStartedAt) / 1000)) // The modal fires onComplete on any WS close (success or user cancel). // Report the same run ID so the backend can collapse this compatibility // callback with its authoritative process-completion hook. try { await fetchApi(`/api/lxc-updates/${applyVmid}/applied`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ success: exitCode === 0, run_id: applyRunId, target: applyTarget, requested_targets: applyTargetIds, target_labels: applyTargetLabels, duration_seconds: duration, ct_name: selectedVM?.name || `CT-${applyVmid}`, refresh_docker_inventory: applyDockerRefresh, }), }) } catch { // Non-fatal — the backend process hook owns finalization. } // The apply ran an updater inside the CT which likely changed the // installed_version of tracked apps AND the OS-package snapshot. // Drop the host-derived caches so the next poll re-scans and the // "Update available" badge doesn't linger with stale data. // Without this, users had to close and reopen the whole Monitor // for the modal to reflect the post-update state. const c = vmModalCacheRef.current c.details.delete(applyVmid) c.schedule.delete(applyVmid) // Keep the last complete App bundle visible while a forced network // revalidation replaces it in the shared cache. fetchLxcApps always // performs the requests (the data map is only a render seed), so no // invalidation is needed and there is no "Loading applications" flash. void fetchLxcApps(applyVmid) // Enter the "Comprobando resultado…" state on the Updates tab. // Baseline snapshot lets a downstream useEffect detect when the // SWR poll actually delivers the post-apply counts (as opposed // to seeing the same stale count still in flight). Was safe // before to skip this because the parent modal closed with the // terminal; now that we keep it open on purpose, the user would // otherwise be staring at "40 pending" right after applying. setUpdatesBaselineCount(selectedVM?.update_check?.count ?? 0) setUpdatesResult(null) setUpdatesRefreshing(true) // Force an immediate SWR revalidation instead of waiting up to // 2.5 s for the natural poll — the sooner the new counts land, // the sooner the banner appears. mutate() is safe now: the // parent Dialog's onOpenChange guard swallows any spurious close // events triggered by re-render cascades (see the Dialog guard // in the JSX below). void mutate() } // Render the "📦 N updates / 🛡 N security" badge next to an LXC in // the dashboard list. Used ONLY in the card row alongside Uptime — // the modal surfaces the same info via a dedicated tab instead of // duplicating a badge in its header. // // Sizing matches the sibling "Uptime: …" text (text-sm + h-4 icon) // so the row reads as a single visual unit. Colour is violet, the // shared accent for "managed updates" across notifications and UI // (mirrors the Secure Gateway visual treatment). Security count // stays red because it's still an urgency cue independent of the // update theme. // Aggregate updates counter (OS packages + registered apps that // aren't opted out of the badge). Returns an update_check-shaped // object so `renderLxcUpdateBadge` and the tab count can consume it // without knowing about app entries. The underlying `update_check` // stays strictly OS-only on the backend — the Updates tab's "OS // packages" section needs a clean `available`/`count` to avoid a // false "N package pending" every time a registered app has an // upstream bump. const getAggregateUpdateCheck = (vm: VMData): LxcUpdateCheck | undefined => { const uc = vm.update_check const appCount = (vm.app_watches || []).filter( (a) => a.update_available === true && !a.exclude_from_badge, ).length const dockerRegistered = (vm.app_watches || []).some((a) => a.helper_slug === "docker") const dockerCount = dockerRegistered ? (vm.docker_inventory?.update_count ?? 0) : 0 const osCount = uc?.count ?? 0 const total = osCount + appCount + dockerCount if (!uc && appCount === 0 && dockerCount === 0) return undefined if (total === 0) return uc return { ...(uc || {}), count: total, available: true, last_check: uc?.last_check ?? new Date().toISOString(), } as LxcUpdateCheck } const renderLxcUpdateBadge = ( uc?: LxcUpdateCheck, compact = false, onClick?: () => void, ) => { if (!uc?.available || !uc.count || uc.count <= 0) return null const last = uc.last_check ? new Date(uc.last_check).toLocaleString() : "—" const topNames = (uc.packages || []) .slice(0, 5) .map((p) => p.name) .join(", ") const secHint = uc.security_count > 0 ? ` · ${uc.security_count} ${t("vmLxc.updatesPanel.security")}` : "" // Tooltip leads with the action when the badge is clickable so the // affordance is explicit on hover — the chevron at the end of the // badge reinforces the same signal visually for users who don't // hover (mobile). const tooltipPrefix = onClick ? `${t("vmLxc.updatesPanel.clickToView")} · ` : "" const tooltip = `${tooltipPrefix}${t("vmLxc.updatesPanel.lastChecked")} ${last}${secHint}${topNames ? ` · ${topNames}` : ""}` // Compact = mobile card; matches the surrounding 10-12px chrome // (ID line, type badge) so the count doesn't visually dominate. // Non-compact = desktop card row, sized to match "Uptime: ..." text. const sizing = compact ? "text-[11px] gap-1 px-1.5 py-0" : "text-sm gap-1.5 px-2 py-0.5" const iconSize = compact ? "h-3 w-3" : "h-4 w-4" // Only soften the bg on hover — no border change, no focus ring. // The chevron at the end of the badge carries the "open this" // affordance on its own. The Badge component's CVA base adds a // `focus:ring-2 focus:ring-ring focus:ring-offset-2` (the white // double border we kept seeing on tap/click) — explicitly cancel // every piece of it here. const clickable = onClick ? "cursor-pointer hover:bg-violet-500/20 transition-colors focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0" : "" return ( {/* Icon-only badge: `↑` (ArrowUpCircle, same glyph as the Apply update buttons) + count. Dropped the "N updates" / "N update" text so we don't need to translate the label into every locale, and the badge takes less horizontal space — matters most on the LXC card row where uptime, IP and other chips compete for width. Count alone with the up-arrow reads unambiguously as "pending updates". */} {uc.count} {/* Chevron only when the badge is wired up as a clickable shortcut — its absence on the dashboard card avoids implying interactivity where there isn't any (the whole row is the click target there). */} {onClick && } ) } // App Watch badge (Phase 2c) — shown next to the update badge in // the header, and inline on the desktop card row. Three states: // • up-to-date (installed==latest) → green filled // • update available → orange filled // • no upstream check / no version → neutral outline // Clicking always opens the App tab. const renderLxcAppBadge = ( aw?: LxcAppWatch | null, compact = false, onClick?: () => void, ) => { if (!aw?.name) return null const installed = aw.installed_version const hasUpdate = aw.update_available === true const upToDate = aw.update_available === false && !!installed const color = hasUpdate ? "bg-purple-600/15 text-purple-300 border-purple-500/40" : upToDate ? "bg-emerald-500/10 text-emerald-400 border-emerald-500/30" : "bg-muted text-muted-foreground border-border" const sizing = compact ? "text-[11px] gap-1 px-1.5 py-0" : "text-sm gap-1.5 px-2 py-0.5" const iconSize = compact ? "h-3 w-3" : "h-4 w-4" const clickable = onClick ? "cursor-pointer hover:brightness-125 transition-all focus:outline-none focus:ring-0" : "" const tooltipParts: string[] = [] if (installed) tooltipParts.push(`Installed: ${installed}`) if (aw.latest_version) tooltipParts.push(`Latest: ${aw.latest_version}`) if (aw.checked_at) tooltipParts.push(`Checked: ${new Date(aw.checked_at).toLocaleString()}`) if (aw.error) tooltipParts.push(`Note: ${aw.error}`) const tooltip = (onClick ? "Click to open App tab · " : "") + tooltipParts.join(" · ") return ( {aw.name} {installed && {installed}} ) } // 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; cpu_cores?: number; cpu_threads?: 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" } const formatCoreCount = (count: number) => { const key = count === 1 ? "one" : count >= 2 && count <= 4 ? "few" : "many" return t(`vmLxc.coreCount.${key}`, { count }) } const displayedFirewallLogs = useMemo(() => { return firewallLogs.filter((entry) => { const text = (entry.t || "").trim().toLowerCase() return text.length > 0 && text !== "no content" }) }, [firewallLogs]) if (isLoading) { return (
{t("vmLxc.loadingTitle")}

{t("vmLxc.loadingDescription")}

) } if (error) { return (
{t("vmLxc.loadingError", { error: 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 `