diff --git a/AppImage/ProxMenux-1.2.4.1-beta.AppImage b/AppImage/ProxMenux-1.2.4.1-beta.AppImage index aa9cd567..e2fee240 100755 Binary files a/AppImage/ProxMenux-1.2.4.1-beta.AppImage and b/AppImage/ProxMenux-1.2.4.1-beta.AppImage differ diff --git a/AppImage/ProxMenux-Monitor.AppImage.sha256 b/AppImage/ProxMenux-Monitor.AppImage.sha256 index e2a4bf14..512219f5 100644 --- a/AppImage/ProxMenux-Monitor.AppImage.sha256 +++ b/AppImage/ProxMenux-Monitor.AppImage.sha256 @@ -1 +1 @@ -774c362ab738cb27ca4ae8e0025caea46bdbe01c8cebbb06ea82776072cb94a0 ProxMenux-1.2.4.1-beta.AppImage +cf60e05bc3c78cfb9dc2e269c564b6fee78238574143750201399acce308dd72 ProxMenux-1.2.4.1-beta.AppImage diff --git a/AppImage/components/lxc-app-panel.tsx b/AppImage/components/lxc-app-panel.tsx index 37614303..b462be9b 100644 --- a/AppImage/components/lxc-app-panel.tsx +++ b/AppImage/components/lxc-app-panel.tsx @@ -25,6 +25,7 @@ import { Loader2, Save, RefreshCw, Trash2, Package, ExternalLink, AlertTriangle, Info, PlusCircle, Pencil, ChevronDown, ChevronRight, EyeOff, ArrowUpCircle, RotateCcw, Check, Settings2, ShieldCheck, CheckCircle2, + Bell, BellOff, } from "lucide-react" import { Card, CardContent } from "./ui/card" import { Button } from "./ui/button" @@ -33,6 +34,7 @@ import { Label } from "./ui/label" import { Badge } from "./ui/badge" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select" import { fetchApi } from "../lib/api-config" +import { fetchLxcApps, getLxcAppsCached, invalidateLxcApps } from "../lib/lxc-apps-cache" import { useT } from "@/lib/i18n/provider" // installed_via is optional now — an empty value means "register only, @@ -82,6 +84,10 @@ interface AppConfig { health_path?: string logo_url?: string helper_slug?: string + // Per-app opt-out for the `app_update_available` notification. + // Absent / true = notify; false = silenced. Set from the bell + // toggle on each app card and/or the Edit form's checkbox. + notifications_enabled?: boolean } interface DetectedApp { @@ -174,6 +180,15 @@ interface Props { ctIp?: string | null onChange?: () => void managed?: ManagedAppInfo | null + // Optional seed payload from the parent's cross-open ref cache. When + // supplied, the panel renders with real content on the very first + // frame and only revalidates silently in the background — no + // "Loading applications…" flash on tab switch or modal reopen. Must + // include BOTH sidecar and suggestions — the panel's empty-state and + // detected-chip strip both depend on suggestions, so seeding sidecar + // alone briefly flashes "no apps registered" until suggestions + // arrives from the network. + initialData?: { sidecar: SidecarResponse; suggestions: Suggestions | null } | null } const EMPTY_APP: AppConfig = { @@ -216,11 +231,16 @@ function suggestPackageName(name: string) { .replace(/^-+|-+$/g, "") } -export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) { +export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData }: Props) { const t = useT() - const [loading, setLoading] = useState(true) - const [sidecar, setSidecar] = useState(null) - const [suggestions, setSuggestions] = useState(null) + // Seed from `initialData` first, then fall back to the shared cache + // module. Together those two sources cover every reopen scenario + // without flashing a spinner — see lxc-apps-cache.ts for the dedup + // logic that also keeps concurrent fetches from racing. + const seed = initialData ?? getLxcAppsCached(vmid) ?? null + const [loading, setLoading] = useState(!seed) + const [sidecar, setSidecar] = useState(seed?.sidecar ?? null) + const [suggestions, setSuggestions] = useState(seed?.suggestions ?? null) const [error, setError] = useState(null) // Editor state const [editing, setEditing] = useState<{ appId: string | null; draft: AppConfig } | null>(null) @@ -257,32 +277,55 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) { const load = useCallback(async () => { if (managed) { setLoading(false); return } - setLoading(true) + // Only show the spinner on cold loads. When we came in seeded via + // `initialData` or the shared cache, the sidecar is already + // populated and the user shouldn't see a flash of "Loading…" + // while we revalidate. + if (!sidecar) setLoading(true) setError(null) try { - const r: SidecarResponse = await fetchApi(`/api/vms/${vmid}/apps`) - setSidecar(r) - // Always fetch suggestions — used both by the empty-state form - // seed AND by the "Also detected on this container" chip strip - // that surfaces unregistered detections even after the CT already - // has ≥1 registered app. Previously gated on `!r.apps?.length`, - // which meant a CT with 1 registered + 1 detected-but-not-yet- - // registered app never showed the second app until the user - // opened + cancelled the editor (which triggered a re-load path - // that happened to fetch it). - try { - const s: Suggestions = await fetchApi(`/api/vms/${vmid}/apps/suggestions`) - setSuggestions(s) - } catch { /* non-fatal */ } + // `fetchLxcApps` bundles sidecar + suggestions in one shared + // in-flight promise. If the parent already fired this fetch on + // modal open (see prefetchVM / handleVMClick in + // virtual-machines.tsx), we await the SAME promise instead of + // duplicating the request against the backend — this eliminates + // the "Loading applications…" flash that used to show while a + // second, racing fetch caught up. + const bundle = await fetchLxcApps(vmid) + if (bundle) { + setSidecar(bundle.sidecar) + setSuggestions(bundle.suggestions) + } } catch (e: any) { setError(e?.message || t("vmLxc.appEditor.loadFailed")) } finally { setLoading(false) } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [vmid, managed]) useEffect(() => { load() }, [load]) + // Turn a raw backend error string ("network error: The read operation + // timed out", etc.) into a localized message. Upstream check errors + // are surfaced verbatim by `lxc_apps.py:_fetch_upstream()`, and the + // panel used to render them in English regardless of locale. Match + // the two shapes the backend produces today and fall back to the + // original string so unknown errors still show something useful. + const localizeUpstreamError = (msg: string | null | undefined): string => { + if (!msg) return "" + const trimmed = msg.trim() + const lower = trimmed.toLowerCase() + if (lower.startsWith("network error:")) { + const detail = trimmed.slice("network error:".length).trim() + if (detail.toLowerCase().includes("timed out") || detail.toLowerCase().includes("timeout")) { + return t("vmLxc.appEditor.upstreamErrorTimeout") + } + return t("vmLxc.appEditor.upstreamErrorNetwork", { detail }) + } + return msg + } + // Fetch the picker catalog once per panel mount. Best-effort — if // the API is unreachable, the picker just stays empty and users // type the app name manually (same as before this feature). @@ -473,6 +516,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) { }) if ((r as any).error) throw new Error((r as any).error) setSidecar(r) + invalidateLxcApps(vmid) setEditing(null) onChange?.() } catch (e: any) { @@ -490,6 +534,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) { method: "POST", }) setSidecar(r) + invalidateLxcApps(vmid) onChange?.() } catch (e: any) { setError(e?.message || t("vmLxc.appEditor.checkFailed")) @@ -498,6 +543,32 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) { } } + // Silence / re-enable `app_update_available` for this specific + // app. Full-record PUT because the backend replaces the whole + // config on update — omit a field and it disappears. We hydrate + // from the current app entry, flip the flag, and post it back. + const toggleAppNotifications = async (app: AppEntry) => { + setBusyAppId(app.id) + setError(null) + try { + const { id: _id, state: _state, created_at: _created, ...rest } = app + const nextEnabled = app.notifications_enabled === false + const payload = { ...rest, notifications_enabled: nextEnabled } + const r: SidecarResponse = await fetchApi(`/api/vms/${vmid}/apps/${app.id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }) + setSidecar(r) + invalidateLxcApps(vmid) + onChange?.() + } catch (e: any) { + setError(e?.message || t("vmLxc.appEditor.saveFailed")) + } finally { + setBusyAppId(null) + } + } + const removeOne = async (appId: string) => { if (!confirm("Remove this application from the CT's App tab?")) return setBusyAppId(appId) @@ -505,6 +576,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) { try { await fetchApi(`/api/vms/${vmid}/apps/${appId}`, { method: "DELETE" }) // Reload from server so the empty state re-fetches suggestions + invalidateLxcApps(vmid) await load() onChange?.() } catch (e: any) { @@ -531,6 +603,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) { body: JSON.stringify({ slug, dismissed: true }), }) setSidecar(r) + invalidateLxcApps(vmid) } catch (e: any) { setError(e?.message || t("vmLxc.appEditor.dismissFailed")) await load() // resync on failure @@ -553,6 +626,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) { body: JSON.stringify({ slug, dismissed: false }), }) setSidecar(r) + invalidateLxcApps(vmid) } catch (e: any) { setError(e?.message || t("vmLxc.appEditor.restoreFailed")) await load() @@ -709,7 +783,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) { {managed.error && (
-
{managed.error}
+
{localizeUpstreamError(managed.error)}
)} @@ -781,14 +855,16 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) { return (
- {/* Editor card — opaque `bg-card` (not the `/50` used by - view-mode cards) so the darker `bg-background` inputs sit - visibly recessed against it. Matches the sunken-input - pattern used in the PBS setup wizard: card = surface, - fields = wells inside the surface. Reverts to `bg-card/50` + {/* Editor card — raised to `bg-accent` (matches the tone + clickable cards get on hover) so the `bg-background` inputs + sit clearly recessed against it. The three descendant + selectors push every Input / Textarea / SelectTrigger + (role="combobox") under this card down to `bg-background` + in one shot, so future fields inherit the sunken look + without per-input styling. Reverts to `bg-card/50` automatically because this render branch only fires when `editing !== null`. */} - +
@@ -1422,6 +1498,28 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) { )}
+ {/* Per-app notification opt-out. Only makes sense for + apps with tracking configured — without an upstream + source there's no `app_update_available` event to + mute. Default is ON (checkbox checked); the bell + toggle on each card is a shortcut to the same field. */} + {method && ( +
+ +
+ )} + {error && (
@@ -1729,7 +1827,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) { {tracking && st?.error && (
-
{st.error}
+
{localizeUpstreamError(st.error)}
)} @@ -1794,6 +1892,27 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) { {t("vmLxc.appEditor.removeButton")}
+ {tracking && ( + + )} {tracking && (
diff --git a/AppImage/components/virtual-machines.tsx b/AppImage/components/virtual-machines.tsx index 01d579b2..37d6d95a 100644 --- a/AppImage/components/virtual-machines.tsx +++ b/AppImage/components/virtual-machines.tsx @@ -2,7 +2,8 @@ import type React from "react" -import { useState, useMemo, useEffect } from "react" +import { useState, useMemo, useEffect, useRef } from "react" +import { fetchLxcApps, getLxcAppsCached, invalidateLxcApps, seedLxcAppsCache } from "../lib/lxc-apps-cache" import { Card, CardContent, CardHeader, CardTitle } from "./ui/card" import { Badge } from "./ui/badge" import { Progress } from "./ui/progress" @@ -674,6 +675,22 @@ export function VirtualMachines() { 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: new Map(), + // NOTE: apps payload lives in the shared module `lxc-apps-cache` + // (dedup between this parent and LxcAppPanel — see fetchLxcApps). + schedule: new Map(), + mountPoints: new Map(), + firewall: new Map(), + }) 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 @@ -751,29 +768,29 @@ export function VirtualMachines() { }, []) 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. + // 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 || ipsLoaded || loadingIPs) return + if (!vmData || loadingIPs) return - const lxcs = vmData.filter((vm) => vm.type === "lxc") - - if (lxcs.length === 0) { - if (!cancelled) setIpsLoaded(true) - 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 < lxcs.length; i += batchSize) { + for (let i = 0; i < missing.length; i += batchSize) { if (cancelled) return - const batch = lxcs.slice(i, i + batchSize) + const batch = missing.slice(i, i + batchSize) await Promise.all( batch.map(async (lxc) => { @@ -781,7 +798,7 @@ export function VirtualMachines() { const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), 10000) - const details = await fetchApi(`/api/vms/${lxc.vmid}`) + const details = await fetchApi(`/api/vms/${lxc.vmid}`) clearTimeout(timeoutId) @@ -789,6 +806,8 @@ export function VirtualMachines() { 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}`) @@ -803,14 +822,14 @@ export function VirtualMachines() { if (cancelled) return setLoadingIPs(false) - setIpsLoaded(true) } fetchLXCIPs() return () => { cancelled = true } - }, [vmData, ipsLoaded, loadingIPs]) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [vmData, loadingIPs]) // Load initial network unit and listen for changes useEffect(() => { @@ -846,33 +865,56 @@ export function VirtualMachines() { 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([]) // 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 ?? []) + setMountPoints(seedMounts?.mount_points ?? []) + setAdHocMounts(seedMounts?.ad_hoc ?? []) + setDetailsLoading(!seedDetails) + setLoadingBackups(!seedBackups) + if (vm.type === "lxc") setLoadingMounts(!seedMounts) + // 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. + // 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}`) + 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 { @@ -880,6 +922,88 @@ export function VirtualMachines() { } } + // 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 + schedule?: any | null + mount_points?: { ok?: boolean; mount_points?: LxcMountPoint[]; ad_hoc?: LxcMountPoint[] } | 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, g.backups.backups) + 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) + } + if (g.schedule && typeof g.schedule === "object") { + cache.schedule.set(g.vmid, g.schedule) + } + if (g.mount_points?.ok) { + cache.mountPoints.set(g.vmid, { + mount_points: g.mount_points.mount_points || [], + ad_hoc: g.mount_points.ad_hoc || [], + }) + } + } + } + }) + .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) => { setLoadingMounts(true) try { @@ -890,8 +1014,11 @@ export function VirtualMachines() { ad_hoc: LxcMountPoint[] }>(`/api/lxc/${vmid}/mount-points`) if (response?.ok) { - setMountPoints(response.mount_points || []) - setAdHocMounts(response.ad_hoc || []) + const mp = response.mount_points || [] + const adhoc = response.ad_hoc || [] + setMountPoints(mp) + setAdHocMounts(adhoc) + vmModalCacheRef.current.mountPoints.set(vmid, { mount_points: mp, ad_hoc: adhoc }) } else { setMountPoints([]) setAdHocMounts([]) @@ -931,9 +1058,10 @@ export function VirtualMachines() { const fetchVmBackups = async (vmid: number) => { setLoadingBackups(true) try { - const response = await fetchApi(`/api/vms/${vmid}/backups`) + const response = await fetchApi<{ backups?: VMBackup[] }>(`/api/vms/${vmid}/backups`) if (response.backups) { setVmBackups(response.backups) + vmModalCacheRef.current.backups.set(vmid, response.backups) } } catch (error) { console.error("Error fetching VM backups:", error) @@ -948,23 +1076,41 @@ export function VirtualMachines() { // 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) => { - setLoadingFirewallLog(true) - setFirewallLogError(null) + // 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) { - setFirewallEnabled(true) - setFirewallLogs([]) - setFirewallLogError(error instanceof Error ? error.message : String(error)) + if (!seed) { + setFirewallEnabled(true) + setFirewallLogs([]) + setFirewallLogError(error instanceof Error ? error.message : String(error)) + } } finally { setLoadingFirewallLog(false) } @@ -1024,6 +1170,20 @@ export function VirtualMachines() { 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) @@ -1237,28 +1397,34 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { { 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") + setScheduleTarget(s.target || "both") + 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) + 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") { - 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") - setScheduleTarget(s.target || "both") - // Unified apply options — backup/restart/storage feed BOTH - // manual applies and scheduled runs. Values live in the - // schedule object even when enabled=false so preferences - // survive toggling the schedule off. - 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) - setExternalCron(s.external_cron || null) + vmModalCacheRef.current.schedule.set(vmid, s) + applySchedulePayload(s) } } catch (e: any) { setScheduleError(e?.message || "Could not load schedule") @@ -1407,6 +1573,9 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }) + // Drop the shared apps cache so the next open/refresh pulls fresh + // data. The panel's own load() picks it up on the next tick. + invalidateLxcApps(vmid) mutate() } const saveCustomCommand = async (vmid: number, app: LxcAppWatch) => { @@ -1486,6 +1655,16 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { } catch { // Non-fatal — the notification is a nice-to-have. } + // The apply ran an updater inside the CT which likely changed the + // installed_version of tracked apps AND the OS-package snapshot. + // Drop this vmid from every cache so the next open 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) + invalidateLxcApps(applyVmid) // Backend's POST /applied handler already force-refreshes the // managed_installs snapshot, so the next natural /api/vms poll // (every 2.5s via SWR refreshInterval) picks up the post-update @@ -1551,8 +1730,15 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { role={onClick ? "button" : undefined} tabIndex={onClick ? 0 : undefined} > - - {uc.count} {compact ? "" : (uc.count === 1 ? t("vmLxc.updatesPanel.updateSingular") : t("vmLxc.updatesPanel.updatePlural"))} + {/* 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 @@ -2055,15 +2241,48 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { const diskGB = (vm.disk / 1024 ** 3).toFixed(1) const maxDiskGB = (vm.maxdisk / 1024 ** 3).toFixed(1) const typeBadge = getTypeBadge(vm.type) - const lxcIP = vm.type === "lxc" ? vmConfigs[vm.vmid] : null + // IP resolution — try both sources so a card never + // ends up with an empty slot while one path is still + // in flight: + // 1) `vmConfigs` — populated by the dedicated + // LXC-IP-fetch effect (batches of 5, gated by + // `ipsLoaded` so it only runs once per mount). + // 2) `vmModalCacheRef.details` — populated by the + // background prefetcher that warms every guest's + // /api/vms/ payload. Contains the same + // `lxc_ip_info.primary_ip` the effect uses; if + // the prefetch landed first, we surface the IP + // immediately without waiting for the batch. + let lxcIP: string | null | undefined = null + if (vm.type === "lxc") { + lxcIP = vmConfigs[vm.vmid] + if (!lxcIP) { + const cached = vmModalCacheRef.current.details.get(vm.vmid) as any + lxcIP = cached?.lxc_ip_info?.primary_ip + || (cached?.config ? extractIPFromConfig(cached.config, cached.lxc_ip_info) : null) + } + } return (
handleVMClick(vm)} + onMouseEnter={() => prefetchVM(vm)} > -
+ {/* Row 1 — identity header. Just badges + name + + ID + update badge, no uptime/IP here. On + `lg+` (≥1024 px, e.g. iPad landscape and up) + uptime + IP show up in the row-2 left column; + on smaller viewports (tablet portrait, narrow + desktops) they disappear entirely — that + width is pre-mobile territory where the + metrics grid needs the whole card width and + a stray uptime line just competes for space. + Users who need uptime/IP on narrow screens + still see them inside the modal's Status + tab. */} +
{getStatusIcon(vm.status)} {getStatusLabel(vm.status)} @@ -2072,109 +2291,123 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { {typeBadge.icon} {typeBadge.label} -
-
- {vm.name} - ID: {vm.vmid} + {vm.name} + ID: {vm.vmid} + {vm.type === "lxc" && ( +
+ {renderLxcUpdateBadge(vm.update_check)}
-
ID: {vm.vmid}
-
- {lxcIP && ( - - IP: {lxcIP} - )} - - {t("vmLxc.uptime", { uptime: formatUptime(vm.uptime, t) })} - - {vm.type === "lxc" && renderLxcUpdateBadge(vm.update_check)}
-
-
-
{t("vmLxc.cpuUsage")}
-
{ - setSelectedMetric("cpu") // undeclared variable fix - }} - > + {/* Row 2 — 2-column layout on wide viewports + (`lg+`, ≥1024 px, iPad landscape and up): + left slot carries uptime + IP (as originally + designed), right slot the 5-column metrics + grid. Below `lg` the left slot is hidden + and the grid spans the full card width. */} +
+ {vm.status === "running" && ( +
+
+ + {t("vmLxc.uptime", { uptime: formatUptime(vm.uptime, t) })} + + {lxcIP && lxcIP !== "DHCP" && lxcIP !== "N/A" && ( + + + {lxcIP} + + )} +
+
+ )} +
+
+
{t("vmLxc.cpuUsage")}
{ + setSelectedMetric("cpu") + }} > - {cpuPercent}% +
+ {cpuPercent}% +
+
-
-
-
-
{t("vmLxc.memory")}
-
{ - setSelectedMetric("memory") - }} - > +
+
{t("vmLxc.memory")}
{ + setSelectedMetric("memory") + }} > - {memGB} / {maxMemGB} GB +
+ {memGB} / {maxMemGB} GB +
+
-
-
-
-
{t("vmLxc.diskUsage")}
-
{ - setSelectedMetric("disk") - }} - > +
+
{t("vmLxc.diskUsage")}
{ + setSelectedMetric("disk") + }} > - {diskGB} / {maxDiskGB} GB -
- -
-
- -
-
{t("vmLxc.diskIo")}
-
-
- - ↓ {formatBytes(vm.diskread, false)} -
-
- - ↑ {formatBytes(vm.diskwrite, false)} +
+ {diskGB} / {maxDiskGB} GB +
+
-
-
-
{t("vmLxc.networkIo")}
-
-
- - ↓ {formatBytes(vm.netin, true)} +
+
{t("vmLxc.diskIo")}
+
+
+ + ↓ {formatBytes(vm.diskread, false)} +
+
+ + ↑ {formatBytes(vm.diskwrite, false)} +
-
- - ↑ {formatBytes(vm.netout, true)} +
+ +
+
{t("vmLxc.networkIo")}
+
+
+ + ↓ {formatBytes(vm.netin, true)} +
+
+ + ↑ {formatBytes(vm.netout, true)} +
@@ -2196,13 +2429,22 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { {getTypeBadge(vm.type).label} - {/* Name and ID */} + {/* Name and ID. + Update indicator is icon-only (no count, no + badge chrome) sitting next to `ID: N` — the + purple up-arrow alone signals "this CT has + pending updates" without stealing width from + the name, which was getting truncated to 4-5 + chars before. The full count still shows on + the desktop card and inside the modal. */}
-
- {vm.name} - {vm.type === "lxc" && renderLxcUpdateBadge(vm.update_check, true)} +
{vm.name}
+
+ ID: {vm.vmid} + {vm.type === "lxc" && vm.update_check?.available && (vm.update_check?.count ?? 0) > 0 && ( + + )}
-
ID: {vm.vmid}
@@ -3434,6 +3676,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { vmid={selectedVM.vmid} ctIp={ctIp} onChange={() => mutate()} + initialData={getLxcAppsCached(selectedVM.vmid) ?? null} managed={ managedEntry ? { @@ -3620,8 +3863,28 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { const anyAppPending = (helperExists && trackedApps.some((a) => a.update_available === true)) || customCmdApps.some((a) => a.update_available === true) - const singleAppMethod = (helperExists ? 1 : 0) + customCmdApps.length === 1 - const combinedApp: { name: string; cmd: string; isHelper: boolean } | null = helperExists + // The combined "Apply OS + " button requires + // the app to be REGISTERED by the user AND actually + // tracked. Two independent gates: + // 1. helper_slug must match the CT's helper — a + // detected-but-not-registered app (e.g. AdGuard + // shown as "Detected" in the App tab) has no + // entry here and never triggers the button. + // 2. The registered entry must show evidence of + // tracking — installed_version present, or + // update_available defined either way. This + // guards against the catalog picker auto-filling + // helper_slug when the user picked the app only + // for its weblink and never configured updates; + // until a check has run, no combined button. + const helperRegistered = helperExists && trackedApps.some( + (a) => + !!a.helper_slug + && a.helper_slug === uc?.helper_slug + && (!!a.installed_version || a.update_available !== undefined), + ) + const singleAppMethod = (helperRegistered ? 1 : 0) + customCmdApps.length === 1 + const combinedApp: { name: string; cmd: string; isHelper: boolean } | null = helperRegistered ? { name: helperName || "application", cmd: "", isHelper: true } : customCmdApps.length === 1 ? { name: customCmdApps[0].name || "application", cmd: customCmdApps[0].update_command!, isHelper: false } @@ -3847,7 +4110,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { const hasUpdate = aw.update_available === true const upToDate = aw.update_available === false && !!aw.installed_version return ( -
+

@@ -3996,7 +4259,15 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { Purple when any part pending; green when everything up to date. */} {(() => { - const hasAnyAppMethod = helperExists || customCmdApps.length > 0 + // Same gate as `helperRegistered` above — + // a detected-but-not-registered helper + // must NOT surface any combined-apply + // button, single or multi. Without this + // the multi-app branch below still fired + // "Apply OS + Apps updates" for CTs whose + // only "app method" was an unregistered + // helper.sh on disk. + const hasAnyAppMethod = helperRegistered || customCmdApps.length > 0 if (!hasAnyAppMethod) return null // Single method — reuse the existing // TARGET=both path so the script picks @@ -4024,20 +4295,27 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { } // Multi-app case — build a single // UPDATE_COMMAND that chains every app - // method with `;`. Helper (if present) - // goes first as its full invocation - // (PHS_SILENT=1 bash /usr/bin/update), - // then each custom_command. Runs as - // one `pct exec sh -c` — no backend - // script change needed. + // method with `;`. Helper only chains + // when the user has REGISTERED the app + // it manages (same rule as the single + // case), so an unregistered helper.sh + // on disk never gets executed via the + // combined button. const parts: string[] = [] - if (helperExists) { + if (helperRegistered) { parts.push("PHS_SILENT=1 bash /usr/bin/update") } for (const a of customCmdApps) { parts.push(a.update_command!.trim()) } - const chained = parts.join("; ") + // Join with `&&` (fail-fast) so a mid-chain + // command failure aborts the rest and the + // final exit code reflects the failure. + // With `;`, `sh -c` returned only the last + // command's exit code and a broken update + // could look successful because a trailing + // no-op finished cleanly. + const chained = parts.join(" && ") return (
- {/* Terminal button for LXC containers - only when running */} - {selectedVM?.type === "lxc" && selectedVM?.status === "running" && ( -
- -
- )} -
- - - - -
+ {/* Footer controls — responsive layout: + • Mobile ( { + const hasTerminal = selectedVM?.type === "lxc" && selectedVM?.status === "running" + return ( +
+ {hasTerminal && ( + + )} + + + + +
+ ) + })()}
) : ( @@ -4780,7 +5076,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { {/* Backup Configuration Modal */} - + @@ -4808,7 +5104,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { {t("vmLxc.backupModal.storage")} - + @@ -4846,7 +5142,7 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { {t("vmLxc.backupModal.notification")} - + @@ -4898,11 +5194,11 @@ const handleDownloadLogs = async (vmid: number, vmName: string) => { {t("vmLxc.backupModal.notes")} -