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,
},