Instant VM/LXC modals via bulk hydration + server prewarmer

Backend keeps every guest's modal payload (details, backups, apps, schedule, mount points) warm in-memory and exposes them through a single `/api/vms/modal-cache-all` endpoint. The dashboard hydrates its entire modal cache from that one request on page load, so opening any guest — first click or after coming back later — renders instantly. Replaces ~84 per-guest fetches with 1.
This commit is contained in:
MacRimi
2026-08-13 22:35:13 +02:00
parent 3fe9a49ebc
commit 037421d762
17 changed files with 1769 additions and 460 deletions
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
774c362ab738cb27ca4ae8e0025caea46bdbe01c8cebbb06ea82776072cb94a0 ProxMenux-1.2.4.1-beta.AppImage
cf60e05bc3c78cfb9dc2e269c564b6fee78238574143750201399acce308dd72 ProxMenux-1.2.4.1-beta.AppImage
+146 -27
View File
@@ -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<SidecarResponse | null>(null)
const [suggestions, setSuggestions] = useState<Suggestions | null>(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<SidecarResponse | null>(seed?.sidecar ?? null)
const [suggestions, setSuggestions] = useState<Suggestions | null>(seed?.suggestions ?? null)
const [error, setError] = useState<string | null>(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 && (
<div className="mt-3 p-2 rounded-md bg-amber-500/10 border border-amber-500/30 flex items-start gap-2">
<Info className="h-4 w-4 text-amber-400 flex-shrink-0 mt-0.5" />
<div className="text-xs text-amber-300">{managed.error}</div>
<div className="text-xs text-amber-300">{localizeUpstreamError(managed.error)}</div>
</div>
)}
@@ -781,14 +855,16 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
return (
<div className="space-y-4">
{/* 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`. */}
<Card className="border border-border bg-card">
<Card className="border border-border bg-accent [&_input]:bg-background [&_textarea]:bg-background [&_[role=combobox]]:bg-background">
<CardContent className="p-4 space-y-4">
<div className="relative">
<Label htmlFor="app-name">{t("vmLxc.appEditor.nameLabel")}</Label>
@@ -1422,6 +1498,28 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
)}
</div>
{/* 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 && (
<div className="pt-2 border-t border-border/50">
<label className="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
checked={draft.notifications_enabled !== false}
onChange={(e) => setField({ notifications_enabled: e.target.checked })}
className="mt-0.5 h-4 w-4 rounded border-border accent-blue-500"
/>
<div className="text-sm">
<div className="text-foreground">{t("vmLxc.appEditor.notifyUpstreamLabel")}</div>
<div className="text-xs text-muted-foreground mt-1">{t("vmLxc.appEditor.notifyUpstreamHelp")}</div>
</div>
</label>
</div>
)}
{error && (
<div className="text-xs text-red-400 flex items-start gap-1.5">
<AlertTriangle className="h-3.5 w-3.5 flex-shrink-0 mt-0.5" />
@@ -1729,7 +1827,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
{tracking && st?.error && (
<div className="mb-3 p-2 rounded-md bg-amber-500/10 border border-amber-500/30 flex items-start gap-2">
<Info className="h-4 w-4 text-amber-400 flex-shrink-0 mt-0.5" />
<div className="text-xs text-amber-300">{st.error}</div>
<div className="text-xs text-amber-300">{localizeUpstreamError(st.error)}</div>
</div>
)}
@@ -1794,6 +1892,27 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed }: Props) {
{t("vmLxc.appEditor.removeButton")}
</button>
<div className="ml-auto flex flex-wrap gap-2">
{tracking && (
<button
type="button"
onClick={() => toggleAppNotifications(app)}
disabled={busyAppId === app.id}
title={
app.notifications_enabled === false
? t("vmLxc.appEditor.notificationsMuted")
: t("vmLxc.appEditor.notificationsEnabled")
}
className={
app.notifications_enabled === false
? "h-8 px-3 text-xs rounded-md border border-border bg-background hover:bg-muted transition-colors inline-flex items-center gap-1.5 disabled:opacity-60 text-muted-foreground"
: "h-8 px-3 text-xs rounded-md border border-blue-500/30 bg-blue-500/10 hover:bg-blue-500/20 text-blue-400 transition-colors inline-flex items-center gap-1.5 disabled:opacity-60"
}
>
{app.notifications_enabled === false
? <BellOff className="h-3.5 w-3.5" />
: <Bell className="h-3.5 w-3.5" />}
</button>
)}
{tracking && (
<button
type="button"
+16 -2
View File
@@ -784,7 +784,15 @@ export function ProxmoxDashboard() {
<div className="container mx-auto px-4 md:px-6 py-4 md:py-6">
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4 md:space-y-6">
<TabsContent value="overview" className="space-y-4 md:space-y-6 mt-0">
{/* forceMount so SystemOverview mounts at dashboard load and
never gets torn down when the user visits another tab.
Without this, every return to Overview re-fires ~7 fetches
(system, vms, storage, proxmox-storage, network, node
metrics, network chart) and the user waited for the
cascade to complete each time. With forceMount, the
5 s / 59 s refresh intervals keep the data fresh in the
background — reopening the tab is instant. */}
<TabsContent value="overview" forceMount className="space-y-4 md:space-y-6 mt-0 data-[state=inactive]:hidden">
<SystemOverview key={`overview-${componentKey}`} />
</TabsContent>
@@ -796,7 +804,13 @@ export function ProxmoxDashboard() {
<NetworkMetrics key={`network-${componentKey}`} />
</TabsContent>
<TabsContent value="vms" className="space-y-4 md:space-y-6 mt-0">
{/* forceMount so the modal-data prefetcher (inside VirtualMachines)
starts warming caches from the moment the dashboard loads,
not the first time the user clicks the VMs tab. Kept
visually hidden with data-attribute selector when the tab
is inactive — mount cost is ~zero (no polling loop that
other components run). */}
<TabsContent value="vms" forceMount className="space-y-4 md:space-y-6 mt-0 data-[state=inactive]:hidden">
<VirtualMachines key={`vms-${componentKey}`} />
</TabsContent>
+20 -3
View File
@@ -130,13 +130,30 @@ export function PwaInstallPrompt() {
>
<div className="relative px-5 pt-5">
<div className="mx-auto mb-3 h-1 w-10 rounded-full bg-border" aria-hidden="true" />
{/* Hardened close button — the X wasn't reliably closing
the sheet on iOS/Android. Fixes:
* `stopPropagation` so the click never bubbles up to
the backdrop handler (which was seeing the target
and might have been running its own logic against
the same tap on some mobile browsers).
* `z-10` puts it above any sibling absolute layers
(drag handle, headings) in case one silently ate
the tap.
* 40 × 40 hit area — comfortable Apple/Google minimum
for a touch target; the 32 × 32 we had was fine on
desktop but easy to miss with a thumb.
* `onPointerDown` as a secondary handler covers the
iOS Safari case where a fast tap on a nested
`<button>` inside a `role="dialog"` can lose the
click event to the parent overlay. */}
<button
type="button"
onClick={handleClose}
onClick={(e) => { e.stopPropagation(); handleClose() }}
onPointerDown={(e) => { e.stopPropagation() }}
aria-label={t("actions.close")}
className="absolute right-3 top-3 flex h-8 w-8 items-center justify-center rounded-full text-muted-foreground hover:bg-muted transition-colors"
className="absolute right-2 top-2 z-10 flex h-10 w-10 items-center justify-center rounded-full text-muted-foreground hover:bg-muted active:bg-muted transition-colors touch-manipulation"
>
<X className="h-4 w-4" />
<X className="h-5 w-5" />
</button>
<div className="mb-4 flex items-start gap-3.5">
File diff suppressed because it is too large Load Diff
+58
View File
@@ -0,0 +1,58 @@
// Shared cross-component cache for the LXC App-tab payload
// (registered sidecar + auto-detected suggestions). Used by both
// virtual-machines.tsx (which prefetches on modal open and on hover)
// and lxc-app-panel.tsx (which reads the cache first and only fetches
// if empty). The in-flight promise map dedups concurrent requests: if
// the parent already fired a prefetch, the panel awaits the SAME
// promise instead of duplicating the request against the backend —
// no more racing fetches on tab switch during a slow first visit.
import { fetchApi } from "./api-config"
export type LxcAppsBundle = {
sidecar: any
suggestions: any | null
}
const dataCache = new Map<number, LxcAppsBundle>()
const inFlight = new Map<number, Promise<LxcAppsBundle | null>>()
export function getLxcAppsCached(vmid: number): LxcAppsBundle | undefined {
return dataCache.get(vmid)
}
export function fetchLxcApps(vmid: number): Promise<LxcAppsBundle | null> {
const existing = inFlight.get(vmid)
if (existing) return existing
const p = Promise.all([
fetchApi(`/api/vms/${vmid}/apps`).catch(() => null) as Promise<any>,
fetchApi(`/api/vms/${vmid}/apps/suggestions`).catch(() => null) as Promise<any>,
])
.then(([sc, sug]) => {
if (!sc) return null
const bundle: LxcAppsBundle = { sidecar: sc, suggestions: sug }
dataCache.set(vmid, bundle)
return bundle
})
.finally(() => {
inFlight.delete(vmid)
})
inFlight.set(vmid, p)
return p
}
export function invalidateLxcApps(vmid: number): void {
dataCache.delete(vmid)
}
// Seed the cache with a sidecar payload from the bulk modal-cache
// endpoint. Only the sidecar side is populated — suggestions still
// resolve lazily when the App panel actually mounts (the bulk
// endpoint intentionally excludes them since most guests don't
// need the auto-detected chips and the payload would balloon).
export function seedLxcAppsCache(vmid: number, sidecar: any): void {
if (!sidecar) return
const existing = dataCache.get(vmid)
if (existing) return // per-panel fetch already ran, don't overwrite
dataCache.set(vmid, { sidecar, suggestions: null })
}
+11 -3
View File
@@ -1338,6 +1338,9 @@
"latestFromLabel": "Latest from",
"upToDateBadge": "Up to date",
"updateAvailableBadge": "Update available",
"upstreamErrorTimeout": "Network timeout while contacting upstream",
"upstreamErrorNetwork": "Network error: {detail}",
"upstreamErrorGeneric": "Upstream check failed: {detail}",
"portDescriptionPlaceholder": "Description (e.g. Web UI, go2rtc, admin)",
"portPortPlaceholder": "port",
"portHttp": "http",
@@ -1408,7 +1411,11 @@
"alsoDetectedContainer": "Also detected on this container",
"addAnotherApplication": "Add another application",
"doneButton": "Done",
"editButton": "Edit"
"editButton": "Edit",
"notificationsEnabled": "Upstream update notifications ON — click to mute",
"notificationsMuted": "Upstream update notifications MUTED — click to enable",
"notifyUpstreamLabel": "Notify me when a new upstream version is available",
"notifyUpstreamHelp": "Sends `app_update_available` to the channels enabled in Settings → Notifications. Turn off if this app can't be updated on your box."
}
},
"settings": {
@@ -1663,9 +1670,10 @@
"health_persistent": "Active health issues (daily)",
"health_issue_new": "New health issue",
"health_issue_resolved": "Health issue resolved",
"update_summary": "Updates available",
"update_summary": "Host package updates",
"pve_update": "Proxmox VE update available",
"update_complete": "Update completed",
"update_complete": "Host update completed",
"app_update_available": "App update available",
"ai_model_migrated": "AI model updated automatically",
"proxmenux_update": "ProxMenux update available",
"mount_stale": "Remote mount is stale",
+18 -11
View File
@@ -1198,9 +1198,9 @@
"helperUpdatesRun": "— las actualizaciones ejecutan el asistente de scripts comunitarios.",
"installedPrefix": "instalado",
"upstreamAvailable": "versión {version} disponible",
"upToDateAt": "Al día en",
"upToDateAt": "Actualizado en",
"applyUpdate": "Aplicar actualización",
"upToDate": "A hoy",
"upToDate": "Actualizado",
"runUpdater": "Ejecutar actualizador",
"terminalTitle": "Aplicar actualizaciones: CT {vmid}",
"terminalDescriptionOs": "Aplicando actualizaciones de paquetes del sistema operativo dentro del contenedor...",
@@ -1335,9 +1335,12 @@
"tagRegexPlaceholder": "por ejemplo, v?(\\d+\\.\\d+\\.\\d+)",
"tagRegexBare": "v?(\\d+\\.\\d+\\.\\d+)",
"installedStatus": "Instalado",
"checkingStatus": "de cheques…",
"latestFromLabel": "Lo último de",
"upToDateBadge": "A hoy",
"checkingStatus": "Comprobando…",
"latestFromLabel": "Última versión de",
"upToDateBadge": "Actualizado",
"upstreamErrorTimeout": "Tiempo de espera agotado al contactar con el origen",
"upstreamErrorNetwork": "Error de red: {detail}",
"upstreamErrorGeneric": "Fallo al comprobar el origen: {detail}",
"updateAvailableBadge": "Actualización disponible",
"portDescriptionPlaceholder": "Descripción (por ejemplo, interfaz de usuario web, go2rtc, administrador)",
"portPortPlaceholder": "puerto",
@@ -1409,7 +1412,11 @@
"alsoDetectedContainer": "También detectado en este contenedor.",
"addAnotherApplication": "Agregar otra aplicación",
"doneButton": "Hecho",
"editButton": "Editar"
"editButton": "Editar",
"notificationsEnabled": "Notificaciones de actualización activas — clic para silenciar",
"notificationsMuted": "Notificaciones de actualización silenciadas — clic para activar",
"notifyUpstreamLabel": "Notificarme cuando haya una nueva versión disponible",
"notifyUpstreamHelp": "Envía `app_update_available` a los canales activos en Ajustes → Notificaciones. Desactívalo si esta app no se puede actualizar en tu instalación."
}
},
"settings": {
@@ -1664,9 +1671,10 @@
"health_persistent": "Problemas de salud activos (diarios)",
"health_issue_new": "Nuevo problema de salud",
"health_issue_resolved": "Problema de salud resuelto",
"update_summary": "Actualizaciones disponibles",
"update_summary": "Actualizaciones de paquetes del host",
"pve_update": "Actualización de Proxmox VE disponible",
"update_complete": "Actualización completada",
"update_complete": "Actualización del host completada",
"app_update_available": "Actualización de app disponible",
"ai_model_migrated": "Modelo de IA actualizado automáticamente",
"proxmenux_update": "Actualización de ProxMenux disponible",
"mount_stale": "El montaje remoto está obsoleto",
@@ -2433,7 +2441,7 @@
"actionableCount": "{count} procesable",
"hardeningScorePve": "Puntuación de endurecimiento (Proxmox ajustada)",
"noCheckDetails": "No hay resultados de verificación detallados disponibles.",
"checksCount": "{count} cheques",
"checksCount": "{count} comprobaciones",
"noWarnings": "Sin advertencias.",
"pveExpected": "Esperado para Proxmox",
"lowRisk": "Bajo riesgo",
@@ -3522,7 +3530,6 @@
"deletePbsDescription": "Esto elimina la instantánea de PBS del almacén de datos.",
"deletePbsTitle": "Eliminar instantánea de PBS",
"companionFile": "archivo complementario",
"descriptionAfter": "",
"descriptionBefore": "Explorar y restaurar copias de seguridad encontradas en",
"downloadTitle": "Descarga esta copia de seguridad",
"emptyAfter": "copias de seguridad todavía.",
@@ -3556,7 +3563,7 @@
"pbsShortDescription": "Proxmox Backup Server"
},
"common": {
"checking": "De cheques...",
"checking": "Comprobando...",
"loading": "Cargando...",
"no": "No",
"or": "o",
+39 -12
View File
@@ -1212,9 +1212,13 @@ def setup_pve_webhook_core() -> dict:
# `could not decode UTF8 string from base64, key 'X-Webhook-Secret' (500)`
# whenever `token_urlsafe` produced `-` or `_` chars (GH #198).
secret_b64 = base64.b64encode(secret.encode()).decode()
# PVE parses /etc/pve/*.cfg TAB-strict. The endpoint_block above
# indents with `\t`; the priv_block MUST too, or PVE silently
# ignores the `secret` line and never sends `X-Webhook-Secret`,
# so every remote delivery lands as 401 invalid_secret. GH #294.
priv_block = (
f"webhook: {_PVE_ENDPOINT_ID}\n"
f" secret name=X-Webhook-Secret,value={secret_b64}\n"
f"\tsecret name=X-Webhook-Secret,value={secret_b64}\n"
)
if priv_text is not None:
@@ -1234,9 +1238,14 @@ def setup_pve_webhook_core() -> dict:
result['error'] = f'Permission denied writing {_PVE_PRIV_CFG}'
result['fallback_commands'] = _build_webhook_fallback()
return result
except Exception:
pass
except Exception as e:
# Silently swallowing this here would report configured:True
# while PVE has no valid secret — exactly the failure mode of
# GH #294. Surface it so the caller can flag the setup.
result['error'] = f'Failed writing {_PVE_PRIV_CFG}: {e}'
result['fallback_commands'] = _build_webhook_fallback()
return result
result['configured'] = True
result['secret'] = secret
return result
@@ -1489,19 +1498,37 @@ def proxmox_webhook():
if not hmac.compare_digest(configured_secret, request_secret):
return _reject(401, 'invalid_secret', 401)
# Layer 3: Anti-replay timestamp
# Layer 3: Anti-replay timestamp.
# PVE's webhook notification target can only send a static secret
# header + a Handlebars-templated body; it cannot inject a custom
# dynamic header, so `X-ProxMenux-Timestamp` never arrives from a
# PVE-origin delivery (GH #294). Our own PVE endpoint template
# already embeds `"timestamp":"{{ timestamp }}"` (Unix epoch), so
# accept the body value as a fallback when the header is absent.
# The replay cache in Layer 4 still binds every accepted request
# to (timestamp, raw_body), so this widens the source of the
# timestamp without weakening the anti-replay guarantee.
raw_body = request.get_data(as_text=True) or ''
ts_header = request.headers.get('X-ProxMenux-Timestamp', '')
if not ts_header:
ts_value = None
if ts_header:
try:
ts_value = int(ts_header)
except (ValueError, TypeError):
return _reject(401, 'invalid_timestamp', 401)
elif raw_body:
try:
body_ts = json.loads(raw_body).get('timestamp')
if body_ts is not None:
ts_value = int(str(body_ts).strip())
except (ValueError, TypeError, json.JSONDecodeError, AttributeError):
ts_value = None
if ts_value is None:
return _reject(401, 'missing_timestamp', 401)
try:
ts_value = int(ts_header)
except (ValueError, TypeError):
return _reject(401, 'invalid_timestamp', 401)
if abs(time.time() - ts_value) > _TIMESTAMP_MAX_DRIFT:
return _reject(401, 'timestamp_expired', 401)
# Layer 4: Replay cache
raw_body = request.get_data(as_text=True) or ''
signature = hashlib.sha256(f"{ts_value}:{raw_body}".encode(errors='replace')).hexdigest()
if _replay_cache.check_and_record(signature):
return _reject(409, 'replay_detected', 409)
File diff suppressed because it is too large Load Diff
+12 -2
View File
@@ -2591,7 +2591,13 @@ class HealthMonitor:
# the `removable` flag, since USB-NVMe and USB-HDD both report
# `removable=0` even though they ARE USB.
attempts = []
if _is_disk_usb(disk_name) or _is_disk_removable(disk_name):
# SNT drivers are NVMe-Storage-Namespace-Transport — only
# meaningful when the underlying device is NVMe. Restricting
# to `nvme*` kernel nodes stops `sd*` USB-SATA (TerraMaster
# DAS, USB HDD/SSD enclosures) from paying 3×5 s of dead
# smartctl timeouts before the plain probe runs (GH #293).
is_nvme_class = disk_name.startswith('nvme')
if is_nvme_class and (_is_disk_usb(disk_name) or _is_disk_removable(disk_name)):
for drv in _USB_NVME_DRIVERS:
attempts.append(['smartctl', '-i', '-j', '-d', drv, dev_path])
attempts.append(['smartctl', '-i', '-j', dev_path])
@@ -2656,7 +2662,11 @@ class HealthMonitor:
# USB detection uses the sysfs path so USB-NVMe bridges (which
# report removable=0) are caught too.
attempts = []
if _is_disk_usb(disk_name) or _is_disk_removable(disk_name):
# Same NVMe-class guard as `_get_disk_identity` — see the
# comment there. Prevents USB-SATA drives from wasting
# timeouts on drivers that will never respond (GH #293).
is_nvme_class = disk_name.startswith('nvme')
if is_nvme_class and (_is_disk_usb(disk_name) or _is_disk_removable(disk_name)):
for drv in _USB_NVME_DRIVERS:
attempts.append(['smartctl', '-n', 'standby', '--health', '-j', '-d', drv, dev_path])
attempts.append(['smartctl', '-n', 'standby', '--health', '-j', dev_path])
+13 -2
View File
@@ -4,7 +4,7 @@ Provides decorator to protect Flask routes with JWT authentication
Automatically checks auth status and validates tokens
"""
from flask import request, jsonify
from flask import request, jsonify, g
from functools import wraps
from auth_manager import load_auth_config, verify_token, verify_token_full
@@ -26,9 +26,20 @@ def require_auth(f):
"""
@wraps(f)
def decorated_function(*args, **kwargs):
# Internal calls (background prewarmers, in-process cache
# refresh) bypass auth. Set `g._internal_call = True` inside
# an `app.test_request_context()` block before invoking a
# decorated handler — the flag lives only for that context so
# a real HTTP request can never accidentally inherit it.
try:
if getattr(g, '_internal_call', False):
return f(*args, **kwargs)
except RuntimeError:
pass # No request context yet — treat as normal auth flow.
# Check if authentication is enabled
config = load_auth_config()
# If auth is disabled or declined, allow access
if not config.get("enabled", False) or config.get("declined", False):
return f(*args, **kwargs)
+80 -2
View File
@@ -796,6 +796,16 @@ def validate_config(payload: dict) -> tuple[bool, Any]:
if hn is not None:
conf["hide_no_updater_notice"] = bool(hn)
# Optional per-app switch for `app_update_available` notifications.
# Default is True (opt-out). Set to False from the App tab when the
# user knows an app can't be updated on their box (compat, forked
# setup, etc.) and wants to keep the "you have updates" badge but
# silence the outbound notification for THIS app only, without
# touching the global toggle.
ne = payload.get("notifications_enabled")
if ne is not None:
conf["notifications_enabled"] = bool(ne)
return True, conf
@@ -1575,6 +1585,12 @@ def record_schedule_run(vmid, status: str, target: str) -> bool:
def _fire_update_notification(vmid, app: dict) -> None:
# Per-app opt-out: user flipped the bell icon off for this specific
# app (because they know it can't be updated on their box or they
# just don't care). Field defaults to True — an app registered
# before this feature landed keeps receiving notifications.
if app.get("notifications_enabled", True) is False:
return
try:
from notification_manager import notification_manager
import socket
@@ -1708,7 +1724,6 @@ def check_app(vmid, app_id: str, force: bool = False) -> Optional[dict]:
err = inst_err or up_err
update_available = compare(installed, latest) if (installed and latest) else None
prev_latest = state.get("latest_version")
app["state"] = {
"installed_version": installed,
@@ -1720,12 +1735,75 @@ def check_app(vmid, app_id: str, force: bool = False) -> Optional[dict]:
sidecar["updated_at"] = _now_iso()
_write_sidecar(vmid, sidecar)
if update_available and latest and latest != prev_latest:
# Emit every time an update is pending. The old `latest !=
# prev_latest` guard tried to prevent spam by only firing on
# the first observation of each new upstream version, but it
# also swallowed the emit whenever the notification setting
# was toggled off → on after the first observation (the
# sidecar already had `latest_version` recorded, so subsequent
# checks looked like "same latest, nothing to do"). Anti-spam
# is the notification manager's job: it dedups by `entity_id`
# (vmid + app_id + latest_version) with its cooldown, and only
# a genuinely new upstream release changes the entity_id and
# triggers a fresh delivery.
if update_available and latest:
_fire_update_notification(vmid, app)
return sidecar
def emit_all_pending_updates() -> int:
"""Walk every sidecar and emit `app_update_available` for each
app currently marked with a pending upstream release. Safe to
call repeatedly — `notification_manager` dedups by entity_id
(vmid + app_id + latest_version), so a given release only sends
once until a newer version appears.
Needed because `check_app(force=False)` short-circuits on a fresh
`checked_at` and never reaches the emit path. The 24 h
PollingCollector runs `refresh_all_apps(force=False)`, so without
this helper the notification only ever fired on the exact tick
where a new upstream version was FIRST observed — and even that
was silenced when the user's setting was OFF at the time.
Returns the number of emits attempted (delivery still depends on
channel enablement + cooldown + rate limit)."""
try:
entries = sorted(os.listdir(_APPS_DIR))
except (FileNotFoundError, OSError):
print("[ProxMenux] emit_all_pending_updates: _APPS_DIR missing", flush=True)
return 0
n = 0
print(f"[ProxMenux] emit_all_pending_updates: scanning {len(entries)} sidecar file(s)", flush=True)
for name in entries:
if not name.endswith(".json"):
continue
try:
vmid = int(name[:-5])
except ValueError:
continue
try:
sidecar = _read_sidecar(vmid)
if not sidecar:
print(f"[ProxMenux] emit_all_pending_updates: CT {vmid} sidecar empty", flush=True)
continue
apps = sidecar.get("apps") or []
pending = [a for a in apps
if (a.get("state") or {}).get("update_available")
and (a.get("state") or {}).get("latest_version")]
print(f"[ProxMenux] emit_all_pending_updates: CT {vmid} apps={len(apps)} pending={len(pending)}", flush=True)
for app in pending:
try:
_fire_update_notification(vmid, app)
n += 1
print(f"[ProxMenux] emit_all_pending_updates: CT {vmid} emit '{app.get('name')}'", flush=True)
except Exception as inner:
print(f"[ProxMenux] emit_all_pending_updates: CT {vmid} emit '{app.get('name')}' FAILED: {inner}", flush=True)
except Exception as e:
print(f"[ProxMenux] emit_all_pending_updates: CT {vmid} outer failure: {e}", flush=True)
print(f"[ProxMenux] emit_all_pending_updates: {n} emit(s) attempted total", flush=True)
return n
def check_all(vmid, force: bool = False) -> Optional[dict]:
sidecar = _read_sidecar(vmid)
if not sidecar:
+11
View File
@@ -3516,6 +3516,17 @@ class PollingCollector:
try:
import lxc_apps
lxc_apps.refresh_all_apps(force=False)
# After the refresh, emit `app_update_available` for every
# sidecar entry currently flagged with a pending upstream
# release. `check_app(force=False)` short-circuits on a
# fresh `checked_at` and never reaches the emit path, so
# without this call the notification only ever fired on
# the exact tick where a new version was FIRST observed —
# missed forever if the user had the toggle off at that
# moment. `notification_manager` dedups by entity_id
# (vmid + app_id + latest_version) so repeated calls only
# deliver one notification per release.
lxc_apps.emit_all_pending_updates()
except Exception as e:
print(f"[PollingCollector] lxc_apps refresh failed: {e}")
+20 -2
View File
@@ -398,7 +398,13 @@ GROUP_RATE_LIMITS = {
'backup': {'max_per_minute': 5, 'max_per_hour': 30},
'services': {'max_per_minute': 5, 'max_per_hour': 30},
'health': {'max_per_minute': 3, 'max_per_hour': 20},
'updates': {'max_per_minute': 3, 'max_per_hour': 15},
# Bumped from 3/min-15/hour: startup reset re-fires every update
# event (app_update x N + nvidia + secure_gateway + post_install
# + summary…) in a single burst; a 3/min ceiling silently dropped
# everything past the third. Steady-state update noise is very
# low (one event per upstream release), so a wider window costs
# nothing.
'updates': {'max_per_minute': 15, 'max_per_hour': 60},
'other': {'max_per_minute': 5, 'max_per_hour': 30},
}
@@ -507,6 +513,13 @@ _DEFAULT_AGGREGATION = {'window': 60, 'min_count': 2, 'burst_type': 'burst_gener
# recovery is per-event; collapsing them adds zero information.
_AGGREGATION_EXEMPT_EVENTS = frozenset({
'error_resolved',
# Per-app upstream update. Each event carries a distinct app name,
# version and CT id — collapsing "5 app updates burst" into a
# summary hides exactly the information the user wants (which
# apps, which versions). Startup emit fires all pending updates
# at once, so without this exemption only the first 1-2 land and
# the rest get buffered into a useless summary.
'app_update_available',
})
@@ -1940,14 +1953,19 @@ class NotificationManager:
# (log_critical_*, disk errors, smart_*, …) — preserves the
# anti-flood guarantee for sources that can burst.
_EVENT_TYPES_RESET_ON_START = (
# Update-status reports
# Update-status reports — re-fire on Monitor restart so the
# user gets a fresh "here's what's pending" as a health check
# that the notification pipeline is alive. Steady-state 24 h
# cooldown resumes after that first post-restart send.
'update_summary',
'proxmenux_update',
'post_install_update',
'pve_update',
'update_available',
'nvidia_driver_update_available',
'coral_driver_update_available',
'secure_gateway_update_available',
'app_update_available',
# Security events that must not be silenced by stale cooldowns
# following a Monitor reinstall (Pedro Rico, 19/05).
'auth_fail',
+16 -7
View File
@@ -524,12 +524,21 @@ TEMPLATES = {
'title': '{hostname}: {app_name} update available on CT {vmid}',
'body': (
'{app_name} on CT {vmid} ({ct_name}) has a new version:\n'
' {installed} → {latest}\n'
'Registered via ProxMenux App Watch.'
' {installed} → {latest}'
),
'label': 'App update available (App Watch)',
'group': 'vm_ct',
'default_enabled': False,
'label': 'App update available',
# Grouped under `updates` (not `vm_ct`) so the user can toggle
# per-app upstream notifications independently from VM/CT
# lifecycle events (start/stop/reboot). Sitting alongside the
# other update templates keeps the Settings UI consistent and
# leaves the group ready for future OCI-image notifications
# that share the same "an upstream release is available"
# semantics.
'group': 'updates',
# Every other update template ships enabled by default; leaving
# this one off meant users who registered apps in the App tab
# never received the notification they explicitly asked for.
'default_enabled': True,
},
'vm_start': {
'title': '{hostname}: VM {vmname} ({vmid}) started',
@@ -1076,7 +1085,7 @@ TEMPLATES = {
'Kernel updates: {kernel_count}\n'
'Important packages:\n{important_list}'
),
'label': 'Updates available',
'label': 'Host package updates',
'group': 'updates',
'default_enabled': True,
},
@@ -1090,7 +1099,7 @@ TEMPLATES = {
'update_complete': {
'title': '{hostname}: System update completed',
'body': 'System packages have been successfully updated.\n{details}',
'label': 'Update completed',
'label': 'Host update completed',
'group': 'updates',
'default_enabled': False,
},