mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-14 18:56:52 +00:00
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:
Binary file not shown.
@@ -1 +1 @@
|
||||
774c362ab738cb27ca4ae8e0025caea46bdbe01c8cebbb06ea82776072cb94a0 ProxMenux-1.2.4.1-beta.AppImage
|
||||
cf60e05bc3c78cfb9dc2e269c564b6fee78238574143750201399acce308dd72 ProxMenux-1.2.4.1-beta.AppImage
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
@@ -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 })
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
+795
-169
File diff suppressed because it is too large
Load Diff
@@ -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])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user