From 98ae1f36260c1a541d58656524588f921efeecc8 Mon Sep 17 00:00:00 2001 From: MacRimi Date: Sat, 17 Oct 2026 18:13:44 +0200 Subject: [PATCH] feat(pbs): post-install menu for Proxmox Backup Server - Open the post-install menu from the PBS menu - List only the customizable options that apply to PBS - Offer the PBS no-subscription repository through the native repository API - Upgrade PBS with the package list shown before applying it - Remove and restore the subscription notice on the PBS web interface - Install Figurine on PBS without the Proxmox VE aliases - Offer the Helper-Scripts PBS post-install and microcode scripts - Tune firewall bridges after the systemd udev rules in the automated post-install - Add PBS repository policy tests and syntax checks - Document the PBS differences on the post-install page - Use backups in the Spanish PBS update message --- .github/workflows/test-pbs.yml | 5 +- AppImage/components/health-status-modal.tsx | 19 +- AppImage/components/monitor-switcher.tsx | 283 +++++++++++++++ AppImage/components/notification-settings.tsx | 14 +- AppImage/components/pbs-dashboard.tsx | 44 --- AppImage/components/pbs-datastore-usage.tsx | 112 ++++++ AppImage/components/pbs-panel.tsx | 202 ++++++++--- AppImage/components/proxmox-dashboard.tsx | 79 +++-- AppImage/components/settings.tsx | 19 +- AppImage/components/system-overview.tsx | 80 ++++- AppImage/lib/use-platform.ts | 8 + AppImage/messages/en/common.json | 49 ++- AppImage/messages/es/common.json | 49 ++- AppImage/scripts/build_appimage.sh | 2 + .../scripts/flask_monitor_links_routes.py | 58 ++++ AppImage/scripts/flask_notification_routes.py | 15 + AppImage/scripts/flask_server.py | 38 ++- AppImage/scripts/monitor_links.py | 321 ++++++++++++++++++ .../tests/test_fwbr_udev_rule_order.py | 12 +- AppImage/scripts/tests/test_monitor_links.py | 150 ++++++++ lang/es.json | 19 +- scripts/global/remove-banner-pve-v3.sh | 13 +- scripts/global/repository-functions.sh | 45 +++ scripts/global/repository_policy.py | 83 +++-- scripts/global/update-pbs.sh | 188 ++++++++++ scripts/menus/menu_post_install.sh | 27 +- scripts/menus/pbs_menu.sh | 28 +- scripts/post_install/auto_post_install.sh | 46 ++- .../post_install/customizable_post_install.sh | 50 ++- scripts/post_install/uninstall-tools.sh | 10 +- tests/test_repository_policy_pbs.py | 103 ++++++ web/app/[locale]/docs/post-install/page.tsx | 10 + web/messages/en/docs/post-install/index.json | 11 + web/messages/es/docs/post-install/index.json | 11 + 34 files changed, 1976 insertions(+), 227 deletions(-) create mode 100644 AppImage/components/monitor-switcher.tsx delete mode 100644 AppImage/components/pbs-dashboard.tsx create mode 100644 AppImage/components/pbs-datastore-usage.tsx create mode 100644 AppImage/lib/use-platform.ts create mode 100644 AppImage/scripts/flask_monitor_links_routes.py create mode 100644 AppImage/scripts/monitor_links.py create mode 100644 AppImage/scripts/tests/test_monitor_links.py create mode 100644 scripts/global/update-pbs.sh create mode 100644 tests/test_repository_policy_pbs.py diff --git a/.github/workflows/test-pbs.yml b/.github/workflows/test-pbs.yml index 2d60623c..2dbc71b0 100644 --- a/.github/workflows/test-pbs.yml +++ b/.github/workflows/test-pbs.yml @@ -17,6 +17,9 @@ on: - 'scripts/global/repository-functions.sh' - 'scripts/menus/pbs_menu.sh' - 'scripts/utilities/pbs_update.sh' + - 'scripts/global/update-pbs.sh' + - 'scripts/menus/menu_post_install.sh' + - 'scripts/post_install/*.sh' push: branches: [main, develop] paths: @@ -49,7 +52,7 @@ jobs: run: | python -B -m unittest discover -s AppImage/scripts/tests -p test_pbs.py -v python -B -m unittest discover -s AppImage/scripts/tests -p test_auth_manager_ssl_reload.py -v - for script in scripts/global/platform-functions.sh scripts/global/repository-functions.sh scripts/menus/pbs_menu.sh scripts/utilities/pbs_update.sh scripts/menus/main_menu.sh scripts/post_install/auto_post_install.sh scripts/post_install/customizable_post_install.sh; do + for script in scripts/global/platform-functions.sh scripts/global/repository-functions.sh scripts/menus/pbs_menu.sh scripts/utilities/pbs_update.sh scripts/menus/main_menu.sh scripts/post_install/auto_post_install.sh scripts/post_install/customizable_post_install.sh scripts/post_install/uninstall-tools.sh scripts/global/update-pbs.sh scripts/menus/menu_post_install.sh; do bash -n "$script" done - uses: actions/setup-node@v6 diff --git a/AppImage/components/health-status-modal.tsx b/AppImage/components/health-status-modal.tsx index c8be541e..090a51fb 100644 --- a/AppImage/components/health-status-modal.tsx +++ b/AppImage/components/health-status-modal.tsx @@ -42,6 +42,7 @@ import { } from "lucide-react" import { ScriptTerminalModal } from "./script-terminal-modal" import { useT } from "@/lib/i18n/provider" +import { useIsPbs } from "@/lib/use-platform" interface CategoryCheck { status: string @@ -119,6 +120,7 @@ const CATEGORIES = [ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatusModalProps) { const t = useT() + const isPbs = useIsPbs() const [loading, setLoading] = useState(true) const [healthData, setHealthData] = useState(null) const [dismissedItems, setDismissedItems] = useState([]) @@ -460,6 +462,11 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu return originalStatus?.toUpperCase() || "UNKNOWN" } + // Checks the host reports as not applicable (guests on PBS) are not listed or counted. + const visibleCategories = CATEGORIES.filter(({ key }) => + !(healthData?.details?.[key as keyof typeof healthData.details] as { not_applicable?: boolean } | undefined)?.not_applicable) + const categoryLabel = (key: string) => t(`healthStatus.categories.${isPbs && key === "services" ? "pbsServices" : key}`) + const getHealthStats = () => { if (!healthData?.details) return { total: 0, healthy: 0, info: 0, warnings: 0, critical: 0, unknown: 0 } @@ -469,7 +476,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu let critical = 0 let unknown = 0 - CATEGORIES.forEach(({ key }) => { + visibleCategories.forEach(({ key }) => { const categoryData = healthData.details[key as keyof typeof healthData.details] if (categoryData) { const effectiveStatus = getEffectiveStatus(key, categoryData.status) @@ -481,7 +488,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu } }) - return { total: CATEGORIES.length, healthy, info, warnings, critical, unknown } + return { total: visibleCategories.length, healthy, info, warnings, critical, unknown } } const stats = getHealthStats() @@ -759,7 +766,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu {/* Category List */}
- {CATEGORIES.map(({ key, Icon }) => { + {visibleCategories.map(({ key, Icon }) => { const categoryData = healthData.details[key as keyof typeof healthData.details] const originalStatus = categoryData?.status || "UNKNOWN" const status = getEffectiveStatus(key, originalStatus) @@ -784,7 +791,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
-

{t(`healthStatus.categories.${key}`)}

+

{categoryLabel(key)}

{hasChecks && ( ({Object.values(checks).filter(c => c.installed !== false).length}) @@ -871,7 +878,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu {filteredDismissed.map((item) => { const catMeta = CATEGORIES.find(c => c.category === item.category || c.key === item.category) const CatIcon = catMeta?.Icon || BellOff - const catLabel = catMeta ? t(`healthStatus.categories.${catMeta.key}`) : item.category + const catLabel = catMeta ? categoryLabel(catMeta.key) : item.category const isPermanent = item.permanent || item.suppression_remaining_hours === -1 return ( @@ -936,7 +943,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
- {catMeta ? t(`healthStatus.categories.${catMeta.key}`) : cs.label} + {catMeta ? categoryLabel(catMeta.key) : cs.label}
{durationLabel} diff --git a/AppImage/components/monitor-switcher.tsx b/AppImage/components/monitor-switcher.tsx new file mode 100644 index 00000000..0cd2d34b --- /dev/null +++ b/AppImage/components/monitor-switcher.tsx @@ -0,0 +1,283 @@ +"use client" + +import { useEffect, useState } from "react" +import useSWR, { mutate as refreshCache } from "swr" +import { ArrowLeft, ChevronDown, DatabaseBackup, ExternalLink, Plus, Server, Trash2 } from "lucide-react" +import { Button } from "./ui/button" +import { Input } from "./ui/input" +import { Label } from "./ui/label" +import { Switch } from "./ui/switch" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "./ui/dialog" +import { + DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, +} from "./ui/dropdown-menu" +import { fetchApi } from "../lib/api-config" +import { useT } from "../lib/i18n/provider" + +interface MonitorLink { + key: string + name: string + product: "pve" | "pbs" + group: "cluster" | "standalone" | "pbs" + url: string | null + status: "online" | "offline" | "unknown" + native_url?: string | null + manual: boolean + hidden: boolean + custom_url: boolean +} +interface MonitorLinks { self: { name: string; product: string }; links: MonitorLink[] } + +const GROUPS = ["cluster", "standalone", "pbs"] as const +const fetcher = (url: string) => fetchApi(url) +const ORIGIN_KEY = "proxmenux-came-from" + +interface Origin { url: string; name: string; product: "pve" | "pbs" } + +// The Monitor a jump came from, as the browser reached it. Only a bare http(s) +// origin is accepted, and the parameters leave the address at once. +function takeOrigin(): (Origin & { fresh?: boolean }) | null { + if (typeof window === "undefined") return null + const params = new URLSearchParams(window.location.search) + const from = params.get("pmx_from") + if (from !== null) { + const name = params.get("pmx_name") || "" + const product = params.get("pmx_product") === "pbs" ? "pbs" : "pve" + for (const key of ["pmx_from", "pmx_name", "pmx_product"]) params.delete(key) + const query = params.toString() + window.history.replaceState(null, "", window.location.pathname + (query ? `?${query}` : "") + window.location.hash) + try { + const parsed = new URL(from) + if (/^https?:$/.test(parsed.protocol) && parsed.origin === from && parsed.origin !== window.location.origin) { + const origin: Origin = { url: parsed.origin, name: (name || parsed.hostname).slice(0, 64), product } + try { sessionStorage.setItem(ORIGIN_KEY, JSON.stringify(origin)) } catch { /* private mode */ } + return { ...origin, fresh: true } + } + } catch { /* not an address */ } + } + try { + const stored = JSON.parse(sessionStorage.getItem(ORIGIN_KEY) || "null") + return stored && typeof stored.url === "string" && /^https?:\/\//.test(stored.url) ? stored : null + } catch { return null } +} + +// Where a link leads: its Monitor, or the native PBS interface when no Monitor answers there. +function destination(link: MonitorLink): { href: string; native: boolean } | null { + if (link.product === "pbs" && link.status !== "online" && link.native_url) return { href: link.native_url, native: true } + return link.url ? { href: link.url, native: false } : null +} + +function ProductIcon({ product, className }: { product: string; className?: string }) { + return product === "pbs" ? : +} + +export function MonitorSwitcher({ nodeName, compact = false }: { nodeName: string; compact?: boolean }) { + const t = useT() + const [manageOpen, setManageOpen] = useState(false) + // The backend keeps the list ready, so the menu opens already filled. + const { data, error, isLoading } = useSWR("/api/monitor-links", fetcher, { revalidateOnFocus: false, refreshInterval: 300000 }) + + const [origin, setOrigin] = useState<(Origin & { fresh?: boolean }) | null>(null) + const [offerOrigin, setOfferOrigin] = useState(false) + const [adding, setAdding] = useState(false) + const [addError, setAddError] = useState("") + useEffect(() => { setOrigin(takeOrigin()) }, []) + const originKnown = !!origin && !!data?.links.some(link => link.url === origin.url) + useEffect(() => { + // Offered once, right after arriving, when this Monitor has no way back yet. + if (origin?.fresh && data && !originKnown) setOfferOrigin(true) + }, [origin, data, originKnown]) + + // The address carries where the jump comes from, so the other Monitor can offer the way back. + const withOrigin = (href: string) => { + const params = new URLSearchParams({ pmx_from: window.location.origin, pmx_name: nodeName, pmx_product: data?.self.product === "pbs" ? "pbs" : "pve" }) + return `${href}/?${params.toString()}` + } + const open = (href: string, newTab: boolean) => { + if (newTab) window.open(href, "_blank", "noopener,noreferrer") + else window.location.assign(href) + } + const go = (link: MonitorLink, newTab: boolean) => { + const target = destination(link) + if (!target) return + if (target.native) open(target.href, true) + else open(withOrigin(target.href), newTab) + } + const addOrigin = async () => { + if (!origin) return + setAdding(true); setAddError("") + try { + await fetchApi("/api/monitor-links", { method: "POST", body: JSON.stringify({ name: origin.name, url: origin.url, product: origin.product }) }) + await refreshCache("/api/monitor-links") + setOfferOrigin(false) + } catch (err) { + setAddError(err instanceof Error ? err.message : t("monitorLinks.saveFailed")) + } finally { setAdding(false) } + } + + return
event.stopPropagation()}> + + + + + + {t("monitorLinks.thisNode")} + + + + {nodeName} + + {origin && !originKnown && open(withOrigin(origin.url), false)}> + + {t("monitorLinks.backTo", { name: origin.name })} + } + {isLoading &&

{t("monitorLinks.checking")}

} + {error &&

{t("monitorLinks.loadFailed")}

} + {data && data.links.length === 0 &&

{t("monitorLinks.empty")}

} + {data && GROUPS.map(group => { + const rows = data.links.filter(link => link.group === group) + if (rows.length === 0) return null + return
+ + {t(`monitorLinks.groups.${group}`)} + {rows.map(link => { + const target = destination(link) + return go(link, false)}> + + + {link.name} + {target?.native + ? {t("monitorLinks.pbsInterface")} + : link.status !== "online" && {t("monitorLinks.noResponse")}} + {target && } + + })} +
+ })} + + setManageOpen(true)}> + {t("monitorLinks.manage")} + +
+
+ {manageOpen && } + {origin && + event.stopPropagation()}> + + {t("monitorLinks.cameFromTitle")} + {t("monitorLinks.cameFromBody", { name: origin.name })} + +

{origin.url}

+ {addError &&

{addError}

} +
+ + +
+
+
} +
+} + +function ManageLinksDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) { + const t = useT() + const { data, mutate } = useSWR("/api/monitor-links?all=1", fetcher, { revalidateOnFocus: false }) + const [form, setForm] = useState({ name: "", url: "", product: "pve" }) + const [addresses, setAddresses] = useState>({}) + const [busy, setBusy] = useState(false) + const [error, setError] = useState("") + + const run = async (action: () => Promise) => { + setBusy(true); setError("") + try { + await action() + await mutate() + await refreshCache("/api/monitor-links") + } catch (err) { + setError(err instanceof Error ? err.message : t("monitorLinks.saveFailed")) + } finally { setBusy(false) } + } + const send = (path: string, method: string, body?: unknown) => + fetchApi(path, { method, ...(body === undefined ? {} : { body: JSON.stringify(body) }) }) + + return + event.stopPropagation()}> + + {t("monitorLinks.dialogTitle")} + {t("monitorLinks.dialogDescription")} + + +
+

{t("monitorLinks.addTitle")}

+
+
+ + setForm({ ...form, name: event.target.value })} /> +
+
+ + setForm({ ...form, url: event.target.value })} /> +
+
+ + +
+
+ +
+ + {error &&

{error}

} + +
+ {data?.links.length === 0 &&

{t("monitorLinks.empty")}

} + {data?.links.map(link => { + const address = addresses[link.key] ?? (link.manual || link.custom_url ? link.url || "" : "") + return
+
+ + {link.name} + {link.manual ? t("monitorLinks.added") : t("monitorLinks.detected")} + {!link.manual && } + {link.manual && } +
+
+ setAddresses({ ...addresses, [link.key]: event.target.value })} /> + +
+
+ })} +
+
+
+} diff --git a/AppImage/components/notification-settings.tsx b/AppImage/components/notification-settings.tsx index e84590ee..194d5574 100644 --- a/AppImage/components/notification-settings.tsx +++ b/AppImage/components/notification-settings.tsx @@ -13,6 +13,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from ". import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "./ui/dialog" import { fetchApi } from "../lib/api-config" import { useT } from "../lib/i18n/provider" +import { useIsPbs } from "../lib/use-platform" import { Bell, BellOff, Send, CheckCircle2, XCircle, Loader2, AlertTriangle, Info, Settings2, Zap, Eye, EyeOff, @@ -154,6 +155,8 @@ function validateGotifyUrl(url: string): { error?: string; warning?: string } { } const EVENT_CATEGORIES = ["vm_ct", "backup", "resources", "storage", "network", "security", "cluster", "services", "health", "updates", "hardware", "system", "other"].map(key => ({ key })) +// Guests, vzdump backups and the cluster do not exist on Proxmox Backup Server. +const PVE_ONLY_CATEGORIES = new Set(["vm_ct", "backup", "cluster"]) const CHANNEL_TYPES = ["telegram", "gotify", "discord", "email", "pushover", "apprise"] as const @@ -330,6 +333,7 @@ const DEFAULT_CONFIG: NotificationConfig = { export function NotificationSettings() { const t = useT() + const isPbs = useIsPbs() const { resolvedTheme } = useTheme() const [config, setConfig] = useState(DEFAULT_CONFIG) const [status, setStatus] = useState(null) @@ -703,7 +707,7 @@ export function NotificationSettings() {
- {EVENT_CATEGORIES.filter(cat => cat.key !== "other").map(cat => { + {EVENT_CATEGORIES.filter(cat => cat.key !== "other" && !(isPbs && PVE_ONLY_CATEGORIES.has(cat.key))).map(cat => { const isEnabled = overrides.categories[cat.key] ?? true const isExpanded = expandedCategories.has(`${chName}.${cat.key}`) // Hide the LXC update toggle when the user has disabled the @@ -922,7 +926,7 @@ export function NotificationSettings() { const wasEnabled = originalConfig.enabled const isNowDisabled = !nextConfig.enabled - if (wasEnabled && isNowDisabled) { + if (wasEnabled && isNowDisabled && !isPbs) { try { await fetchApi("/api/notifications/proxmox/cleanup-webhook", { method: "POST" }) } catch { @@ -1217,8 +1221,10 @@ export function NotificationSettings() { setConfig(newConfig) setOriginalConfig(newConfig) - // 2) Auto-configure PVE webhook - try { + // 2) Auto-configure PVE webhook; PBS has no /etc/pve notifications + if (isPbs) { + setWebhookSetup({ status: "idle", fallback_commands: [], error: "" }) + } else try { const setup = await fetchApi<{ configured: boolean secret?: string diff --git a/AppImage/components/pbs-dashboard.tsx b/AppImage/components/pbs-dashboard.tsx deleted file mode 100644 index fd90b1b6..00000000 --- a/AppImage/components/pbs-dashboard.tsx +++ /dev/null @@ -1,44 +0,0 @@ -"use client" - -import { useState } from "react" -import useSWR from "swr" -import { PBSPanel } from "./pbs-panel" -import { Security } from "./security" -import { SystemLogs } from "./system-logs" -import { TerminalPanel } from "./terminal-panel" -import { ThemeToggle } from "./theme-toggle" -import { Button } from "./ui/button" -import { Card, CardContent } from "./ui/card" -import { fetchApi } from "../lib/api-config" -import { useT } from "../lib/i18n/provider" - -interface Vitals { hostname: string; cpu_usage: number; memory_usage: number; memory_used: number; memory_total: number; uptime: string } - -export function PBSDashboard() { - const t = useT() - const [tab, setTab] = useState("overview") - const { data } = useSWR("/api/system", url => fetchApi(url), { refreshInterval: 10000 }) - return
-
-

ProxMenux · PBS

{data?.hostname || t("pbs.loading")}

- - -
- - {tab === "overview" && <> -
- {[[t("pbs.cpu"), data ? `${data.cpu_usage.toFixed(1)}%` : "—"], - [t("pbs.memory"), data ? `${data.memory_used.toFixed(1)} / ${data.memory_total.toFixed(1)} GiB (${data.memory_usage.toFixed(1)}%)` : "—"], - [t("pbs.uptime"), data?.uptime || "—"]].map(([label, value]) => -

{label}

{value}

)} -
- - } - {tab === "logs" && } - {tab === "terminal" && } - {tab === "security" && } -
-} diff --git a/AppImage/components/pbs-datastore-usage.tsx b/AppImage/components/pbs-datastore-usage.tsx new file mode 100644 index 00000000..944ba340 --- /dev/null +++ b/AppImage/components/pbs-datastore-usage.tsx @@ -0,0 +1,112 @@ +"use client" + +import { CalendarClock, Database } from "lucide-react" +import { Badge } from "./ui/badge" +import { Progress } from "./ui/progress" +import { formatStorage } from "../lib/utils" +import { useT } from "../lib/i18n/provider" + +export interface PBSDatastore { + name: string + path?: string + total?: number | null + used?: number | null + avail?: number | null + error?: string + "backend-type"?: string + "estimated-full-date"?: number + "gc-status"?: { upid?: string | null; "removed-bytes"?: number; "pending-bytes"?: number } +} + +export function datastorePercent(store: PBSDatastore): number | null { + if (store.total == null || store.used == null || store.total <= 0) return null + return (store.used / store.total) * 100 +} + +// Same thresholds and colours as the PVE storage cards. +export function usageTone(percent: number): { bar: string; text: string; used: string } { + if (percent > 90) return { bar: "[&>div]:bg-red-500", text: "text-red-500", used: "text-red-400" } + if (percent > 75) return { bar: "[&>div]:bg-yellow-500", text: "text-yellow-500", used: "text-yellow-400" } + return { bar: "[&>div]:bg-blue-500", text: "text-muted-foreground", used: "text-blue-400" } +} + +const gib = (bytes: number) => formatStorage(bytes / 1024 ** 3) + +// A datastore without a readable capacity keeps its message: it is never shown as empty. +export function DatastoreUsageRow({ store, compact = false }: { store: PBSDatastore; compact?: boolean }) { + const t = useT() + const percent = datastorePercent(store) + if (percent == null) { + return
+
+ {store.name} + — +
+

{store.error || t("pbs.capacityUnavailable")}

+
+ } + const tone = usageTone(percent) + return
+
+ {store.name} + {percent.toFixed(1)}% +
+ + {!compact &&
+ {gib(store.used!)} / {gib(store.total!)} + {t("pbs.free")}: {gib(store.avail ?? store.total! - store.used!)} +
} +
+} + +// Same layout as the "Proxmox storage" cards of the PVE Monitor. +export function PBSDatastoreCard({ store }: { store: PBSDatastore }) { + const t = useT() + const percent = datastorePercent(store) + const tone = percent == null ? null : usageTone(percent) + const fullDate = store["estimated-full-date"] + const type = store["backend-type"] || "datastore" + return
+
+
+ +

{store.name}

+ {type} +
+
+ + {store.error ? t("storage.status.error") : t("storage.status.active")} + + {percent != null && {percent.toFixed(2)}%} +
+
+ {percent != null && tone ? ( +
+ +
+
+

{t("storage.total")}

+

{gib(store.total!)}

+
+
+

{t("storage.used")}

+

{gib(store.used!)}

+
+
+

{t("storage.available")}

+

{gib(store.avail ?? store.total! - store.used!)}

+
+
+
+ ) : ( +

{store.error || t("storage.capacityNotReported")}

+ )} + {(store.path || fullDate !== undefined) &&
+ {store.path && {store.path}} + {fullDate !== undefined && + {t("pbs.estimatedFull")}: + {fullDate > 0 ? new Date(fullDate * 1000).toLocaleDateString() : t("pbs.estimatedFullUnknown")} + } +
} +
+} diff --git a/AppImage/components/pbs-panel.tsx b/AppImage/components/pbs-panel.tsx index fb1354eb..d71b30a9 100644 --- a/AppImage/components/pbs-panel.tsx +++ b/AppImage/components/pbs-panel.tsx @@ -2,29 +2,74 @@ import { useEffect, useState } from "react" import useSWR from "swr" +import { + Activity, ArchiveRestore, Database, DatabaseBackup, Download, ListChecks, + RefreshCw, Scissors, Server, Settings2, ShieldCheck, Terminal, Trash2, +} from "lucide-react" import { Card, CardContent, CardHeader, CardTitle } from "./ui/card" +import { Badge } from "./ui/badge" import { Button } from "./ui/button" import { Input } from "./ui/input" import { Label } from "./ui/label" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select" +import { PBSDatastoreCard, type PBSDatastore } from "./pbs-datastore-usage" import { fetchApi } from "../lib/api-config" -import { formatBytes } from "../lib/utils" import { useT } from "../lib/i18n/provider" interface Connection { id: string; name: string; url: string; token_id: string; fingerprint: string; has_token: boolean } interface Connections { local_available: boolean; servers: Connection[] } -interface Datastore { name: string; path?: string; total?: number | null; used?: number | null; avail?: number | null; error?: string } interface Task { upid: string; worker_type?: string; worker_id?: string; starttime?: number; endtime?: number; status?: string } interface Job { id: string; store?: string; schedule?: string; "last-run-state"?: string } interface PBSStatus { name: string; version: string | null; fetched_at: number; services: Record; - datastores: Datastore[] | null; tasks: Task[] | null; + datastores: PBSDatastore[] | null; tasks: Task[] | null; sync: Job[] | null; verify: Job[] | null; prune: Job[] | null; errors: Record; } const emptyForm = { id: "", name: "", url: "", token_id: "", fingerprint: "", token_secret: "" } -const selectClass = "h-10 rounded-md border bg-background px-3 text-sm" +const NEW_CONNECTION = "__new__" +const badgeTone = { + ok: "bg-green-500/10 text-green-500 border-green-500/20", + warn: "bg-amber-500/10 text-amber-500 border-amber-500/20", + error: "bg-red-500/10 text-red-500 border-red-500/20", + running: "bg-blue-500/10 text-blue-500 border-blue-500/20", + muted: "bg-muted text-muted-foreground border-border", +} -export function PBSPanel() { +const TASK_ICONS: Record = { + backup: DatabaseBackup, reader: ArchiveRestore, verify: ShieldCheck, verificationjob: ShieldCheck, + verify_group: ShieldCheck, verify_snapshot: ShieldCheck, prune: Scissors, prunejob: Scissors, + garbage_collection: Trash2, sync: RefreshCw, syncjob: RefreshCw, aptupdate: Download, + termproxy: Terminal, dircreate: Database, "create-datastore": Database, +} + +// PBS reports "OK", "WARNINGS: n" or the error text; a task without one is still running. +function taskState(task: Task): keyof typeof badgeTone { + if (!task.status) return task.endtime ? "muted" : "running" + if (task.status === "OK") return "ok" + if (task.status.startsWith("WARNINGS")) return "warn" + return "error" +} + +function jobState(state?: string): keyof typeof badgeTone { + if (!state) return "muted" + if (state === "ok") return "ok" + if (state.toLowerCase().startsWith("warn")) return "warn" + return "error" +} + +function formatDuration(seconds: number) { + if (!Number.isFinite(seconds) || seconds < 0) return "—" + if (seconds < 60) return `${Math.round(seconds)}s` + const minutes = Math.floor(seconds / 60) + if (minutes < 60) return `${minutes}m ${Math.round(seconds % 60)}s` + const hours = Math.floor(minutes / 60) + return hours < 24 ? `${hours}h ${minutes % 60}m` : `${Math.floor(hours / 24)}d ${hours % 24}h` +} + +// `localOnly` is the PBS host's own Monitor: no connection picker, the +// header Refresh already reloads the page data. +export function PBSPanel({ localOnly = false }: { localOnly?: boolean } = {}) { const t = useT() const { data: connections, error: connectionError, mutate: reloadConnections } = useSWR( "/api/pbs/servers", url => fetchApi(url), { revalidateOnFocus: false }) @@ -69,36 +114,43 @@ export function PBSPanel() { } catch (err) { setError(err instanceof Error ? err.message : t("pbs.saveFailed")) } finally { setBusy(false) } } - const capacity = (value?: number | null) => value == null ? "—" : formatBytes(value) + const now = Date.now() / 1000 - return
-
-

{t("pbs.title")}

- - - -
+ return
+ {!localOnly &&
+

{t("pbs.title")}

+ + + +
} {(connectionError || statusError) &&

{String((connectionError || statusError)?.message || t("pbs.loadFailed"))}

} {isLoading &&

{t("pbs.loading")}

} {!selected && !connectionError && connections &&

{t("pbs.noServer")}

} - {manage && + {manage && !localOnly && {t("pbs.manage")}

{t("pbs.permissionsHelp")}

- +
{(["name", "url", "token_id", "token_secret", "fingerprint"] as const).map(field =>
@@ -118,32 +170,78 @@ export function PBSPanel() { } {data && <> -

{data.name} · PBS {data.version || "—"} · {t("pbs.sampled")} {new Date(data.fetched_at * 1000).toLocaleTimeString()}

- {Object.keys(data.services).length > 0 &&
- {Object.entries(data.services).map(([service, state]) => {service}: {state})} -
} +
+ {data.name} · PBS {data.version || "—"} · {t("pbs.sampled")} {new Date(data.fetched_at * 1000).toLocaleTimeString()} + {Object.entries(data.services).map(([service, state]) => + {service}: {state})} +
{Object.entries(data.errors).map(([section, detail]) =>

{t(`pbs.${section}`)}: {detail}

)} - {t("pbs.datastores")} - {data.datastores?.length === 0 &&

{t("pbs.noDatastores")}

} - {data.datastores && data.datastores.length > 0 &&
- - {data.datastores.map(row => )} -
{t("pbs.name")}{t("pbs.total")}{t("pbs.used")}{t("pbs.free")}
{row.name}{row.error &&

{row.error}

}
{capacity(row.total)}{capacity(row.used)}{capacity(row.avail)}
} -
- {t("pbs.tasks")} - {data.tasks?.length === 0 &&

{t("pbs.noTasks")}

} - {data.tasks && data.tasks.length > 0 &&
- - {data.tasks.map(row => )} -
{t("pbs.task")}{t("pbs.started")}{t("pbs.status")}
{row.worker_type} {row.worker_id}{row.starttime ? new Date(row.starttime * 1000).toLocaleString() : "—"}{row.status || (row.endtime ? "—" : t("pbs.running"))}
} -
- {t("pbs.jobs")} -
- - {(["sync", "verify", "prune"] as const).flatMap(kind => (data[kind] || []).map(row => - ))} -
{t("pbs.job")}{t("pbs.datastores")}{t("pbs.schedule")}{t("pbs.status")}
{t(`pbs.${kind}`)} · {row.id}{row.store || "—"}{row.schedule || "—"}{row["last-run-state"] || "—"}
-
+ + + {t("pbs.datastores")} + + {data.datastores?.length === 0 &&

{t("pbs.noDatastores")}

} + {data.datastores && data.datastores.length > 0 &&
+ {data.datastores.map(store => )} +
} +
+
+ + + {t("pbs.tasks")} + + {data.tasks?.length === 0 &&

{t("pbs.noTasks")}

} + {data.tasks && data.tasks.length > 0 &&
+ + + + + + + {data.tasks.map(row => { + const Icon = TASK_ICONS[row.worker_type || ""] || Activity + const state = taskState(row) + const end = row.endtime ?? (state === "running" ? now : undefined) + return + + + + + + })} +
{t("pbs.task")}{t("pbs.started")}{t("pbs.duration")}{t("pbs.status")}
+ + {row.worker_type} + {row.worker_id && {row.worker_id}} + {row.starttime ? new Date(row.starttime * 1000).toLocaleString() : "—"}{row.starttime && end ? formatDuration(end - row.starttime) : "—"} + {state === "running" ? t("pbs.running") : row.status || "—"} +
} +
+
+ + + {t("pbs.jobs")} + + {(() => { + const jobs = (["sync", "verify", "prune"] as const).flatMap(kind => (data[kind] || []).map(row => ({ kind, row }))) + if (jobs.length === 0) return

{t("pbs.noJobs")}

+ return
+ + + + + + + {jobs.map(({ kind, row }) => + + + + + )} +
{t("pbs.job")}{t("pbs.datastores")}{t("pbs.schedule")}{t("pbs.status")}
{t(`pbs.${kind}`)} · {row.id}{row.store || "—"}{row.schedule || "—"}{row["last-run-state"] || "—"}
+ })()} +
+
}
} diff --git a/AppImage/components/proxmox-dashboard.tsx b/AppImage/components/proxmox-dashboard.tsx index 679c9aaf..4b426082 100644 --- a/AppImage/components/proxmox-dashboard.tsx +++ b/AppImage/components/proxmox-dashboard.tsx @@ -18,7 +18,8 @@ import { Security } from "./security" import { Profile } from "./profile" import { About } from "./about" import { BackupTab } from "./backup-tab" -import { PBSDashboard } from "./pbs-dashboard" +import { PBSPanel } from "./pbs-panel" +import { MonitorSwitcher } from "./monitor-switcher" import { OnboardingCarousel } from "./onboarding-carousel" import { HealthStatusModal } from "./health-status-modal" import { ReleaseNotesModal, useVersionCheck } from "./release-notes-modal" @@ -46,7 +47,9 @@ import { ClipboardCheck, Info, DatabaseBackup, + Database, ChevronDown, + Clock, } from "lucide-react" import Image from "next/image" import { ThemeToggle } from "./theme-toggle" @@ -105,10 +108,27 @@ export function ProxmoxDashboard() { } function PVEDashboard() { + return +} + +function PBSDashboard() { + return +} + +type Product = "pve" | "pbs" +type TopTab = TabId | "datastores" + +// Proxmox Backup Server has no guests, apps or PVE storage: it keeps the +// same header, health and administration with its own top-level sections. +const PBS_TABS: TopTab[] = ["overview", "datastores", "terminal", "admin"] + +function MonitorDashboard({ product }: { product: Product }) { const t = useT() - useSWR("/api/apps/custom-links", _dashboardPrefetchFetcher, { revalidateOnFocus: false }) - useSWR("/api/apps/categories", _dashboardPrefetchFetcher, { revalidateOnFocus: false }) - const { order: tabOrder } = useTabOrder() + const isPbs = product === "pbs" + useSWR(isPbs ? null : "/api/apps/custom-links", _dashboardPrefetchFetcher, { revalidateOnFocus: false }) + useSWR(isPbs ? null : "/api/apps/categories", _dashboardPrefetchFetcher, { revalidateOnFocus: false }) + const { order: userTabOrder } = useTabOrder() + const tabOrder: TopTab[] = isPbs ? PBS_TABS : userTabOrder const [systemStatus, setSystemStatus] = useState({ status: "healthy", uptime: "Loading...", @@ -126,8 +146,8 @@ function PVEDashboard() { // runs post-hydration; the tiny flash is acceptable and matches the // pattern next-themes uses for the same reason. useEffect(() => { - setActiveTab(firstActualTab()) - }, []) + setActiveTab(isPbs ? "overview" : firstActualTab()) + }, [isPbs]) const [infoCount, setInfoCount] = useState(0) const [updateAvailable, setUpdateAvailable] = useState(false) const [showNavigation, setShowNavigation] = useState(true) @@ -400,6 +420,7 @@ function PVEDashboard() { const getActiveTabLabel = () => { switch (activeTab) { case "overview": return t("navigation.overview") + case "datastores": return t("pbs.datastores") case "apps": return t("navigation.apps") case "vms": return t("navigation.virtualMachines") case "storage": return t("navigation.storage") @@ -419,8 +440,9 @@ function PVEDashboard() { return (
- - setShowReleaseNotes(false)} /> + {/* Both describe the PVE Monitor. */} + {!isPbs && } + {!isPbs && setShowReleaseNotes(false)} />} {!isServerConnected && (
@@ -473,22 +495,17 @@ function PVEDashboard() {

{t("app.title")}

-

{t("app.description")}

+

{t(isPbs ? "app.descriptionPbs" : "app.description")}

- - {t("status.node", { node: displayServerName })} + + {t("status.uptime", { uptime: displayUptime })}
{/* Desktop Actions */}
-
- -
-
{t("status.node", { node: displayServerName })}
-
-
+
@@ -589,9 +606,7 @@ function PVEDashboard() { )}
- - {t("status.uptime", { uptime: displayUptime })} - +
@@ -632,7 +647,7 @@ function PVEDashboard() { { value: "audit", label: t("navigation.audit"), Icon: ClipboardCheck, default: false }, { value: "settings", label: t("navigation.settings"), Icon: SettingsIcon, default: false }, { value: "about", label: t("navigation.about"), Icon: Info, default: false }, - ] + ].filter(item => !isPbs || item.value !== "audit") const activeNodeItem = NODE_ITEMS.find(i => i.value === activeTab) const activeAdminItem = ADMIN_ITEMS.find(i => i.value === activeTab) const isNodeActive = activeNodeItem !== undefined @@ -698,9 +713,10 @@ function PVEDashboard() { ) - const renderTop = (id: TabId) => { + const renderTop = (id: TopTab) => { switch (id) { case "overview": return renderDirect("overview", LayoutDashboard, t("navigation.overview")) + case "datastores": return renderDirect("datastores", Database, t("pbs.datastores")) case "apps": return renderDirect("apps", Grid3x3, t("navigation.apps")) case "vms": return renderDirect("vms", Boxes, t("navigation.virtualMachines")) case "backup": return renderDirect("backup", DatabaseBackup, t("navigation.backup")) @@ -710,7 +726,7 @@ function PVEDashboard() { } } return ( - + {tabOrder.map(renderTop)} ) @@ -771,6 +787,7 @@ function PVEDashboard() { {tabOrder.map((id): React.ReactNode => { switch (id) { case "overview": return btn("overview", LayoutDashboard, t("navigation.overview")) + case "datastores": return btn("datastores", Database, t("pbs.datastores")) case "apps": return btn("apps", Grid3x3, t("navigation.apps")) case "vms": return btn("vms", Boxes, t("navigation.virtualMachines")) case "backup": return btn("backup", DatabaseBackup, t("navigation.backup")) @@ -786,7 +803,7 @@ function PVEDashboard() { {btn("logs", ScrollText, t("navigation.systemLogs"))} {btn("security", ShieldCheck, t("navigation.security"))} - {btn("audit", ClipboardCheck, t("navigation.audit"))} + {!isPbs && btn("audit", ClipboardCheck, t("navigation.audit"))} {btn("settings", SettingsIcon, t("navigation.settings"))} {btn("about", Info, t("navigation.about"))} @@ -820,9 +837,16 @@ function PVEDashboard() { 5 s / 59 s refresh intervals keep the data fresh in the background — reopening the tab is instant. */} - + + {isPbs && ( + + + + )} + + {!isPbs && <> @@ -848,11 +872,13 @@ function PVEDashboard() { + } + {!isPbs && <> @@ -860,13 +886,14 @@ function PVEDashboard() { + } - + {/* Profile tab — not surfaced in the top tabs nav. The only diff --git a/AppImage/components/settings.tsx b/AppImage/components/settings.tsx index a49968f6..37e3edc8 100644 --- a/AppImage/components/settings.tsx +++ b/AppImage/components/settings.tsx @@ -16,6 +16,7 @@ import { Input } from "./ui/input" import { getNetworkUnit } from "../lib/format-network" import { fetchApi } from "../lib/api-config" import { SUPPORTED_LANGUAGES, useI18n } from "../lib/i18n/provider" +import { useIsPbs } from "../lib/use-platform" import type { LanguageCode } from "../lib/i18n/languages" import { NavTabOrderCard } from "./nav-tab-order-card" @@ -312,6 +313,7 @@ interface NetworkInterface { export function Settings() { const { language, setLanguage, t } = useI18n() + const isPbs = useIsPbs() const tFallback = (key: string, fallback: string) => { const translated = t(key) return translated === key ? fallback : translated @@ -1124,7 +1126,7 @@ export function Settings() {
{/* Navigation Tab Order — user-orderable top-level tabs */} - + {!isPbs && } {/* Network Units Settings */} @@ -1280,7 +1282,7 @@ export function Settings() { {/* Per-category rows */}
- {suppressionCategories.map((cat) => { + {suppressionCategories.filter(cat => !isPbs || healthCategoryKey(cat) !== "vms").map((cat) => { const IconComp = CATEGORY_ICONS[cat.icon] || HeartPulse const effectiveHours = getEffectiveHours(cat) const isCustomMode = effectiveHours === -2 || (cat.key in customValues) @@ -1288,7 +1290,8 @@ export function Settings() { const isLong = effectiveHours >= 720 && effectiveHours !== -1 && effectiveHours !== -2 const hasChanged = cat.key in pendingChanges && pendingChanges[cat.key] !== cat.hours const selectVal = isCustomMode ? "custom" : getSelectValue(effectiveHours, cat.key) - const catLabel = tFallback(`settings.healthMonitor.categories.${healthCategoryKey(cat)}`, cat.label) + const categoryKey = isPbs && healthCategoryKey(cat) === "services" ? "pbsServices" : healthCategoryKey(cat) + const catLabel = tFallback(`settings.healthMonitor.categories.${categoryKey}`, cat.label) return (
@@ -1496,6 +1499,7 @@ export function Settings() { + {!isPbs && <> {/* Remote Storage Exclusions */} @@ -1678,6 +1682,7 @@ export function Settings() { )} + } {/* Network Interface Exclusions */} @@ -1875,6 +1880,7 @@ export function Settings() { is re-enabled). */} + {!isPbs && <> {/* GitHub API — optional authentication for app upstream checks. */} @@ -1981,15 +1987,16 @@ export function Settings() { )} + } - {/* Notification Settings */} - + {/* Notification Settings (PVE only for now) */} + {!isPbs && } {/* Issue #195: snippets storage selector. Only renders when more than one storage advertises content=snippets — on a typical standalone host with just `local` there's nothing to choose, so showing an empty selector would be noise. */} - {snippetsCandidates.length > 1 && ( + {!isPbs && snippetsCandidates.length > 1 && (
diff --git a/AppImage/components/system-overview.tsx b/AppImage/components/system-overview.tsx index ab443d42..ecbeeef7 100644 --- a/AppImage/components/system-overview.tsx +++ b/AppImage/components/system-overview.tsx @@ -1,10 +1,12 @@ "use client" import { useState, useEffect } from "react" +import useSWR from "swr" import { Card, CardContent, CardHeader, CardTitle } from "./ui/card" import { Progress } from "./ui/progress" import { Badge } from "./ui/badge" -import { Cpu, MemoryStick, Thermometer, Server, Zap, AlertCircle, HardDrive, Network, ChevronRight } from "lucide-react" +import { Cpu, MemoryStick, Thermometer, Server, Zap, AlertCircle, HardDrive, Network, ChevronRight, Database } from "lucide-react" +import { DatastoreUsageRow, type PBSDatastore } from "./pbs-datastore-usage" import { NodeMetricsCharts } from "./node-metrics-charts" import { NetworkTrafficChart } from "./network-traffic-chart" import { TemperatureDetailModal } from "./temperature-detail-modal" @@ -185,9 +187,17 @@ const getUnitsSettings = (): "Bytes" | "Bits" => { return raw && raw.toLowerCase() === "bits" ? "Bits" : "Bytes" } -export function SystemOverview() { +export function SystemOverview({ product = "pve" }: { product?: "pve" | "pbs" } = {}) { const t = useT() const { language } = useI18n() + const isPbs = product === "pbs" + // On Proxmox Backup Server the datastores take the place of guests and PVE storage. + const { data: pbsStatus, error: pbsError } = useSWR<{ datastores: PBSDatastore[] | null }>( + isPbs ? "/api/pbs/status?server=local" : null, + (url: string) => fetchApi<{ datastores: PBSDatastore[] | null }>(url), + { refreshInterval: 60000, revalidateOnFocus: false }, + ) + const datastores = pbsStatus?.datastores ?? null const [systemData, setSystemData] = useState(null) const [vmData, setVmData] = useState([]) const [storageData, setStorageData] = useState(null) @@ -212,8 +222,8 @@ export function SystemOverview() { const fetchAllData = async () => { const [systemResult, vmResult, storageResults, networkResult] = await Promise.all([ fetchSystemData().finally(() => setLoadingStates((prev) => ({ ...prev, system: false }))), - fetchVMData().finally(() => setLoadingStates((prev) => ({ ...prev, vms: false }))), - Promise.all([fetchStorageData(), fetchProxmoxStorageData()]).finally(() => + (isPbs ? Promise.resolve([]) : fetchVMData()).finally(() => setLoadingStates((prev) => ({ ...prev, vms: false }))), + Promise.all([fetchStorageData(), isPbs ? Promise.resolve(null) : fetchProxmoxStorageData()]).finally(() => setLoadingStates((prev) => ({ ...prev, storage: false })), ), fetchNetworkData().finally(() => setLoadingStates((prev) => ({ ...prev, network: false }))), @@ -248,12 +258,13 @@ export function SystemOverview() { }, 5000) const vmInterval = setInterval(async () => { + if (isPbs) return const data = await fetchVMData() setVmData(data) }, 59000) const storageInterval = setInterval(async () => { - const [storage, proxmoxStorage] = await Promise.all([fetchStorageData(), fetchProxmoxStorageData()]) + const [storage, proxmoxStorage] = await Promise.all([fetchStorageData(), isPbs ? Promise.resolve(null) : fetchProxmoxStorageData()]) if (storage) setStorageData(storage) if (proxmoxStorage) setProxmoxStorageData(proxmoxStorage) }, 59000) @@ -278,7 +289,7 @@ export function SystemOverview() { clearInterval(networkInterval) window.removeEventListener("networkUnitChanged" as any, handleUnitChange) } - }, [t]) + }, [t, isPbs]) if (!hasAttemptedLoad || loadingStates.system) { return ( @@ -527,7 +538,46 @@ export function SystemOverview() { - {/* ── Active VM & LXC (preview restyle v2: pills mismo tamaño que "X running") ── */} + {isPbs ? ( + + + {t("pbs.datastores")} + + + + {!datastores && !pbsError ? ( +
+
+
+
+ ) : !datastores ? ( +

{t("pbs.loadFailed")}

+ ) : datastores.length === 0 ? ( +

{t("pbs.noDatastores")}

+ ) : ( +
+
+ {datastores.length} + {datastores.some(store => store.error) ? ( + + {t("pbs.datastoresUnavailable", { count: datastores.filter(store => store.error).length })} + + ) : ( + + {t("pbs.datastoresOnline")} + + )} +
+ {datastores.slice(0, 2).map(store => )} + {datastores.length > 2 && ( +

{t("pbs.moreDatastores", { count: datastores.length - 2 })}

+ )} +
+ )} +
+
+ ) : ( + /* ── Active VM & LXC (preview restyle v2: pills mismo tamaño que "X running") ── */ {t("overview.activeVmLxc")} @@ -565,6 +615,7 @@ export function SystemOverview() { )} + )} 0 ? "cursor-pointer hover:bg-white/5 transition-colors" : ""}`} @@ -708,7 +759,16 @@ export function SystemOverview() {
- {vmLxcStorages && vmLxcStorages.length > 0 ? ( + {isPbs ? ( +
+
{t("pbs.datastores")}
+ {!datastores ? ( +
{pbsError ? t("pbs.loadFailed") : t("pbs.loading")}
+ ) : datastores.length === 0 ? ( +
{t("pbs.noDatastores")}
+ ) : datastores.map(store => )} +
+ ) : vmLxcStorages && vmLxcStorages.length > 0 ? (
{t("overview.vmLxcStorage")}
@@ -744,7 +804,7 @@ export function SystemOverview() {
)} - {localStorage && ( + {!isPbs && localStorage && (
{t("overview.localStorageSystem")}
@@ -899,7 +959,7 @@ export function SystemOverview() { {formatSystemUptime(systemData.uptime)}
- {t("overview.proxmoxVersion")} + {t(isPbs ? "overview.pbsVersion" : "overview.proxmoxVersion")} {systemData.proxmox_version || t("app.notAvailable")}
diff --git a/AppImage/lib/use-platform.ts b/AppImage/lib/use-platform.ts new file mode 100644 index 00000000..cbb99cc6 --- /dev/null +++ b/AppImage/lib/use-platform.ts @@ -0,0 +1,8 @@ +import useSWR from "swr" +import { fetchApi } from "./api-config" + +// Shares the dashboard's /api/platform request through the SWR cache. +export function useIsPbs(): boolean { + const { data } = useSWR<{ product: string }>("/api/platform", (url: string) => fetchApi<{ product: string }>(url), { revalidateOnFocus: false }) + return data?.product === "pbs" +} diff --git a/AppImage/messages/en/common.json b/AppImage/messages/en/common.json index fa596302..cc8d31f7 100644 --- a/AppImage/messages/en/common.json +++ b/AppImage/messages/en/common.json @@ -50,11 +50,20 @@ "navigation": "PBS navigation", "cpu": "CPU", "memory": "Memory", - "uptime": "Uptime" + "uptime": "Uptime", + "capacityUnavailable": "Capacity not available.", + "datastoresUnavailable": "{count} unavailable", + "datastoresOnline": "All available", + "moreDatastores": "+{count} more datastores", + "estimatedFull": "Estimated full", + "estimatedFullUnknown": "Not enough data yet", + "duration": "Duration", + "noJobs": "No sync, verification or prune schedules are configured." }, "app": { "title": "ProxMenux Monitor", "description": "Proxmox System Dashboard", + "descriptionPbs": "Proxmox Backup Server Dashboard", "loading": "Loading...", "connecting": "Connecting to ProxMenux Monitor", "unknown": "Unknown", @@ -195,6 +204,7 @@ "dayMany": "{count} days" }, "proxmoxVersion": "Proxmox Version:", + "pbsVersion": "PBS Version:", "kernel": "Kernel:", "availableUpdates": "Available Updates:", "packages": "packages", @@ -1880,6 +1890,7 @@ "network": "Network Interfaces", "vms": "VMs & Containers", "services": "PVE Services", + "pbsServices": "PBS Services", "logs": "System Logs", "updates": "System Updates", "security": "Security & Certificates", @@ -4874,6 +4885,7 @@ "network": "Network interfaces", "vms": "VMs & containers", "services": "PVE services", + "pbsServices": "PBS services", "logs": "System logs", "updates": "System updates", "security": "Security & certificates" @@ -6529,5 +6541,38 @@ "cancel": "Cancel" } }, - "runtime": {"notifications":{"templates":{"state_change":{"title":"{hostname}: {category} changed to {current}{entity_suffix}","body":"{category} status changed from {previous} to {current}.\n{reason}","label":"Health state changed"},"new_error":{"title":"{hostname}: New {severity} - {category}{entity_suffix}","body":"{reason}","label":"New health issue"},"error_resolved":{"title":"{hostname}: Resolved - {category}{entity_suffix}","body":"The {category} issue has been resolved.\n{reason}\n🚦 Previous severity: {original_severity}\n⏱️ Duration: {duration}","label":"Recovery notification"},"error_escalated":{"title":"{hostname}: Escalated to {severity} - {category}{entity_suffix}","body":"{reason}","label":"Health issue escalated"},"health_degraded":{"title":"{title_or_default}","body":"{reason}","label":"Health check degraded"},"lxc_updates_available":{"title":"{hostname}: {count} LXC(s) with package updates available","body":"📊 {count} LXC(s) with pending package updates (📦 {total_packages} total, 🔒 {security_count} security):\n\n{ct_list}","label":"LXC updates available (experimental)"},"lxc_update_applied":{"title":"{hostname}: LXC {ct_name} ({vmid}) update {result}","body":"{details}","label":"LXC update applied"},"oci_update_completed":{"title":"{hostname}: {app_name} updated","body":"{app_name} was updated to its new image and its data was kept.\nContainers: {containers}","label":"OCI application updated"},"oci_update_failed":{"title":"{hostname}: {app_name} update did not complete","body":"The update of {app_name} did not complete.\nReason: {reason}\nContainers: {containers}\nOpen Manage installed OCI applications to check its state.","label":"OCI application update failed"},"oci_recreate_completed":{"title":"{hostname}: {app_name} recreated","body":"{app_name} was recreated with its saved configuration and its data was kept.\nContainers: {containers}","label":"OCI application recreated"},"oci_recreate_failed":{"title":"{hostname}: {app_name} recreation did not complete","body":"The recreation of {app_name} did not complete.\nReason: {reason}\nContainers: {containers}\nOpen Manage installed OCI applications to check its state.","label":"OCI application recreation failed"},"oci_modify_completed":{"title":"{hostname}: {app_name} modified","body":"{app_name} was modified with its new options and its data was kept.\nContainers: {containers}","label":"OCI application modified"},"oci_modify_failed":{"title":"{hostname}: {app_name} modification did not complete","body":"The modification of {app_name} did not complete.\nReason: {reason}\nContainers: {containers}\nOpen Manage installed OCI applications to check its state.","label":"OCI application modification failed"},"oci_watchdog_restarted":{"title":"{hostname}: {app_name} restarted after it stopped","body":"{app_name} stopped on its own and the watchdog started it again.\nContainers: {containers}","label":"OCI application restarted by the watchdog"},"oci_watchdog_failed":{"title":"{hostname}: {app_name} could not be kept running","body":"The watchdog could not keep {app_name} running: it stopped again or did not start after several attempts, and it is left stopped.\nContainers: {containers}\nStart it by hand once the cause is solved; the watchdog then watches it again.","label":"OCI application left stopped by the watchdog"},"app_update_available":{"title":"{hostname}: {app_name} update available on CT {vmid}","body":"{app_name} on CT {vmid} ({ct_name}) has a new version:\n {installed} → {latest}","label":"App update available"},"docker_stack_update_available":{"title":"{hostname}: Docker updates available on CT {vmid}","body":"Container {ct_name} (CT {vmid}) has {count} Docker update(s):\n{details}","label":"Docker updates available"},"vm_start":{"title":"{hostname}: VM {vmname} ({vmid}) started","body":"Virtual machine {vmname} (ID: {vmid}) is now running.","label":"VM started"},"vm_start_warning":{"title":"{hostname}: VM {vmname} ({vmid}) started with warnings","body":"Virtual machine {vmname} (ID: {vmid}) started successfully but has warnings.\nWarnings: {reason}","label":"VM started (warnings)"},"vm_stop":{"title":"{hostname}: VM {vmname} ({vmid}) stopped","body":"Virtual machine {vmname} (ID: {vmid}) has been stopped.","label":"VM stopped"},"vm_shutdown":{"title":"{hostname}: VM {vmname} ({vmid}) shut down","body":"Virtual machine {vmname} (ID: {vmid}) has been cleanly shut down.","label":"VM shutdown"},"vm_fail":{"title":"{hostname}: VM {vmname} ({vmid}) FAILED","body":"Virtual machine {vmname} (ID: {vmid}) has crashed or failed to start.\nReason: {reason}","label":"VM FAILED"},"vm_restart":{"title":"{hostname}: VM {vmname} ({vmid}) restarted","body":"Virtual machine {vmname} (ID: {vmid}) has been restarted.","label":"VM restarted"},"ct_start":{"title":"{hostname}: CT {vmname} ({vmid}) started","body":"Container {vmname} (ID: {vmid}) is now running.","label":"CT started"},"ct_start_warning":{"title":"{hostname}: CT {vmname} ({vmid}) started with warnings","body":"Container {vmname} (ID: {vmid}) started successfully but has warnings.\nWarnings: {reason}","label":"CT started (warnings)"},"ct_stop":{"title":"{hostname}: CT {vmname} ({vmid}) stopped","body":"Container {vmname} (ID: {vmid}) has been stopped.","label":"CT stopped"},"ct_shutdown":{"title":"{hostname}: CT {vmname} ({vmid}) shut down","body":"Container {vmname} (ID: {vmid}) has been cleanly shut down.","label":"CT shutdown"},"ct_restart":{"title":"{hostname}: CT {vmname} ({vmid}) restarted","body":"Container {vmname} (ID: {vmid}) has been restarted.","label":"CT restarted"},"ct_fail":{"title":"{hostname}: CT {vmname} ({vmid}) FAILED","body":"Container {vmname} (ID: {vmid}) has crashed or failed to start.\nReason: {reason}","label":"CT FAILED"},"migration_start":{"title":"{hostname}: Migration started — {vmname} ({vmid})","body":"Live migration of {vmname} (ID: {vmid}) to node {target_node} has started.","label":"Migration started"},"migration_complete":{"title":"{hostname}: Migration complete — {vmname} ({vmid})","body":"{vmname} (ID: {vmid}) successfully migrated to node {target_node}.","label":"Migration complete"},"migration_warning":{"title":"{hostname}: Migration complete with warnings — {vmname} ({vmid})","body":"{vmname} (ID: {vmid}) migrated to node {target_node} but encountered warnings.\nWarnings: {reason}","label":"Migration (warnings)"},"migration_fail":{"title":"{hostname}: Migration FAILED — {vmname} ({vmid})","body":"Migration of {vmname} (ID: {vmid}) to node {target_node} failed.\nReason: {reason}","label":"Migration FAILED"},"replication_fail":{"title":"{hostname}: Replication FAILED — {vmname} ({vmid})","body":"Replication of {vmname} (ID: {vmid}) failed.\nReason: {reason}","label":"Replication FAILED"},"replication_complete":{"title":"{hostname}: Replication complete — {vmname} ({vmid})","body":"Replication of {vmname} (ID: {vmid}) completed successfully.","label":"Replication complete"},"backup_start":{"title":"{hostname}: Backup started on {storage}","body":"Backup job started on storage {storage}.\n{reason}","label":"Backup started"},"backup_complete":{"title":"{hostname} → {storage}: Backup complete — {vmname} ({vmid})","body":"Backup of {vmname} (ID: {vmid}) completed successfully on {storage}.\nSize: {size}","label":"Backup complete"},"backup_warning":{"title":"{hostname} → {storage}: Backup complete with warnings — {vmname} ({vmid})","body":"Backup of {vmname} (ID: {vmid}) on {storage} completed but encountered warnings.\nWarnings: {reason}","label":"Backup (warnings)"},"backup_fail":{"title":"{hostname} → {storage}: Backup FAILED — {vmname} ({vmid})","body":"Backup of {vmname} (ID: {vmid}) failed on {storage}.\nReason: {reason}","label":"Backup FAILED"},"host_backup_start":{"title":"{hostname}: Host backup started → {backend_label}","body":"Job: {job_id}\nBackend: {backend_label}\nDestination: {destination}\nProfile: {profile_mode}","label":"Host backup started"},"host_backup_complete":{"title":"{hostname}: Host backup complete → {backend_label}","body":"Job: {job_id}\nBackend: {backend_label}\nDestination: {destination}\nData size: {data_size}\nArchive size: {archive_size}\nDuration: {duration}","label":"Host backup complete"},"host_backup_fail":{"title":"{hostname}: Host backup FAILED → {backend_label}","body":"Job: {job_id}\nBackend: {backend_label}\nDestination: {destination}\nDuration before failure: {duration}\nReason: {reason}\nLog: {log_file}","label":"Host backup FAILED"},"snapshot_complete":{"title":"{hostname}: Snapshot created — {vmname} ({vmid})","body":"Snapshot \"{snapshot_name}\" created for {vmname} (ID: {vmid}).","label":"Snapshot created"},"snapshot_fail":{"title":"{hostname}: Snapshot FAILED — {vmname} ({vmid})","body":"Snapshot creation for {vmname} (ID: {vmid}) failed.\nReason: {reason}","label":"Snapshot FAILED"},"cpu_high":{"title":"{hostname}: High CPU usage — {value}%","body":"CPU usage has reached {value}% on {cores} cores.\n{details}","label":"High CPU usage"},"ram_high":{"title":"{hostname}: High memory usage — {value}%","body":"Memory usage: {used} / {total} ({value}%).\n{details}","label":"High memory usage"},"temp_high":{"title":"{hostname}: High sensor temperature — {value}°C","body":"Sensor temperature has reached {value}°C (threshold: {threshold}°C).\n{details}","label":"High temperature"},"disk_space_low":{"title":"{hostname}: Low disk space on {mount}","body":"Filesystem {mount}: {used}% used ({available} available).\nFree up disk space to avoid service disruption.","label":"Low disk space"},"disk_io_error":{"title":"{hostname}: Disk failure detected — {device}","body":"I/O error or disk failure detected on device {device}.\n{reason}","label":"Disk failure / I/O error"},"storage_unavailable":{"title":"{hostname}: Storage unavailable — {storage_name}","body":"PVE storage \"{storage_name}\" (type: {storage_type}) is not accessible.\nReason: {reason}","label":"Storage unavailable"},"smart_test_complete":{"title":"{hostname}: SMART test completed — {device}","body":"SMART {test_type} test on /dev/{device} has completed.\nResult: {result}\nDuration: {duration}","label":"SMART test completed"},"smart_test_failed":{"title":"{hostname}: SMART test FAILED — {device}","body":"SMART {test_type} test on /dev/{device} has failed.\nResult: {result}\nReason: {reason}","label":"SMART test FAILED"},"gpu_mode_switch":{"title":"{hostname}: GPU mode changed to {new_mode}","body":"GPU passthrough mode has been switched.\nGPU: {gpu_name} ({gpu_pci})\nPrevious mode: {old_mode}\nNew mode: {new_mode}\n{details}","label":"GPU mode switched"},"gpu_passthrough_blocked":{"title":"{hostname}: {guest_type} {guest_id} blocked at startup","body":"PCIe passthrough guard prevented {guest_type} {guest_id} ({guest_name}) from starting.\nReason: {reason}\n{details}","label":"GPU passthrough blocked"},"pci_passthrough_conflict":{"title":"{hostname}: PCIe device conflict detected — {device_pci}","body":"A PCIe device is assigned to multiple guests.\nDevice: {device_pci}\nConflicting guests: {guest_list}\nAction required: Stop one of the guests or reassign the device.","label":"PCIe device conflict"},"load_high":{"title":"{hostname}: High system load — {value}","body":"System load average is {value} on {cores} cores.\n{details}","label":"High system load"},"network_down":{"title":"{hostname}: Network connectivity lost{entity_suffix}","body":"The node has lost network connectivity.\nReason: {reason}","label":"Network connectivity lost"},"network_latency":{"title":"{hostname}: High network latency — {value}ms","body":"Latency to gateway: {value}ms (threshold: {threshold}ms).\nThis may indicate network congestion or hardware issues.","label":"High network latency"},"auth_fail":{"title":"{hostname}: Authentication failure","body":"Failed login attempt detected.\nSource IP: {source_ip}\nUser: {username}\nService: {service}","label":"Authentication failure"},"ip_block":{"title":"{hostname}: IP blocked by Fail2Ban","body":"IP address {source_ip} has been banned.\nJail: {jail}\nFailed attempts: {failures}","label":"IP blocked by Fail2Ban"},"firewall_issue":{"title":"{hostname}: Firewall issue detected{entity_suffix}","body":"A firewall configuration issue has been detected.\nReason: {reason}","label":"Firewall issue detected"},"user_permission_change":{"title":"{hostname}: User permission changed","body":"User: {username}\nChange: {change_details}","label":"User permission changed"},"split_brain":{"title":"{hostname}: Cluster event reported","body":"A cluster event was reported:\n{reason}","label":"Cluster event"},"node_disconnect":{"title":"{hostname}: Node {node_name} disconnected","body":"Node {node_name} has disconnected from the cluster.","label":"Node disconnected"},"node_reconnect":{"title":"{hostname}: Node {node_name} reconnected","body":"Node {node_name} has rejoined the cluster successfully.","label":"Node reconnected"},"system_startup":{"title":"{hostname}: {reason}","body":"{summary}","label":"System startup report"},"system_shutdown":{"title":"{hostname}: System shutting down","body":"The node is shutting down.\n{reason}","label":"System shutting down"},"system_reboot":{"title":"{hostname}: System rebooting","body":"The node is rebooting.\n{reason}","label":"System rebooting"},"system_restore_completed":{"title":"{hostname}: Host restore finished","body":"Post-restore tasks completed in background.\n\nGuest configurations copied: {guests}\nBind-mount stub directories created: {stubs}\nStale node dirs removed: {stale_nodes}\nComponents reinstalled: {components}\nDuration: {duration}\n{warnings_block}","label":"Host restore completed"},"system_problem":{"title":"{hostname}: System problem detected{entity_suffix}","body":"A system-level problem has been detected.\nReason: {reason}","label":"System problem detected"},"service_fail":{"title":"{hostname}: Service failed — {service_name}","body":"System service \"{service_name}\" has failed.\nReason: {reason}","label":"Service failed"},"oom_kill":{"title":"{hostname}: OOM Kill — {process}","body":"Process \"{process}\" was killed by the Out-of-Memory manager.\n{reason}","label":"Out of memory kill"},"service_fail_batch":{"title":"{hostname}: {service_count} services failed{entity_suffix}","body":"{reason}","label":"Service fail batch"},"cron_output":{"title":"{hostname}: {pve_title}","body":"{reason}","label":"Cron job output (per-cron stdout via mail)"},"system_mail":{"title":"{hostname}: {pve_title}","body":"{reason}","label":"Smartd / mail bounces (PVE system mail)"},"apt_listchanges":{"title":"{hostname}: {pve_title}","body":"Upstream package information forwarded by Proxmox VE through apt-listchanges. The following text comes from the package maintainer and is not a ProxMenux recommendation.\n\n{reason}","label":"apt-listchanges package notices"},"webhook_test":{"title":"{hostname}: Webhook test received","body":"PVE webhook connectivity test successful.\n{reason}","label":"Webhook test"},"update_available":{"title":"{hostname}: Updates available","body":"Total updates: {total_count}\nSecurity: {security_count}\nProxmox: {pve_count}\nKernel: {kernel_count}\nImportant packages:\n{important_list}","label":"Updates available (legacy)"},"unknown_persistent":{"title":"{hostname}: Check unavailable - {category}{entity_suffix}","body":"Health check for {category} has been unavailable for 3+ cycles.\n{reason}","label":"Check unavailable"},"health_persistent":{"title":"{hostname}: {count} active health issue(s){entity_suffix}","body":"The following health issues remain unresolved:\n{issue_list}\n\nThis digest is sent once every 24 hours while issues persist.","label":"Active health issues (daily)"},"health_issue_new":{"title":"{hostname}: New health issue — {category}{entity_suffix}","body":"New {severity} issue detected in: {category}\nDetails: {reason}","label":"New health issue"},"health_issue_resolved":{"title":"{hostname}: Resolved - {category}{entity_suffix}","body":"{category} issue has been resolved.\n{reason}\nDuration: {duration}","label":"Health issue resolved"},"update_summary":{"title":"{hostname}: Updates available","body":"Total updates: {total_count}\nSecurity updates: {security_count}\nProxmox-related updates: {pve_count}\nKernel updates: {kernel_count}\nImportant packages:\n{important_list}","label":"Host package updates"},"pve_update":{"title":"{hostname}: Proxmox VE {new_version} available","body":"A new Proxmox VE release is available.\nCurrent: {current_version}\nNew: {new_version}\n{details}","label":"Proxmox VE update available"},"update_complete":{"title":"{hostname}: System update completed","body":"System packages have been successfully updated.\n{details}","label":"Host update completed"},"ai_model_migrated":{"title":"{hostname}: AI model updated","body":"The AI model for notifications has been automatically updated.\nProvider: {provider}\nPrevious model: {old_model}\nNew model: {new_model}\n\n{message}","label":"AI model auto-updated"},"proxmenux_update":{"title":"{hostname}: ProxMenux {new_version} available","body":"A new version of ProxMenux is available.\nCurrent: {current_version}\nNew: {new_version}","label":"ProxMenux update available"},"mount_stale":{"title":"{hostname}: stale remote mount {mount_target}","body":"Remote mount {mount_target} ({fstype}) from {mount_source} is stale{lxc_scope}.\nStat timed out or returned an error: {error}\n\nApps writing to this path will silently land on the underlying filesystem and may fill the disk. Remount or fix connectivity ASAP.","label":"Remote mount stale"},"mount_readonly":{"title":"{hostname}: remote mount {mount_target} is read-only","body":"Remote mount {mount_target} ({fstype}) from {mount_source} is mounted read-only{lxc_scope}. Writes will fail. If this was unintentional, remount with rw.","label":"Remote mount read-only"},"lxc_disk_low":{"title":"{hostname}: CT {vmid} rootfs at {usage_percent}%","body":"CT {vmid} ({name}) rootfs is at {usage_percent}% ({disk_bytes_human} / {maxdisk_bytes_human}).\n\nA full LXC rootfs prevents the container from booting cleanly. Either expand the rootfs (pct resize {vmid} rootfs +1G) or free space inside the container.","label":"LXC rootfs near full"},"vm_disk_low":{"title":"{hostname}: VM {vmid} filesystems at {usage_percent}%","body":"VM {vmid} ({name}) guest filesystems are at {usage_percent}% ({disk_bytes_human} / {maxdisk_bytes_human}).\n\nReported by the QEMU guest agent. Includes every persistent filesystem the guest mounts on a block device — virtual disks and PCI-passthrough drives alike. Free up space inside the guest or expand the affected storage before writes start to fail.","label":"VM filesystems near full"},"lxc_mount_low":{"title":"{hostname}: CT {vmid} mount {mount} at {usage_percent}%","body":"Mount {mount} inside CT {vmid} ({name}) is at {usage_percent}% used.\nFilesystem type: {fstype}\n\nA full mount inside a container often blocks the application silently — writes either fail or, worse, land on the rootfs and trigger the rootfs alert next. Free up space on the mount or expand it.","label":"LXC mount near full"},"pve_storage_full":{"title":"{hostname}: PVE storage {storage_name} at {usage_percent}%","body":"Proxmox storage \"{storage_name}\" (type: {storage_type}) is at {usage_percent}% used.\n\nOnce full, no new VM/CT can be provisioned and existing guests may fail to write. Move/delete unused volumes or expand the underlying pool/LV/RBD image.","label":"PVE storage near full"},"zfs_pool_full":{"title":"{hostname}: ZFS pool {pool_name} at {usage_percent}%","body":"ZFS pool \"{pool_name}\" is at {usage_percent}% capacity.\n\nZFS performance and write reliability degrade sharply above ~80% capacity (CoW needs free space for new blocks). Free up snapshots, prune old datasets, or add more vdevs to the pool.","label":"ZFS pool near full"},"post_install_update":{"title":"{hostname}: {count} ProxMenux optimization update(s) available","body":"{count} ProxMenux optimization update(s) available on this host.\n\n🛠️ Available versions:\n{tool_list}\n\n💡 Apply from:\n • ProxMenux Monitor → Settings → ProxMenux Optimizations\n • Or open the ProxMenux menu → Settings post-install Proxmox → \"Apply available updates\"","label":"ProxMenux optimization updates available"},"secure_gateway_update_available":{"title":"{hostname}: {app_name} update available{update_title_suffix}","body":"{app_name} (managed by ProxMenux) has 📦 {package_count} package update(s) pending in its container.\n{version_line}\n\n💡 Open ProxMenux Monitor > Security > Secure Gateway and click \"Update\" to apply.\n\n🗂️ Packages:\n{package_list}","label":"Secure Gateway update available"},"nvidia_driver_update_available":{"title":"{hostname}: NVIDIA driver update available — v{latest_version}","body":"A newer maintenance release is available for the installed NVIDIA driver branch.\n🔹 Currently installed: v{current_version}\n🟢 Latest available: v{latest_version}\n\n{upgrade_reason}\n\n💡 To reinstall:\n • From the ProxMenux post-install menu: {menu_label}\n\nReinstalling rebuilds the DKMS module against the running kernel and requires a reboot to load the new driver.","label":"NVIDIA driver update available"},"coral_driver_update_available":{"title":"{hostname}: Coral TPU driver update available — {latest_version}","body":"A newer {variant_label} is available.\n🔹 Currently installed: {current_version}\n🟢 Latest available: {latest_version}\n\n{upgrade_reason}\n\n💡 To reinstall:\n • From the ProxMenux post-install menu: {menu_label}\n\n{reboot_note}","label":"Coral TPU driver update available"},"burst_auth_fail":{"title":"{hostname}: +{count} more auth failures in {window}","body":"+{count} additional authentication failures detected in {window} ({total_count} total).\nSources: {entity_list}","label":"Auth failures burst"},"burst_ip_block":{"title":"{hostname}: Fail2Ban banned +{count} more IPs in {window}","body":"+{count} additional IPs banned by Fail2Ban in {window} ({total_count} total).\nIPs: {entity_list}","label":"IP block burst"},"burst_disk_io":{"title":"{hostname}: +{count} more disk I/O errors on {entity_list}","body":"+{count} additional I/O errors detected in {window} ({total_count} total).\nDevices: {entity_list}","label":"Disk I/O burst"},"burst_cluster":{"title":"{hostname}: Cluster flapping detected (+{count} more changes)","body":"Cluster state changed +{count} more times in {window} ({total_count} total).\nNodes: {entity_list}","label":"Cluster flapping burst"},"burst_service_fail":{"title":"{hostname}: +{count} more services failed in {window}","body":"+{count} additional service failures detected in {window} ({total_count} total).\nThis typically indicates a node reboot or PVE service restart.\n\nAdditional failures:\n{details}","label":"Service fail burst"},"burst_system":{"title":"{hostname}: +{count} more system problems in {window}","body":"+{count} additional system problems detected in {window} ({total_count} total).\n\nAdditional issues:\n{details}","label":"System problems burst"},"burst_generic":{"title":"{hostname}: +{count} more {event_type} events in {window}","body":"+{count} additional events of type {event_type} in {window} ({total_count} total).\n\nAdditional events:\n{details}","label":"Generic burst"},"kernel_warning":{"title":"{hostname}: Kernel diagnostic event detected","body":"The kernel recorded a diagnostic event: {kernel_details}","label":"Kernel warnings and diagnostic traces"}},"lxcUpdate":{"status":{"success":"succeeded","failure":"failed","partial":"completed partially","deferred":"deferred","skipped":"skipped"},"source":{"scheduled":"Scheduled","manual":"Manual"},"sourceLabel":"Source","targets":"Targets","osPending":"OS packages pending: {before} → {after}","osUnverified":"OS packages: update command executed; result not verified","applications":"Applications: {items}","applicationsUnverified":"Applications: updater executed; no tracked version change was observed","dockerEngineChange":"Docker Engine: {before} → {after}","dockerEngineVerified":"Docker Engine: verified at {version}","dockerEngineUnverified":"Docker Engine: update command executed; version not verified","dockerImagesPending":"Docker images pending: {before} → {after}","dockerImagesChanged":"Docker images changed: {images}","deferredTargets":"Deferred targets: {targets}","reason":"Reason: {reason}","restartRequired":"Restart required: {value}","yes":"yes","no":"no","verificationPending":"Verification pending until the container is running","verificationWarning":"Verification warning: {error}","duration":"Duration: {duration}"},"fallback":{"unknownTitle":"{hostname}: {event_type}","healthCheckDegraded":"Health check degraded","none":"none","temperatureAlertTitle":"{hostname}: Sensor temperature alert","temperatureAlertBody":"Temperature alert recorded without a complete measurement payload.","recordedReason":"Recorded reason: {reason}","recordedDetails":"Recorded details: {details}"},"fields":{"vmid":"VM/CT","name":"Name","device":"Device","sourceIp":"Source IP","node":"Node","category":"Category","service":"Service","jail":"Jail","user":"User","count":"Count","window":"Window","affected":"Affected"},"vzdump":{"size":"Size: {value}","duration":"Duration: {value}","file":"File","backups":"{count} backups","failed":"{count} failed","total":"Total: {value}","time":"Time: {value}"},"startup":{"issuesTitle":"{hostname}: System startup - {count} issue(s) detected","completeTitle":"{hostname}: System startup completed","operational":"All systems operational.","vmCountOne":"{count} VM","vmCountMany":"{count} VMs","ctCountOne":"{count} CT","ctCountMany":"{count} CTs","started":"✅ {counts} started","vmFailed":"❌ VM failed: {name} - {reason}","ctFailed":"❌ CT failed: {name} - {reason}","unknownError":"unknown error","storageUnavailable":"⚠️ Storage: {count} unavailable ({names})","servicesFailed":"⚠️ Services: {count} failed ({names})","duration":"⏱️ Startup completed in {minutes} min"},"appUpdates":{"singleTitle":"{hostname}: {app_name} update available on CT {vmid}","singleBody":"{app_name} on CT {vmid} ({ct_name}) has a new version:\n {installed} → {latest}","emptyTitle":"{hostname}: Application updates available","emptyBody":"Application updates are available.","batchTitle":"{hostname}: {count} application updates available","batchLeadOneContainer":"{count} applications in {container_count} LXC container have a newer version:","batchLeadManyContainers":"{count} applications in {container_count} LXC containers have a newer version:","additional":"… {count} additional application(s)","app":"app","unknown":"unknown"},"healthDegraded":{"categories":{"cpu":"CPU usage and temperature","memory":"Memory and swap","storage":"Storage mounts and space","disks":"Disk I/O and errors","network":"Network interfaces","vms":"VMs and containers","services":"PVE services","logs":"System logs","updates":"System updates","security":"Security"},"severity":{"critical":"Critical","warning":"Warning"},"singleTitle":"{hostname}: {severity} health status – {category}","multipleTitle":"{hostname}: {count} health checks degraded","multipleLine":"• {severity}: {category}\n {reason}","reasons":{"cpuSustained":"CPU is above {threshold}% for {duration}s."}},"digest":{"title":"{hostname}: 24h summary ({timestamp})","readFailed":"digest read failed: {error}","empty":"No INFO events buffered for this digest window.","lead":"{count} INFO events grouped by category:\n","more":" • … and {count} more","footer":"(Critical/Warning events arrived at the time they happened, not in this digest.)","quietTitle":"{hostname}: {count} events buffered during Quiet Hours","groups":{"vm_ct":"VM a CT","backup":"Backup","resources":"Resources","storage":"Storage","network":"Network","security":"Security","cluster":"Cluster","services":"Services","health":"Health","updates":"Updates","hardware":"Hardware","system":"System","other":"Other"}},"test":{"title":"ProxMenux Test","welcome":"Welcome to ProxMenux Monitor!","verify":"This is a test message to verify your notification channel is working correctly.","configuration":"Channel configuration:","iconsEnabled":"Icons: enabled","iconsDisabled":"Icons: disabled","aiEnabled":"AI: enabled ({info})","aiDisabled":"AI: disabled","alerts":"You will receive alerts about system events, health status changes, and security incidents.","photoCaption":"You can use this image as the profile photo for your notification bot."},"channels":{"discord":{"category":"Category","host":"Host","severity":"Severity"},"email":{"report":"{group} Report","details":"Details","host":"Host","footer":"ProxMenux Notification Service","severity":{"critical":"Critical","warning":"Warning","info":"Information","ok":"Resolved","default":"Notice"},"groups":{"vm_ct":"Virtual Machine / Container","backup":"Backup & Snapshot","resources":"System Resources","storage":"Storage","network":"Network","security":"Security","cluster":"Cluster","services":"System Services","health":"Health Monitor","updates":"System Updates","system":"System","hardware":"Hardware","other":"System Notification"},"fields":{"vmCtId":"VM/CT ID","name":"Name","action":"Action","targetNode":"Target Node","reason":"Reason","storage":"Storage","status":"Status","size":"Size","duration":"Duration","snapshot":"Snapshot","metric":"Metric","currentValue":"Current Value","threshold":"Threshold","cpuCores":"CPU Cores","memory":"Memory","temperature":"Temperature","mountPoint":"Mount Point","usage":"Usage","available":"Available","device":"Device","severity":"Severity","storageName":"Storage Name","type":"Type","interface":"Interface","latency":"Latency","event":"Event","sourceIp":"Source IP","username":"Username","service":"Service","jail":"Jail","failures":"Failures","change":"Change","node":"Node","quorum":"Quorum","nodesAffected":"Nodes Affected","process":"Process","category":"Category","previousSeverity":"Previous Severity","activeIssues":"Active Issues","totalUpdates":"Total Updates","securityUpdates":"Security Updates","proxmoxUpdates":"Proxmox Updates","kernelUpdates":"Kernel Updates","importantPackages":"Important Packages","currentVersion":"Current Version","newVersion":"New Version"},"status":{"failed":"Failed","completed":"Completed","started":"Started","unconfirmed":"Unconfirmed","completed_with_warnings":"Completed with warnings"}}},"temperature":{"sampleSpan":"High samples span {duration}."},"backup":{"unconfirmedTitle":"{hostname}: Backup outcome unconfirmed","confirmedTitle":"{hostname}: Backup complete","confirmedBody":"Backup completed successfully.","errorTitle":"{hostname}: Backup error reported","errorBody":"The backup report contains an error.","unconfirmedBody":"The backup outcome is not confirmed.","warningTitle":"{hostname}: Backup completed with warnings","warningBody":"Backup completed with warnings.","diagnosticsOmitted":"Additional diagnostic lines or text omitted: {count}. Original report retained."}}} + "runtime": {"notifications":{"templates":{"state_change":{"title":"{hostname}: {category} changed to {current}{entity_suffix}","body":"{category} status changed from {previous} to {current}.\n{reason}","label":"Health state changed"},"new_error":{"title":"{hostname}: New {severity} - {category}{entity_suffix}","body":"{reason}","label":"New health issue"},"error_resolved":{"title":"{hostname}: Resolved - {category}{entity_suffix}","body":"The {category} issue has been resolved.\n{reason}\n🚦 Previous severity: {original_severity}\n⏱️ Duration: {duration}","label":"Recovery notification"},"error_escalated":{"title":"{hostname}: Escalated to {severity} - {category}{entity_suffix}","body":"{reason}","label":"Health issue escalated"},"health_degraded":{"title":"{title_or_default}","body":"{reason}","label":"Health check degraded"},"lxc_updates_available":{"title":"{hostname}: {count} LXC(s) with package updates available","body":"📊 {count} LXC(s) with pending package updates (📦 {total_packages} total, 🔒 {security_count} security):\n\n{ct_list}","label":"LXC updates available (experimental)"},"lxc_update_applied":{"title":"{hostname}: LXC {ct_name} ({vmid}) update {result}","body":"{details}","label":"LXC update applied"},"oci_update_completed":{"title":"{hostname}: {app_name} updated","body":"{app_name} was updated to its new image and its data was kept.\nContainers: {containers}","label":"OCI application updated"},"oci_update_failed":{"title":"{hostname}: {app_name} update did not complete","body":"The update of {app_name} did not complete.\nReason: {reason}\nContainers: {containers}\nOpen Manage installed OCI applications to check its state.","label":"OCI application update failed"},"oci_recreate_completed":{"title":"{hostname}: {app_name} recreated","body":"{app_name} was recreated with its saved configuration and its data was kept.\nContainers: {containers}","label":"OCI application recreated"},"oci_recreate_failed":{"title":"{hostname}: {app_name} recreation did not complete","body":"The recreation of {app_name} did not complete.\nReason: {reason}\nContainers: {containers}\nOpen Manage installed OCI applications to check its state.","label":"OCI application recreation failed"},"oci_modify_completed":{"title":"{hostname}: {app_name} modified","body":"{app_name} was modified with its new options and its data was kept.\nContainers: {containers}","label":"OCI application modified"},"oci_modify_failed":{"title":"{hostname}: {app_name} modification did not complete","body":"The modification of {app_name} did not complete.\nReason: {reason}\nContainers: {containers}\nOpen Manage installed OCI applications to check its state.","label":"OCI application modification failed"},"oci_watchdog_restarted":{"title":"{hostname}: {app_name} restarted after it stopped","body":"{app_name} stopped on its own and the watchdog started it again.\nContainers: {containers}","label":"OCI application restarted by the watchdog"},"oci_watchdog_failed":{"title":"{hostname}: {app_name} could not be kept running","body":"The watchdog could not keep {app_name} running: it stopped again or did not start after several attempts, and it is left stopped.\nContainers: {containers}\nStart it by hand once the cause is solved; the watchdog then watches it again.","label":"OCI application left stopped by the watchdog"},"app_update_available":{"title":"{hostname}: {app_name} update available on CT {vmid}","body":"{app_name} on CT {vmid} ({ct_name}) has a new version:\n {installed} → {latest}","label":"App update available"},"docker_stack_update_available":{"title":"{hostname}: Docker updates available on CT {vmid}","body":"Container {ct_name} (CT {vmid}) has {count} Docker update(s):\n{details}","label":"Docker updates available"},"vm_start":{"title":"{hostname}: VM {vmname} ({vmid}) started","body":"Virtual machine {vmname} (ID: {vmid}) is now running.","label":"VM started"},"vm_start_warning":{"title":"{hostname}: VM {vmname} ({vmid}) started with warnings","body":"Virtual machine {vmname} (ID: {vmid}) started successfully but has warnings.\nWarnings: {reason}","label":"VM started (warnings)"},"vm_stop":{"title":"{hostname}: VM {vmname} ({vmid}) stopped","body":"Virtual machine {vmname} (ID: {vmid}) has been stopped.","label":"VM stopped"},"vm_shutdown":{"title":"{hostname}: VM {vmname} ({vmid}) shut down","body":"Virtual machine {vmname} (ID: {vmid}) has been cleanly shut down.","label":"VM shutdown"},"vm_fail":{"title":"{hostname}: VM {vmname} ({vmid}) FAILED","body":"Virtual machine {vmname} (ID: {vmid}) has crashed or failed to start.\nReason: {reason}","label":"VM FAILED"},"vm_restart":{"title":"{hostname}: VM {vmname} ({vmid}) restarted","body":"Virtual machine {vmname} (ID: {vmid}) has been restarted.","label":"VM restarted"},"ct_start":{"title":"{hostname}: CT {vmname} ({vmid}) started","body":"Container {vmname} (ID: {vmid}) is now running.","label":"CT started"},"ct_start_warning":{"title":"{hostname}: CT {vmname} ({vmid}) started with warnings","body":"Container {vmname} (ID: {vmid}) started successfully but has warnings.\nWarnings: {reason}","label":"CT started (warnings)"},"ct_stop":{"title":"{hostname}: CT {vmname} ({vmid}) stopped","body":"Container {vmname} (ID: {vmid}) has been stopped.","label":"CT stopped"},"ct_shutdown":{"title":"{hostname}: CT {vmname} ({vmid}) shut down","body":"Container {vmname} (ID: {vmid}) has been cleanly shut down.","label":"CT shutdown"},"ct_restart":{"title":"{hostname}: CT {vmname} ({vmid}) restarted","body":"Container {vmname} (ID: {vmid}) has been restarted.","label":"CT restarted"},"ct_fail":{"title":"{hostname}: CT {vmname} ({vmid}) FAILED","body":"Container {vmname} (ID: {vmid}) has crashed or failed to start.\nReason: {reason}","label":"CT FAILED"},"migration_start":{"title":"{hostname}: Migration started — {vmname} ({vmid})","body":"Live migration of {vmname} (ID: {vmid}) to node {target_node} has started.","label":"Migration started"},"migration_complete":{"title":"{hostname}: Migration complete — {vmname} ({vmid})","body":"{vmname} (ID: {vmid}) successfully migrated to node {target_node}.","label":"Migration complete"},"migration_warning":{"title":"{hostname}: Migration complete with warnings — {vmname} ({vmid})","body":"{vmname} (ID: {vmid}) migrated to node {target_node} but encountered warnings.\nWarnings: {reason}","label":"Migration (warnings)"},"migration_fail":{"title":"{hostname}: Migration FAILED — {vmname} ({vmid})","body":"Migration of {vmname} (ID: {vmid}) to node {target_node} failed.\nReason: {reason}","label":"Migration FAILED"},"replication_fail":{"title":"{hostname}: Replication FAILED — {vmname} ({vmid})","body":"Replication of {vmname} (ID: {vmid}) failed.\nReason: {reason}","label":"Replication FAILED"},"replication_complete":{"title":"{hostname}: Replication complete — {vmname} ({vmid})","body":"Replication of {vmname} (ID: {vmid}) completed successfully.","label":"Replication complete"},"backup_start":{"title":"{hostname}: Backup started on {storage}","body":"Backup job started on storage {storage}.\n{reason}","label":"Backup started"},"backup_complete":{"title":"{hostname} → {storage}: Backup complete — {vmname} ({vmid})","body":"Backup of {vmname} (ID: {vmid}) completed successfully on {storage}.\nSize: {size}","label":"Backup complete"},"backup_warning":{"title":"{hostname} → {storage}: Backup complete with warnings — {vmname} ({vmid})","body":"Backup of {vmname} (ID: {vmid}) on {storage} completed but encountered warnings.\nWarnings: {reason}","label":"Backup (warnings)"},"backup_fail":{"title":"{hostname} → {storage}: Backup FAILED — {vmname} ({vmid})","body":"Backup of {vmname} (ID: {vmid}) failed on {storage}.\nReason: {reason}","label":"Backup FAILED"},"host_backup_start":{"title":"{hostname}: Host backup started → {backend_label}","body":"Job: {job_id}\nBackend: {backend_label}\nDestination: {destination}\nProfile: {profile_mode}","label":"Host backup started"},"host_backup_complete":{"title":"{hostname}: Host backup complete → {backend_label}","body":"Job: {job_id}\nBackend: {backend_label}\nDestination: {destination}\nData size: {data_size}\nArchive size: {archive_size}\nDuration: {duration}","label":"Host backup complete"},"host_backup_fail":{"title":"{hostname}: Host backup FAILED → {backend_label}","body":"Job: {job_id}\nBackend: {backend_label}\nDestination: {destination}\nDuration before failure: {duration}\nReason: {reason}\nLog: {log_file}","label":"Host backup FAILED"},"snapshot_complete":{"title":"{hostname}: Snapshot created — {vmname} ({vmid})","body":"Snapshot \"{snapshot_name}\" created for {vmname} (ID: {vmid}).","label":"Snapshot created"},"snapshot_fail":{"title":"{hostname}: Snapshot FAILED — {vmname} ({vmid})","body":"Snapshot creation for {vmname} (ID: {vmid}) failed.\nReason: {reason}","label":"Snapshot FAILED"},"cpu_high":{"title":"{hostname}: High CPU usage — {value}%","body":"CPU usage has reached {value}% on {cores} cores.\n{details}","label":"High CPU usage"},"ram_high":{"title":"{hostname}: High memory usage — {value}%","body":"Memory usage: {used} / {total} ({value}%).\n{details}","label":"High memory usage"},"temp_high":{"title":"{hostname}: High sensor temperature — {value}°C","body":"Sensor temperature has reached {value}°C (threshold: {threshold}°C).\n{details}","label":"High temperature"},"disk_space_low":{"title":"{hostname}: Low disk space on {mount}","body":"Filesystem {mount}: {used}% used ({available} available).\nFree up disk space to avoid service disruption.","label":"Low disk space"},"disk_io_error":{"title":"{hostname}: Disk failure detected — {device}","body":"I/O error or disk failure detected on device {device}.\n{reason}","label":"Disk failure / I/O error"},"storage_unavailable":{"title":"{hostname}: Storage unavailable — {storage_name}","body":"PVE storage \"{storage_name}\" (type: {storage_type}) is not accessible.\nReason: {reason}","label":"Storage unavailable"},"smart_test_complete":{"title":"{hostname}: SMART test completed — {device}","body":"SMART {test_type} test on /dev/{device} has completed.\nResult: {result}\nDuration: {duration}","label":"SMART test completed"},"smart_test_failed":{"title":"{hostname}: SMART test FAILED — {device}","body":"SMART {test_type} test on /dev/{device} has failed.\nResult: {result}\nReason: {reason}","label":"SMART test FAILED"},"gpu_mode_switch":{"title":"{hostname}: GPU mode changed to {new_mode}","body":"GPU passthrough mode has been switched.\nGPU: {gpu_name} ({gpu_pci})\nPrevious mode: {old_mode}\nNew mode: {new_mode}\n{details}","label":"GPU mode switched"},"gpu_passthrough_blocked":{"title":"{hostname}: {guest_type} {guest_id} blocked at startup","body":"PCIe passthrough guard prevented {guest_type} {guest_id} ({guest_name}) from starting.\nReason: {reason}\n{details}","label":"GPU passthrough blocked"},"pci_passthrough_conflict":{"title":"{hostname}: PCIe device conflict detected — {device_pci}","body":"A PCIe device is assigned to multiple guests.\nDevice: {device_pci}\nConflicting guests: {guest_list}\nAction required: Stop one of the guests or reassign the device.","label":"PCIe device conflict"},"load_high":{"title":"{hostname}: High system load — {value}","body":"System load average is {value} on {cores} cores.\n{details}","label":"High system load"},"network_down":{"title":"{hostname}: Network connectivity lost{entity_suffix}","body":"The node has lost network connectivity.\nReason: {reason}","label":"Network connectivity lost"},"network_latency":{"title":"{hostname}: High network latency — {value}ms","body":"Latency to gateway: {value}ms (threshold: {threshold}ms).\nThis may indicate network congestion or hardware issues.","label":"High network latency"},"auth_fail":{"title":"{hostname}: Authentication failure","body":"Failed login attempt detected.\nSource IP: {source_ip}\nUser: {username}\nService: {service}","label":"Authentication failure"},"ip_block":{"title":"{hostname}: IP blocked by Fail2Ban","body":"IP address {source_ip} has been banned.\nJail: {jail}\nFailed attempts: {failures}","label":"IP blocked by Fail2Ban"},"firewall_issue":{"title":"{hostname}: Firewall issue detected{entity_suffix}","body":"A firewall configuration issue has been detected.\nReason: {reason}","label":"Firewall issue detected"},"user_permission_change":{"title":"{hostname}: User permission changed","body":"User: {username}\nChange: {change_details}","label":"User permission changed"},"split_brain":{"title":"{hostname}: Cluster event reported","body":"A cluster event was reported:\n{reason}","label":"Cluster event"},"node_disconnect":{"title":"{hostname}: Node {node_name} disconnected","body":"Node {node_name} has disconnected from the cluster.","label":"Node disconnected"},"node_reconnect":{"title":"{hostname}: Node {node_name} reconnected","body":"Node {node_name} has rejoined the cluster successfully.","label":"Node reconnected"},"system_startup":{"title":"{hostname}: {reason}","body":"{summary}","label":"System startup report"},"system_shutdown":{"title":"{hostname}: System shutting down","body":"The node is shutting down.\n{reason}","label":"System shutting down"},"system_reboot":{"title":"{hostname}: System rebooting","body":"The node is rebooting.\n{reason}","label":"System rebooting"},"system_restore_completed":{"title":"{hostname}: Host restore finished","body":"Post-restore tasks completed in background.\n\nGuest configurations copied: {guests}\nBind-mount stub directories created: {stubs}\nStale node dirs removed: {stale_nodes}\nComponents reinstalled: {components}\nDuration: {duration}\n{warnings_block}","label":"Host restore completed"},"system_problem":{"title":"{hostname}: System problem detected{entity_suffix}","body":"A system-level problem has been detected.\nReason: {reason}","label":"System problem detected"},"service_fail":{"title":"{hostname}: Service failed — {service_name}","body":"System service \"{service_name}\" has failed.\nReason: {reason}","label":"Service failed"},"oom_kill":{"title":"{hostname}: OOM Kill — {process}","body":"Process \"{process}\" was killed by the Out-of-Memory manager.\n{reason}","label":"Out of memory kill"},"service_fail_batch":{"title":"{hostname}: {service_count} services failed{entity_suffix}","body":"{reason}","label":"Service fail batch"},"cron_output":{"title":"{hostname}: {pve_title}","body":"{reason}","label":"Cron job output (per-cron stdout via mail)"},"system_mail":{"title":"{hostname}: {pve_title}","body":"{reason}","label":"Smartd / mail bounces (PVE system mail)"},"apt_listchanges":{"title":"{hostname}: {pve_title}","body":"Upstream package information forwarded by Proxmox VE through apt-listchanges. The following text comes from the package maintainer and is not a ProxMenux recommendation.\n\n{reason}","label":"apt-listchanges package notices"},"webhook_test":{"title":"{hostname}: Webhook test received","body":"PVE webhook connectivity test successful.\n{reason}","label":"Webhook test"},"update_available":{"title":"{hostname}: Updates available","body":"Total updates: {total_count}\nSecurity: {security_count}\nProxmox: {pve_count}\nKernel: {kernel_count}\nImportant packages:\n{important_list}","label":"Updates available (legacy)"},"unknown_persistent":{"title":"{hostname}: Check unavailable - {category}{entity_suffix}","body":"Health check for {category} has been unavailable for 3+ cycles.\n{reason}","label":"Check unavailable"},"health_persistent":{"title":"{hostname}: {count} active health issue(s){entity_suffix}","body":"The following health issues remain unresolved:\n{issue_list}\n\nThis digest is sent once every 24 hours while issues persist.","label":"Active health issues (daily)"},"health_issue_new":{"title":"{hostname}: New health issue — {category}{entity_suffix}","body":"New {severity} issue detected in: {category}\nDetails: {reason}","label":"New health issue"},"health_issue_resolved":{"title":"{hostname}: Resolved - {category}{entity_suffix}","body":"{category} issue has been resolved.\n{reason}\nDuration: {duration}","label":"Health issue resolved"},"update_summary":{"title":"{hostname}: Updates available","body":"Total updates: {total_count}\nSecurity updates: {security_count}\nProxmox-related updates: {pve_count}\nKernel updates: {kernel_count}\nImportant packages:\n{important_list}","label":"Host package updates"},"pve_update":{"title":"{hostname}: Proxmox VE {new_version} available","body":"A new Proxmox VE release is available.\nCurrent: {current_version}\nNew: {new_version}\n{details}","label":"Proxmox VE update available"},"update_complete":{"title":"{hostname}: System update completed","body":"System packages have been successfully updated.\n{details}","label":"Host update completed"},"ai_model_migrated":{"title":"{hostname}: AI model updated","body":"The AI model for notifications has been automatically updated.\nProvider: {provider}\nPrevious model: {old_model}\nNew model: {new_model}\n\n{message}","label":"AI model auto-updated"},"proxmenux_update":{"title":"{hostname}: ProxMenux {new_version} available","body":"A new version of ProxMenux is available.\nCurrent: {current_version}\nNew: {new_version}","label":"ProxMenux update available"},"mount_stale":{"title":"{hostname}: stale remote mount {mount_target}","body":"Remote mount {mount_target} ({fstype}) from {mount_source} is stale{lxc_scope}.\nStat timed out or returned an error: {error}\n\nApps writing to this path will silently land on the underlying filesystem and may fill the disk. Remount or fix connectivity ASAP.","label":"Remote mount stale"},"mount_readonly":{"title":"{hostname}: remote mount {mount_target} is read-only","body":"Remote mount {mount_target} ({fstype}) from {mount_source} is mounted read-only{lxc_scope}. Writes will fail. If this was unintentional, remount with rw.","label":"Remote mount read-only"},"lxc_disk_low":{"title":"{hostname}: CT {vmid} rootfs at {usage_percent}%","body":"CT {vmid} ({name}) rootfs is at {usage_percent}% ({disk_bytes_human} / {maxdisk_bytes_human}).\n\nA full LXC rootfs prevents the container from booting cleanly. Either expand the rootfs (pct resize {vmid} rootfs +1G) or free space inside the container.","label":"LXC rootfs near full"},"vm_disk_low":{"title":"{hostname}: VM {vmid} filesystems at {usage_percent}%","body":"VM {vmid} ({name}) guest filesystems are at {usage_percent}% ({disk_bytes_human} / {maxdisk_bytes_human}).\n\nReported by the QEMU guest agent. Includes every persistent filesystem the guest mounts on a block device — virtual disks and PCI-passthrough drives alike. Free up space inside the guest or expand the affected storage before writes start to fail.","label":"VM filesystems near full"},"lxc_mount_low":{"title":"{hostname}: CT {vmid} mount {mount} at {usage_percent}%","body":"Mount {mount} inside CT {vmid} ({name}) is at {usage_percent}% used.\nFilesystem type: {fstype}\n\nA full mount inside a container often blocks the application silently — writes either fail or, worse, land on the rootfs and trigger the rootfs alert next. Free up space on the mount or expand it.","label":"LXC mount near full"},"pve_storage_full":{"title":"{hostname}: PVE storage {storage_name} at {usage_percent}%","body":"Proxmox storage \"{storage_name}\" (type: {storage_type}) is at {usage_percent}% used.\n\nOnce full, no new VM/CT can be provisioned and existing guests may fail to write. Move/delete unused volumes or expand the underlying pool/LV/RBD image.","label":"PVE storage near full"},"zfs_pool_full":{"title":"{hostname}: ZFS pool {pool_name} at {usage_percent}%","body":"ZFS pool \"{pool_name}\" is at {usage_percent}% capacity.\n\nZFS performance and write reliability degrade sharply above ~80% capacity (CoW needs free space for new blocks). Free up snapshots, prune old datasets, or add more vdevs to the pool.","label":"ZFS pool near full"},"post_install_update":{"title":"{hostname}: {count} ProxMenux optimization update(s) available","body":"{count} ProxMenux optimization update(s) available on this host.\n\n🛠️ Available versions:\n{tool_list}\n\n💡 Apply from:\n • ProxMenux Monitor → Settings → ProxMenux Optimizations\n • Or open the ProxMenux menu → Settings post-install Proxmox → \"Apply available updates\"","label":"ProxMenux optimization updates available"},"secure_gateway_update_available":{"title":"{hostname}: {app_name} update available{update_title_suffix}","body":"{app_name} (managed by ProxMenux) has 📦 {package_count} package update(s) pending in its container.\n{version_line}\n\n💡 Open ProxMenux Monitor > Security > Secure Gateway and click \"Update\" to apply.\n\n🗂️ Packages:\n{package_list}","label":"Secure Gateway update available"},"nvidia_driver_update_available":{"title":"{hostname}: NVIDIA driver update available — v{latest_version}","body":"A newer maintenance release is available for the installed NVIDIA driver branch.\n🔹 Currently installed: v{current_version}\n🟢 Latest available: v{latest_version}\n\n{upgrade_reason}\n\n💡 To reinstall:\n • From the ProxMenux post-install menu: {menu_label}\n\nReinstalling rebuilds the DKMS module against the running kernel and requires a reboot to load the new driver.","label":"NVIDIA driver update available"},"coral_driver_update_available":{"title":"{hostname}: Coral TPU driver update available — {latest_version}","body":"A newer {variant_label} is available.\n🔹 Currently installed: {current_version}\n🟢 Latest available: {latest_version}\n\n{upgrade_reason}\n\n💡 To reinstall:\n • From the ProxMenux post-install menu: {menu_label}\n\n{reboot_note}","label":"Coral TPU driver update available"},"burst_auth_fail":{"title":"{hostname}: +{count} more auth failures in {window}","body":"+{count} additional authentication failures detected in {window} ({total_count} total).\nSources: {entity_list}","label":"Auth failures burst"},"burst_ip_block":{"title":"{hostname}: Fail2Ban banned +{count} more IPs in {window}","body":"+{count} additional IPs banned by Fail2Ban in {window} ({total_count} total).\nIPs: {entity_list}","label":"IP block burst"},"burst_disk_io":{"title":"{hostname}: +{count} more disk I/O errors on {entity_list}","body":"+{count} additional I/O errors detected in {window} ({total_count} total).\nDevices: {entity_list}","label":"Disk I/O burst"},"burst_cluster":{"title":"{hostname}: Cluster flapping detected (+{count} more changes)","body":"Cluster state changed +{count} more times in {window} ({total_count} total).\nNodes: {entity_list}","label":"Cluster flapping burst"},"burst_service_fail":{"title":"{hostname}: +{count} more services failed in {window}","body":"+{count} additional service failures detected in {window} ({total_count} total).\nThis typically indicates a node reboot or PVE service restart.\n\nAdditional failures:\n{details}","label":"Service fail burst"},"burst_system":{"title":"{hostname}: +{count} more system problems in {window}","body":"+{count} additional system problems detected in {window} ({total_count} total).\n\nAdditional issues:\n{details}","label":"System problems burst"},"burst_generic":{"title":"{hostname}: +{count} more {event_type} events in {window}","body":"+{count} additional events of type {event_type} in {window} ({total_count} total).\n\nAdditional events:\n{details}","label":"Generic burst"},"kernel_warning":{"title":"{hostname}: Kernel diagnostic event detected","body":"The kernel recorded a diagnostic event: {kernel_details}","label":"Kernel warnings and diagnostic traces"}},"lxcUpdate":{"status":{"success":"succeeded","failure":"failed","partial":"completed partially","deferred":"deferred","skipped":"skipped"},"source":{"scheduled":"Scheduled","manual":"Manual"},"sourceLabel":"Source","targets":"Targets","osPending":"OS packages pending: {before} → {after}","osUnverified":"OS packages: update command executed; result not verified","applications":"Applications: {items}","applicationsUnverified":"Applications: updater executed; no tracked version change was observed","dockerEngineChange":"Docker Engine: {before} → {after}","dockerEngineVerified":"Docker Engine: verified at {version}","dockerEngineUnverified":"Docker Engine: update command executed; version not verified","dockerImagesPending":"Docker images pending: {before} → {after}","dockerImagesChanged":"Docker images changed: {images}","deferredTargets":"Deferred targets: {targets}","reason":"Reason: {reason}","restartRequired":"Restart required: {value}","yes":"yes","no":"no","verificationPending":"Verification pending until the container is running","verificationWarning":"Verification warning: {error}","duration":"Duration: {duration}"},"fallback":{"unknownTitle":"{hostname}: {event_type}","healthCheckDegraded":"Health check degraded","none":"none","temperatureAlertTitle":"{hostname}: Sensor temperature alert","temperatureAlertBody":"Temperature alert recorded without a complete measurement payload.","recordedReason":"Recorded reason: {reason}","recordedDetails":"Recorded details: {details}"},"fields":{"vmid":"VM/CT","name":"Name","device":"Device","sourceIp":"Source IP","node":"Node","category":"Category","service":"Service","jail":"Jail","user":"User","count":"Count","window":"Window","affected":"Affected"},"vzdump":{"size":"Size: {value}","duration":"Duration: {value}","file":"File","backups":"{count} backups","failed":"{count} failed","total":"Total: {value}","time":"Time: {value}"},"startup":{"issuesTitle":"{hostname}: System startup - {count} issue(s) detected","completeTitle":"{hostname}: System startup completed","operational":"All systems operational.","vmCountOne":"{count} VM","vmCountMany":"{count} VMs","ctCountOne":"{count} CT","ctCountMany":"{count} CTs","started":"✅ {counts} started","vmFailed":"❌ VM failed: {name} - {reason}","ctFailed":"❌ CT failed: {name} - {reason}","unknownError":"unknown error","storageUnavailable":"⚠️ Storage: {count} unavailable ({names})","servicesFailed":"⚠️ Services: {count} failed ({names})","duration":"⏱️ Startup completed in {minutes} min"},"appUpdates":{"singleTitle":"{hostname}: {app_name} update available on CT {vmid}","singleBody":"{app_name} on CT {vmid} ({ct_name}) has a new version:\n {installed} → {latest}","emptyTitle":"{hostname}: Application updates available","emptyBody":"Application updates are available.","batchTitle":"{hostname}: {count} application updates available","batchLeadOneContainer":"{count} applications in {container_count} LXC container have a newer version:","batchLeadManyContainers":"{count} applications in {container_count} LXC containers have a newer version:","additional":"… {count} additional application(s)","app":"app","unknown":"unknown"},"healthDegraded":{"categories":{"cpu":"CPU usage and temperature","memory":"Memory and swap","storage":"Storage mounts and space","disks":"Disk I/O and errors","network":"Network interfaces","vms":"VMs and containers","services":"PVE services","logs":"System logs","updates":"System updates","security":"Security"},"severity":{"critical":"Critical","warning":"Warning"},"singleTitle":"{hostname}: {severity} health status – {category}","multipleTitle":"{hostname}: {count} health checks degraded","multipleLine":"• {severity}: {category}\n {reason}","reasons":{"cpuSustained":"CPU is above {threshold}% for {duration}s."}},"digest":{"title":"{hostname}: 24h summary ({timestamp})","readFailed":"digest read failed: {error}","empty":"No INFO events buffered for this digest window.","lead":"{count} INFO events grouped by category:\n","more":" • … and {count} more","footer":"(Critical/Warning events arrived at the time they happened, not in this digest.)","quietTitle":"{hostname}: {count} events buffered during Quiet Hours","groups":{"vm_ct":"VM a CT","backup":"Backup","resources":"Resources","storage":"Storage","network":"Network","security":"Security","cluster":"Cluster","services":"Services","health":"Health","updates":"Updates","hardware":"Hardware","system":"System","other":"Other"}},"test":{"title":"ProxMenux Test","welcome":"Welcome to ProxMenux Monitor!","verify":"This is a test message to verify your notification channel is working correctly.","configuration":"Channel configuration:","iconsEnabled":"Icons: enabled","iconsDisabled":"Icons: disabled","aiEnabled":"AI: enabled ({info})","aiDisabled":"AI: disabled","alerts":"You will receive alerts about system events, health status changes, and security incidents.","photoCaption":"You can use this image as the profile photo for your notification bot."},"channels":{"discord":{"category":"Category","host":"Host","severity":"Severity"},"email":{"report":"{group} Report","details":"Details","host":"Host","footer":"ProxMenux Notification Service","severity":{"critical":"Critical","warning":"Warning","info":"Information","ok":"Resolved","default":"Notice"},"groups":{"vm_ct":"Virtual Machine / Container","backup":"Backup & Snapshot","resources":"System Resources","storage":"Storage","network":"Network","security":"Security","cluster":"Cluster","services":"System Services","health":"Health Monitor","updates":"System Updates","system":"System","hardware":"Hardware","other":"System Notification"},"fields":{"vmCtId":"VM/CT ID","name":"Name","action":"Action","targetNode":"Target Node","reason":"Reason","storage":"Storage","status":"Status","size":"Size","duration":"Duration","snapshot":"Snapshot","metric":"Metric","currentValue":"Current Value","threshold":"Threshold","cpuCores":"CPU Cores","memory":"Memory","temperature":"Temperature","mountPoint":"Mount Point","usage":"Usage","available":"Available","device":"Device","severity":"Severity","storageName":"Storage Name","type":"Type","interface":"Interface","latency":"Latency","event":"Event","sourceIp":"Source IP","username":"Username","service":"Service","jail":"Jail","failures":"Failures","change":"Change","node":"Node","quorum":"Quorum","nodesAffected":"Nodes Affected","process":"Process","category":"Category","previousSeverity":"Previous Severity","activeIssues":"Active Issues","totalUpdates":"Total Updates","securityUpdates":"Security Updates","proxmoxUpdates":"Proxmox Updates","kernelUpdates":"Kernel Updates","importantPackages":"Important Packages","currentVersion":"Current Version","newVersion":"New Version"},"status":{"failed":"Failed","completed":"Completed","started":"Started","unconfirmed":"Unconfirmed","completed_with_warnings":"Completed with warnings"}}},"temperature":{"sampleSpan":"High samples span {duration}."},"backup":{"unconfirmedTitle":"{hostname}: Backup outcome unconfirmed","confirmedTitle":"{hostname}: Backup complete","confirmedBody":"Backup completed successfully.","errorTitle":"{hostname}: Backup error reported","errorBody":"The backup report contains an error.","unconfirmedBody":"The backup outcome is not confirmed.","warningTitle":"{hostname}: Backup completed with warnings","warningBody":"Backup completed with warnings.","diagnosticsOmitted":"Additional diagnostic lines or text omitted: {count}. Original report retained."}}}, + "monitorLinks": { + "thisNode": "This node", + "checking": "Checking nodes…", + "loadFailed": "Could not load the node list.", + "empty": "No other nodes yet. Add one to open its Monitor from here.", + "groups": { + "cluster": "Cluster", + "standalone": "Standalone nodes", + "pbs": "Proxmox Backup Server" + }, + "noResponse": "No response", + "pbsInterface": "PBS interface", + "openNewTab": "Open in a new tab", + "manage": "Add or manage nodes…", + "dialogTitle": "Linked nodes", + "dialogDescription": "Cluster nodes and configured Proxmox Backup Servers are detected automatically. Each Monitor keeps its own sign-in.", + "addTitle": "Add a node", + "name": "Name", + "address": "Monitor address", + "type": "Type", + "add": "Add", + "save": "Save", + "remove": "Remove", + "visible": "Visible", + "autoAddress": "Automatic", + "detected": "Detected", + "added": "Added", + "saveFailed": "Could not save the node list.", + "backTo": "Back to {name}", + "cameFromTitle": "Add Monitor node?", + "cameFromBody": "Add {name} to the linked nodes of this Monitor.", + "notNow": "Not now" + } } diff --git a/AppImage/messages/es/common.json b/AppImage/messages/es/common.json index 322ea52a..a9754ca6 100644 --- a/AppImage/messages/es/common.json +++ b/AppImage/messages/es/common.json @@ -50,12 +50,21 @@ "navigation": "Navegación de PBS", "cpu": "CPU", "memory": "Memoria", - "uptime": "Tiempo de actividad" + "uptime": "Tiempo de actividad", + "capacityUnavailable": "Capacidad no disponible.", + "datastoresUnavailable": "{count} no disponibles", + "datastoresOnline": "Todos disponibles", + "moreDatastores": "+{count} datastores más", + "estimatedFull": "Lleno previsto", + "estimatedFullUnknown": "Aún no hay datos suficientes", + "duration": "Duración", + "noJobs": "No hay programaciones de sincronización, verificación ni prune." }, "_meta": "Community translation target. Copy keys from ../en/common.json and translate the values.", "app": { "title": "ProxMenux Monitor", "description": "Proxmox System Dashboard", + "descriptionPbs": "Proxmox Backup Server Dashboard", "loading": "Cargando...", "connecting": "Conexión al ProxMenux Monitor", "unknown": "Desconocido", @@ -193,6 +202,7 @@ "dayMany": "{count} días" }, "proxmoxVersion": "Versión Proxmox:", + "pbsVersion": "Versión PBS:", "kernel": "Núcleo:", "availableUpdates": "Actualizaciones disponibles:", "packages": "paquetes", @@ -1881,6 +1891,7 @@ "network": "Interfaces de red", "vms": "Máquinas virtuales y contenedores", "services": "Servicios PVE", + "pbsServices": "Servicios PBS", "logs": "Registros del sistema", "updates": "Actualizaciones del sistema", "security": "Seguridad y certificados", @@ -4874,7 +4885,8 @@ "disks": "E/S de disco y errores", "network": "Interfaces de red", "vms": "Máquinas virtuales y contenedores", - "services": "servicios PVE", + "services": "Servicios PVE", + "pbsServices": "Servicios PBS", "logs": "Registros del sistema", "updates": "Actualizaciones del sistema", "security": "Seguridad y certificados" @@ -7321,5 +7333,38 @@ "diagnosticsOmitted": "Líneas o texto de diagnóstico omitidos: {count}. Se conserva el informe original." } } + }, + "monitorLinks": { + "thisNode": "Este nodo", + "checking": "Comprobando nodos…", + "loadFailed": "No se pudo cargar la lista de nodos.", + "empty": "Aún no hay otros nodos. Añade uno para abrir su Monitor desde aquí.", + "groups": { + "cluster": "Clúster", + "standalone": "Nodos independientes", + "pbs": "Proxmox Backup Server" + }, + "noResponse": "Sin respuesta", + "pbsInterface": "Interfaz de PBS", + "openNewTab": "Abrir en una pestaña nueva", + "manage": "Añadir o gestionar nodos…", + "dialogTitle": "Nodos enlazados", + "dialogDescription": "Los nodos del clúster y los Proxmox Backup Server configurados se detectan automáticamente. Cada Monitor mantiene su propio inicio de sesión.", + "addTitle": "Añadir un nodo", + "name": "Nombre", + "address": "Dirección del Monitor", + "type": "Tipo", + "add": "Añadir", + "save": "Guardar", + "remove": "Eliminar", + "visible": "Visible", + "autoAddress": "Automática", + "detected": "Detectado", + "added": "Añadido", + "saveFailed": "No se pudo guardar la lista de nodos.", + "backTo": "Volver a {name}", + "cameFromTitle": "¿Añadir nodo Monitor?", + "cameFromBody": "Añade {name} a los nodos enlazados de este Monitor.", + "notNow": "Ahora no" } } diff --git a/AppImage/scripts/build_appimage.sh b/AppImage/scripts/build_appimage.sh index 40560c21..783978f6 100755 --- a/AppImage/scripts/build_appimage.sh +++ b/AppImage/scripts/build_appimage.sh @@ -163,6 +163,8 @@ cp "$SCRIPT_DIR/temperature_sensor_resolver.py" "$APP_DIR/usr/bin/" 2>/dev/null cp "$SCRIPT_DIR/proxmox_storage_monitor.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ proxmox_storage_monitor.py not found" cp "$SCRIPT_DIR/pbs_manager.py" "$APP_DIR/usr/bin/" cp "$SCRIPT_DIR/flask_pbs_routes.py" "$APP_DIR/usr/bin/" +cp "$SCRIPT_DIR/monitor_links.py" "$APP_DIR/usr/bin/" +cp "$SCRIPT_DIR/flask_monitor_links_routes.py" "$APP_DIR/usr/bin/" cp "$APPIMAGE_ROOT/../scripts/global/proxmox_platform.py" "$APP_DIR/usr/bin/" cp "$SCRIPT_DIR/flask_script_runner.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_script_runner.py not found" cp "$SCRIPT_DIR/security_manager.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ security_manager.py not found" diff --git a/AppImage/scripts/flask_monitor_links_routes.py b/AppImage/scripts/flask_monitor_links_routes.py new file mode 100644 index 00000000..24c7d3ee --- /dev/null +++ b/AppImage/scripts/flask_monitor_links_routes.py @@ -0,0 +1,58 @@ +"""Links between ProxMenux Monitors for the node selector in the header.""" + +from flask import Blueprint, jsonify, request +import monitor_links +from jwt_middleware import require_auth, require_admin_scope + +monitor_links_bp = Blueprint("monitor_links", __name__) + + +@monitor_links_bp.after_request +def no_cache(response): + response.headers["Cache-Control"] = "no-store" + return response + + +@monitor_links_bp.route("/api/monitor-links") +@require_auth +def list_links(): + return jsonify(monitor_links.cached_links(include_hidden=request.args.get("all") == "1")) + + +@monitor_links_bp.route("/api/monitor-links", methods=["POST"]) +@require_admin_scope +def add_link(): + try: + key = monitor_links.add_link(request.get_json(silent=True)) + monitor_links.refresh() + return jsonify(success=True, key=key) + except monitor_links.LinkError as exc: + return jsonify(error=str(exc)), 400 + except OSError: + return jsonify(error="Could not save the node list."), 500 + + +@monitor_links_bp.route("/api/monitor-links/", methods=["PATCH"]) +@require_admin_scope +def update_link(key): + try: + monitor_links.update_link(key, request.get_json(silent=True)) + monitor_links.refresh() + return jsonify(success=True) + except monitor_links.LinkError as exc: + return jsonify(error=str(exc)), 400 + except OSError: + return jsonify(error="Could not save the node list."), 500 + + +@monitor_links_bp.route("/api/monitor-links/", methods=["DELETE"]) +@require_admin_scope +def delete_link(key): + try: + monitor_links.delete_link(key) + monitor_links.refresh() + return jsonify(success=True) + except monitor_links.LinkError as exc: + return jsonify(error=str(exc)), 400 + except OSError: + return jsonify(error="Could not save the node list."), 500 diff --git a/AppImage/scripts/flask_notification_routes.py b/AppImage/scripts/flask_notification_routes.py index 2354141d..907ad3b6 100644 --- a/AppImage/scripts/flask_notification_routes.py +++ b/AppImage/scripts/flask_notification_routes.py @@ -1156,6 +1156,14 @@ def setup_pve_webhook_core() -> dict: 'fallback_commands': [], 'error': None, } + try: # Proxmox Backup Server has no /etc/pve notification configuration + from pbs_manager import is_pbs_host + on_pbs = is_pbs_host() + except Exception: + on_pbs = False + if on_pbs: + result['not_applicable'] = True + return result try: if not _pve_cluster_fs_mounted(): @@ -1339,6 +1347,13 @@ def cleanup_pve_webhook_core() -> dict: Only removes blocks named 'proxmenux-webhook' / 'proxmenux-default'. """ result = {'cleaned': False, 'error': None} + try: # Proxmox Backup Server has no /etc/pve notification configuration + from pbs_manager import is_pbs_host + on_pbs = is_pbs_host() + except Exception: + on_pbs = False + if on_pbs: + return {'cleaned': True, 'error': None, 'not_applicable': True} try: if not _pve_cluster_fs_mounted(): diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py index 3ffa0c4c..ca789d38 100644 --- a/AppImage/scripts/flask_server.py +++ b/AppImage/scripts/flask_server.py @@ -91,6 +91,7 @@ from flask_health_routes import health_bp # noqa: E402 from flask_auth_routes import auth_bp # noqa: E402 from flask_proxmenux_routes import proxmenux_bp # noqa: E402 from flask_pbs_routes import pbs_bp # noqa: E402 +from flask_monitor_links_routes import monitor_links_bp # noqa: E402 from pbs_manager import get_platform, is_pbs_host # noqa: E402 from flask_security_routes import security_bp # noqa: E402 from flask_notification_routes import notification_bp # noqa: E402 @@ -198,6 +199,7 @@ app.register_blueprint(auth_bp) app.register_blueprint(health_bp) app.register_blueprint(proxmenux_bp) app.register_blueprint(pbs_bp) +app.register_blueprint(monitor_links_bp) app.register_blueprint(security_bp) app.register_blueprint(notification_bp) app.register_blueprint(oci_bp) @@ -12214,6 +12216,17 @@ def _get_node_metrics_payload(timeframe, max_age=_NODE_METRICS_TTL): lock.release() +def _node_rrd_command(local_node, timeframe, cf=None): + """Node RRD query; Proxmox Backup Server keeps the same fields under its own API.""" + if is_pbs_host(): + return ['proxmox-backup-debug', 'api', 'get', '/nodes/localhost/rrd', + '--timeframe', timeframe, '--cf', cf or 'AVERAGE', '--output-format', 'json'] + command = ['pvesh', 'get', f'/nodes/{local_node}/rrddata', '--timeframe', timeframe] + if cf: + command += ['--cf', cf] + return command + ['--output-format', 'json'] + + def _compute_node_metrics_payload(timeframe: str) -> dict: """Fetch and shape one node RRD snapshot within a bounded query budget.""" valid_timeframes = ('hour', 'day', 'week', 'month', 'year') @@ -12237,8 +12250,7 @@ def _compute_node_metrics_payload(timeframe: str) -> dict: try: rrd_result = subprocess.run( - ['pvesh', 'get', f'/nodes/{local_node}/rrddata', - '--timeframe', timeframe, '--output-format', 'json'], + _node_rrd_command(local_node, timeframe), capture_output=True, text=True, timeout=_NODE_METRICS_TIMEOUT, ) except subprocess.TimeoutExpired: @@ -12332,9 +12344,7 @@ def _compute_node_metrics_payload(timeframe: str) -> dict: return None try: extra = subprocess.run( - ['pvesh', 'get', f'/nodes/{local_node}/rrddata', - '--timeframe', timeframe, '--cf', cf, - '--output-format', 'json'], + _node_rrd_command(local_node, timeframe, cf), capture_output=True, text=True, timeout=remaining, ) if extra.returncode == 0 and extra.stdout: @@ -23339,8 +23349,8 @@ if __name__ == '__main__': # the 24 h PollingCollector cycle. Runs in a daemon thread with # a delay so it doesn't block startup — notification_manager needs # a moment to bind channels before we emit. - # The guest services below read VMs, LXCs and the PVE node metrics, - # none of which exist on a Proxmox Backup Server. + # The guest services below read VMs and LXCs, which do not exist on a + # Proxmox Backup Server. pve_host = not is_pbs_host() try: def _startup_emit_pending_app_updates(): @@ -23359,13 +23369,19 @@ if __name__ == '__main__': # Prewarm only the Overview's default day range; other ranges are lazy. try: - if pve_host: - metrics_thread = threading.Thread(target=_node_metrics_prewarmer_loop, daemon=True, name='node-metrics-prewarmer') - metrics_thread.start() - print("[ProxMenux] Node-metrics prewarmer started (day range, 90s interval)") + metrics_thread = threading.Thread(target=_node_metrics_prewarmer_loop, daemon=True, name='node-metrics-prewarmer') + metrics_thread.start() + print("[ProxMenux] Node-metrics prewarmer started (day range, 90s interval)") except Exception as e: print(f"[ProxMenux] Node-metrics prewarmer failed to start: {e}") + # Linked Monitors for the header node selector, renewed every 5 minutes. + try: + import monitor_links + monitor_links.start_prewarmer() + except Exception as e: + print(f"[ProxMenux] Monitor links prewarmer failed to start: {e}") + # ── VM/CT modal-cache prewarmer ── # Keeps _vm_details_cache / _vm_backups_cache / app snapshots / # _vm_schedule_cache warm from the backend so the "Loading diff --git a/AppImage/scripts/monitor_links.py b/AppImage/scripts/monitor_links.py new file mode 100644 index 00000000..35ff58a2 --- /dev/null +++ b/AppImage/scripts/monitor_links.py @@ -0,0 +1,321 @@ +"""Other ProxMenux Monitors reachable from this one: cluster nodes, PBS servers +and entries added by hand. + +Only links are kept here. No credential is stored or sent: the browser opens +the other Monitor, which keeps its own sign-in. +""" + +import ipaddress +import json +import os +import re +import secrets +import socket +import ssl +import subprocess +import threading +import time +import urllib.parse +import urllib.request +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +import pbs_manager + +LINKS_FILE = Path(os.environ.get("PROXMENUX_MONITOR_LINKS", "/usr/local/share/proxmenux/monitor_links.json")) +SSL_CONFIG_FILE = Path(os.environ.get("PROXMENUX_SSL_CONFIG", "/etc/proxmenux/ssl_config.json")) +STORAGE_CFG = Path("/etc/pve/storage.cfg") +MONITOR_PORT = 8008 +PBS_UI_PORT = 8007 +PROBE_TIMEOUT = 3 +PROBE_TTL = 300 +MAX_MANUAL = 64 +PRODUCTS = ("pve", "pbs") + +_lock = threading.Lock() +_probe_cache = {} +_snapshot = {"at": 0.0, "data": None} +_refresh_lock = threading.Lock() + + +class LinkError(ValueError): + pass + + +def _load(): + try: + data = json.loads(LINKS_FILE.read_text()) + except (OSError, ValueError): + return {"manual": [], "overrides": {}} + manual = data.get("manual") if isinstance(data.get("manual"), list) else [] + overrides = data.get("overrides") if isinstance(data.get("overrides"), dict) else {} + return {"manual": [row for row in manual if isinstance(row, dict)], "overrides": overrides} + + +def _save(data): + LINKS_FILE.parent.mkdir(parents=True, exist_ok=True) + temporary = LINKS_FILE.with_suffix(".tmp") + temporary.write_text(json.dumps(data, indent=2)) + os.chmod(temporary, 0o600) + os.replace(temporary, LINKS_FILE) + + +def normalize_url(value): + """Return the origin of an http(s) address, or raise LinkError.""" + if not isinstance(value, str) or not value.strip(): + raise LinkError("Enter the address of the Monitor.") + text = value.strip() + if "://" not in text: + text = "https://" + text + parsed = urllib.parse.urlsplit(text) + if parsed.scheme not in ("http", "https") or not parsed.hostname: + raise LinkError("The address must start with http:// or https:// and name a host.") + if parsed.username or parsed.password: + raise LinkError("The address must not contain a user or password.") + try: + port = parsed.port + except ValueError as exc: + raise LinkError("The port of the address is not valid.") from exc + host = parsed.hostname + if ":" in host: + host = f"[{host}]" + return f"{parsed.scheme}://{host}" + (f":{port}" if port else "") + + +def _local_scheme(): + try: + return "https" if json.loads(SSL_CONFIG_FILE.read_text()).get("enabled") else "http" + except (OSError, ValueError, AttributeError): + return "http" + + +def _host_url(host, port, scheme=None): + if ":" in host and not host.startswith("["): + host = f"[{host}]" + return f"{scheme or _local_scheme()}://{host}:{port}" + + +def _is_private(host): + """Only addresses in private ranges are probed without being asked to.""" + try: + addresses = {info[4][0] for info in socket.getaddrinfo(host, None)} + except OSError: + return False + try: + return bool(addresses) and all(ipaddress.ip_address(a.split("%")[0]).is_private for a in addresses) + except ValueError: + return False + + +def _cluster_nodes(): + try: + result = subprocess.run(["pvesh", "get", "/cluster/status", "--output-format", "json"], + capture_output=True, text=True, timeout=8) + rows = json.loads(result.stdout) if result.returncode == 0 else [] + except (OSError, subprocess.SubprocessError, ValueError): + return [] + nodes = [] + for row in rows if isinstance(rows, list) else []: + if isinstance(row, dict) and row.get("type") == "node" and not row.get("local") and row.get("name") and row.get("ip"): + nodes.append({"key": f"cluster:{row['name']}", "name": str(row["name"]), "product": "pve", + "group": "cluster", "host": str(row["ip"])}) + return nodes + + +def _storage_pbs(): + try: + text = STORAGE_CFG.read_text() + except OSError: + return [] + servers, current = [], None + for line in text.splitlines(): + header = re.match(r"^(\w+):\s*(\S+)", line) + if header: + current = {"name": header.group(2), "port": PBS_UI_PORT} if header.group(1) == "pbs" else None + if current is not None: + servers.append(current) + continue + option = re.match(r"^\s+(server|port)\s+(\S+)", line) + if current is not None and option: + current[option.group(1)] = option.group(2) + return [row for row in servers if row.get("server")] + + +def _pbs_servers(): + found = {} + for row in _storage_pbs(): + host = row["server"] + port = row.get("port") if str(row.get("port", "")).isdigit() else PBS_UI_PORT + found.setdefault(host, {"name": row["name"], "native_url": _host_url(host, port, "https")}) + try: + for row in pbs_manager.public_servers(): + host = urllib.parse.urlsplit(row.get("url", "")).hostname + if host: + found.setdefault(host, {"name": row.get("name") or host, "native_url": normalize_url(row["url"])}) + except Exception: + pass + return [{"key": f"pbs:{host}", "name": info["name"], "product": "pbs", "group": "pbs", + "host": host, "native_url": info["native_url"]} for host, info in found.items()] + + +def _probe(url): + """True when a ProxMenux Monitor answers at url; nothing is sent but the request.""" + now = time.monotonic() + with _lock: + cached = _probe_cache.get(url) + if cached and cached[0] > now: + return cached[1] + context = ssl.create_default_context() + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE # liveness only: no credential or token is ever sent + online = False + try: + request = urllib.request.Request(url + "/api/auth/status", headers={"Accept": "application/json"}) + with urllib.request.urlopen(request, timeout=PROBE_TIMEOUT, context=context) as response: + online = response.status == 200 and "auth_enabled" in json.loads(response.read(4096)) + except Exception: + online = False + with _lock: + if len(_probe_cache) > 256: + _probe_cache.clear() + _probe_cache[url] = (now + PROBE_TTL, online) + return online + + +def _resolve(entry, override): + """Pick the Monitor address and its state for one discovered or manual entry.""" + if override.get("url"): + return override["url"], "online" if _probe(override["url"]) else "offline" + if entry.get("url"): + return entry["url"], "online" if _probe(entry["url"]) else "offline" + host = entry["host"] + if not _is_private(host): + return None, "unknown" + for scheme in (_local_scheme(), "https" if _local_scheme() == "http" else "http"): + url = _host_url(host, MONITOR_PORT, scheme) + if _probe(url): + return url, "online" + return _host_url(host, MONITOR_PORT), "offline" + + +def list_links(include_hidden=False): + platform = pbs_manager.get_platform() + data = _load() + entries = [] + if platform.get("product") != "pbs": + entries += _cluster_nodes() + _pbs_servers() + for row in data["manual"]: + entries.append({"key": f"manual:{row.get('id')}", "name": row.get("name") or row.get("url"), + "product": row.get("product") if row.get("product") in PRODUCTS else "pve", + "group": "pbs" if row.get("product") == "pbs" else "standalone", + "url": row.get("url"), "manual": True}) + visible = [e for e in entries if include_hidden or not data["overrides"].get(e["key"], {}).get("hidden")] + with ThreadPoolExecutor(max_workers=8) as executor: + resolved = list(executor.map(lambda e: _resolve(e, data["overrides"].get(e["key"], {})), visible)) + links = [] + for entry, (url, state) in zip(visible, resolved): + override = data["overrides"].get(entry["key"], {}) + links.append({"key": entry["key"], "name": entry["name"], "product": entry["product"], + "group": entry["group"], "url": url, "status": state, + "native_url": entry.get("native_url"), "manual": bool(entry.get("manual")), + "hidden": bool(override.get("hidden")), "custom_url": bool(override.get("url"))}) + return {"self": {"name": platform.get("hostname") or socket.gethostname(), "product": platform.get("product")}, + "links": links} + + +def refresh(): + """Recompute the list with fresh probes and keep it for the header.""" + with _refresh_lock: + with _lock: + _probe_cache.clear() + data = list_links(include_hidden=True) + _snapshot.update(at=time.monotonic(), data=data) + return data + + +def _refresh_quietly(): + try: + refresh() + except Exception: + pass + + +def cached_links(include_hidden=False): + """The last list at once; a stale one is renewed in the background.""" + data = _snapshot["data"] + if data is None: + data = refresh() + elif time.monotonic() - _snapshot["at"] > PROBE_TTL and not _refresh_lock.locked(): + threading.Thread(target=_refresh_quietly, daemon=True, name="monitor-links-refresh").start() + if include_hidden: + return data + return {**data, "links": [row for row in data["links"] if not row["hidden"]]} + + +def start_prewarmer(): + def loop(): + time.sleep(10) + while True: + _refresh_quietly() + time.sleep(PROBE_TTL) + threading.Thread(target=loop, daemon=True, name="monitor-links-prewarmer").start() + + +def add_link(payload): + if not isinstance(payload, dict): + raise LinkError("Invalid request.") + name = str(payload.get("name") or "").strip()[:64] + url = normalize_url(payload.get("url")) + product = payload.get("product") if payload.get("product") in PRODUCTS else "pve" + with _lock: + data = _load() + if len(data["manual"]) >= MAX_MANUAL: + raise LinkError(f"At most {MAX_MANUAL} nodes can be added.") + if any(row.get("url") == url for row in data["manual"]): + raise LinkError("This address is already in the list.") + identifier = secrets.token_hex(6) + data["manual"].append({"id": identifier, "name": name or urllib.parse.urlsplit(url).hostname, + "url": url, "product": product}) + _save(data) + return f"manual:{identifier}" + + +def update_link(key, payload): + if not isinstance(payload, dict): + raise LinkError("Invalid request.") + with _lock: + data = _load() + if key.startswith("manual:"): + row = next((r for r in data["manual"] if f"manual:{r.get('id')}" == key), None) + if row is None: + raise LinkError("Node not found.") + if "name" in payload: + row["name"] = str(payload["name"] or "").strip()[:64] or row["name"] + if payload.get("url"): + row["url"] = normalize_url(payload["url"]) + if payload.get("product") in PRODUCTS: + row["product"] = payload["product"] + elif re.fullmatch(r"(cluster|pbs):[\w.:\-\[\]]{1,253}", key): + override = data["overrides"].setdefault(key, {}) + if "url" in payload: + if payload["url"]: + override["url"] = normalize_url(payload["url"]) + else: + override.pop("url", None) + if "hidden" in payload: + override["hidden"] = bool(payload["hidden"]) + if not override: + data["overrides"].pop(key, None) + else: + raise LinkError("Node not found.") + _save(data) + + +def delete_link(key): + with _lock: + data = _load() + remaining = [r for r in data["manual"] if f"manual:{r.get('id')}" != key] + if len(remaining) == len(data["manual"]): + raise LinkError("Only nodes added by hand can be removed; detected ones can be hidden.") + data["manual"] = remaining + _save(data) diff --git a/AppImage/scripts/tests/test_fwbr_udev_rule_order.py b/AppImage/scripts/tests/test_fwbr_udev_rule_order.py index 70f61b5f..231fc61d 100644 --- a/AppImage/scripts/tests/test_fwbr_udev_rule_order.py +++ b/AppImage/scripts/tests/test_fwbr_udev_rule_order.py @@ -26,18 +26,18 @@ class FwbrUdevRuleOrderTests(unittest.TestCase): def test_auto_post_install_uses_late_rule_and_removes_legacy_rule(self): source = network_optimization_block(AUTO_POST_INSTALL) self.assertIn('local FUNC_VERSION="1.2"', source) - self.assertIn(f"rm -f /etc/udev/rules.d/{OLD_RULE}", source) - self.assertIn(f"cat > /etc/udev/rules.d/{NEW_RULE}", source) + self.assertIn(f"pmx_remove_file /etc/udev/rules.d/{OLD_RULE}", source) + self.assertIn(f"pmx_write_file /etc/udev/rules.d/{NEW_RULE}", source) self.assertIn(f"chmod 0644 /etc/udev/rules.d/{NEW_RULE}", source) - self.assertNotIn(f"cat > /etc/udev/rules.d/{OLD_RULE}", source) + self.assertNotIn(f"pmx_write_file /etc/udev/rules.d/{OLD_RULE}", source) def test_customizable_post_install_uses_late_rule_and_removes_legacy_rule(self): source = network_optimization_block(CUSTOM_POST_INSTALL) self.assertIn('local FUNC_VERSION="1.2"', source) - self.assertIn(f"rm -f /etc/udev/rules.d/{OLD_RULE}", source) - self.assertIn(f"cat > /etc/udev/rules.d/{NEW_RULE}", source) + self.assertIn(f"pmx_remove_file /etc/udev/rules.d/{OLD_RULE}", source) + self.assertIn(f"pmx_write_file /etc/udev/rules.d/{NEW_RULE}", source) self.assertIn(f"chmod 0644 /etc/udev/rules.d/{NEW_RULE}", source) - self.assertNotIn(f"cat > /etc/udev/rules.d/{OLD_RULE}", source) + self.assertNotIn(f"pmx_write_file /etc/udev/rules.d/{OLD_RULE}", source) def test_uninstall_removes_new_and_legacy_rule_names(self): source = UNINSTALL_TOOLS.read_text() diff --git a/AppImage/scripts/tests/test_monitor_links.py b/AppImage/scripts/tests/test_monitor_links.py new file mode 100644 index 00000000..48036393 --- /dev/null +++ b/AppImage/scripts/tests/test_monitor_links.py @@ -0,0 +1,150 @@ +import json +from pathlib import Path +import sys +import tempfile +from types import SimpleNamespace +import unittest +from unittest import mock + +ROOT = Path(__file__).resolve().parents[3] +sys.path.insert(0, str(ROOT / "AppImage/scripts")) + +import monitor_links as links + + +class MonitorLinksTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + storage = self.root / "storage.cfg" + storage.write_text("dir: local\n\tpath /var/lib/vz\n\npbs: Lab\n\tdatastore store1\n\tserver 10.0.0.49\n\n" + "pbs: Cloud\n\tdatastore x\n\tserver pbs.example.com\n\tport 8443\n") + patches = [ + mock.patch.object(links, "LINKS_FILE", self.root / "links.json"), + mock.patch.object(links, "SSL_CONFIG_FILE", self.root / "ssl.json"), + mock.patch.object(links, "STORAGE_CFG", storage), + mock.patch.object(links.pbs_manager, "get_platform", return_value={"product": "pve", "hostname": "amd"}), + mock.patch.object(links.pbs_manager, "public_servers", return_value=[]), + ] + for patch in patches: + patch.start() + self.addCleanup(patch.stop) + links._probe_cache.clear() + cluster = [{"type": "cluster", "name": "Casa"}, + {"type": "node", "name": "amd", "ip": "10.0.0.55", "local": 1}, + {"type": "node", "name": "constructor", "ip": "10.0.0.50", "local": 0}] + self.run_patch = mock.patch.object(links.subprocess, "run", + return_value=SimpleNamespace(returncode=0, stdout=json.dumps(cluster))) + self.run_patch.start() + self.addCleanup(self.run_patch.stop) + + def test_normalize_url_keeps_only_the_origin_and_rejects_credentials(self): + self.assertEqual(links.normalize_url("192.168.0.49:8008/dashboard?x=1"), "https://192.168.0.49:8008") + self.assertEqual(links.normalize_url("http://pve.lan"), "http://pve.lan") + for bad in ("", "ftp://host", "https://user:pass@host", "https://host:99999", "javascript:alert(1)"): + with self.subTest(bad=bad), self.assertRaises(links.LinkError): + links.normalize_url(bad) + + def test_discovers_other_cluster_nodes_and_pbs_without_the_local_node(self): + probed = [] + with mock.patch.object(links, "_probe", side_effect=lambda url: probed.append(url) or url.endswith("10.0.0.50:8008")), \ + mock.patch.object(links, "_is_private", side_effect=lambda host: host.startswith("10.")): + result = links.list_links() + by_key = {row["key"]: row for row in result["links"]} + self.assertEqual(result["self"]["name"], "amd") + self.assertNotIn("cluster:amd", by_key) + self.assertEqual(by_key["cluster:constructor"]["status"], "online") + self.assertEqual(by_key["pbs:10.0.0.49"]["native_url"], "https://10.0.0.49:8007") + self.assertEqual(by_key["pbs:10.0.0.49"]["status"], "offline") + # A public PBS is listed with its own interface but never probed unasked. + self.assertEqual(by_key["pbs:pbs.example.com"]["status"], "unknown") + self.assertEqual(by_key["pbs:pbs.example.com"]["native_url"], "https://pbs.example.com:8443") + self.assertFalse(any("example.com" in url for url in probed)) + + def test_pbs_host_lists_only_manual_entries(self): + links.pbs_manager.get_platform.return_value = {"product": "pbs", "hostname": "pbs-lab"} + links.add_link({"name": "amd", "url": "https://10.0.0.55:8008", "product": "pve"}) + with mock.patch.object(links, "_probe", return_value=True): + result = links.list_links() + self.assertEqual([row["name"] for row in result["links"]], ["amd"]) + self.links_run_not_called() + + def links_run_not_called(self): + self.assertFalse(any(call.args and call.args[0][:1] == ["pvesh"] for call in links.subprocess.run.call_args_list)) + + def test_manual_entries_overrides_and_hidden_detected_nodes(self): + key = links.add_link({"name": "lab", "url": "10.0.0.46:8008", "product": "pve"}) + with self.assertRaises(links.LinkError): + links.add_link({"url": "https://10.0.0.46:8008"}) + links.update_link("cluster:constructor", {"url": "https://constructor.lan:8008"}) + links.update_link("pbs:10.0.0.49", {"hidden": True}) + with mock.patch.object(links, "_probe", return_value=False), \ + mock.patch.object(links, "_is_private", return_value=True): + visible = {row["key"]: row for row in links.list_links()["links"]} + everything = {row["key"]: row for row in links.list_links(include_hidden=True)["links"]} + self.assertEqual(visible[key]["url"], "https://10.0.0.46:8008") + self.assertEqual(visible["cluster:constructor"]["url"], "https://constructor.lan:8008") + self.assertNotIn("pbs:10.0.0.49", visible) + self.assertTrue(everything["pbs:10.0.0.49"]["hidden"]) + with self.assertRaises(links.LinkError): + links.delete_link("cluster:constructor") + links.delete_link(key) + self.assertEqual(json.loads((self.root / "links.json").read_text())["manual"], []) + self.assertEqual((self.root / "links.json").stat().st_mode & 0o777, 0o600) + + def test_cached_list_is_served_without_new_probes_and_renewed_when_stale(self): + links._snapshot.update(at=0.0, data=None) + self.addCleanup(links._snapshot.update, at=0.0, data=None) + links.update_link("pbs:10.0.0.49", {"hidden": True}) + with mock.patch.object(links, "_probe", return_value=True) as probe, \ + mock.patch.object(links, "_is_private", return_value=True): + first = links.cached_links() + calls = probe.call_count + again = links.cached_links() + self.assertEqual(probe.call_count, calls) + self.assertEqual(first, again) + self.assertNotIn("pbs:10.0.0.49", [row["key"] for row in first["links"]]) + self.assertIn("pbs:10.0.0.49", [row["key"] for row in links.cached_links(include_hidden=True)["links"]]) + links._snapshot["at"] -= links.PROBE_TTL + 1 + with mock.patch.object(links.threading, "Thread") as thread: + stale = links.cached_links() + self.assertEqual(stale, first) + thread.assert_called_once() + + def test_unknown_keys_are_rejected(self): + for key in ("other:x", "cluster:../../etc", "manual:nope"): + with self.subTest(key=key), self.assertRaises(links.LinkError): + links.update_link(key, {"hidden": True}) + + def test_probe_sends_no_credentials_and_needs_a_monitor_answer(self): + seen = [] + + class Response: + status = 200 + + def __init__(self, body): + self.body = body + + def read(self, _size): + return self.body + + def __enter__(self): + return self + + def __exit__(self, *_): + return False + + def urlopen(request, timeout, context): + seen.append(request) + return Response(b'{"auth_enabled": true}' if "monitor" in request.full_url else b"") + + with mock.patch.object(links.urllib.request, "urlopen", side_effect=urlopen): + self.assertTrue(links._probe("https://monitor:8008")) + self.assertFalse(links._probe("https://other:8008")) + self.assertTrue(all(request.full_url.endswith("/api/auth/status") for request in seen)) + self.assertTrue(all("Authorization" not in request.headers for request in seen)) + + +if __name__ == "__main__": + unittest.main() diff --git a/lang/es.json b/lang/es.json index de4d581e..c0605465 100644 --- a/lang/es.json +++ b/lang/es.json @@ -37,6 +37,7 @@ "/var/lib/vz/dump (Proxmox default vzdump path)": "/var/lib/vz/dump (ruta por defecto de vzdump)", "/var/lib/vz/dump (Proxmox default)": "/var/lib/vz/dump (por defecto en Proxmox)", "1777 = sticky bit + rwx for all. No shared group needed.": "1777 = sticky bit + rwx para todos. No se necesita un grupo compartido.", + "====== PBS UPDATE COMPLETED ======": "====== ACTUALIZACIÓN PBS COMPLETADA ======", "====== PVE UPDATE COMPLETED ======": "====== ACTUALIZACIÓN PVE COMPLETADA ======", ">> Create a new backup job <<": ">> Crear un trabajo de backup nuevo <<", ">> Save and return <<": ">> Guardar y volver <<", @@ -86,6 +87,7 @@ "A modern wiki and knowledge base for teams": "Una wiki y base de conocimiento moderna para equipos", "A new ProxMenux version is available:": "Una nueva versión de ProxMenux está disponible:", "A new kernel is staged for the next boot:": "Se prepara un nuevo kernel para el siguiente arranque:", + "A newer kernel is installed. Reboot before the next backup window to use it.": "Hay un kernel más reciente instalado. Reinicie antes de la próxima ventana de backups para usarlo.", "A newer version is available:": "Hay una versión más nueva disponible:", "A pending operation exists for": "Existe una operación pendiente", "A pending stack assembly already exists; it is not overwritten": "Ya existe un ensamblado de pila pendiente; no se sobrescribe", @@ -168,6 +170,7 @@ "APT language configuration updated": "Configuración del idioma APT actualizada", "APT language downloads restored": "Descargas del idioma APT restauradas", "APT package lists updated": "Listas de paquetes APT actualizadas", + "APT refresh failed. Check the PBS subscription and repositories in Administration > Repositories. Log:": "La actualización de APT ha fallado. Revise la suscripción y los repositorios de PBS en Administration > Repositories. Log:", "APT refresh failed. Check the PBS subscription and repositories in the native PBS interface; no source was changed.": "No se pudieron actualizar los índices de APT. Comprueba la suscripción y los repositorios de PBS en su interfaz nativa; no se ha cambiado ninguna fuente.", "APT update failed. Check repository access; NVIDIA installation stopped.": "La actualización de APT ha fallado. Verifica el acceso a los repositorios; la instalación de NVIDIA se ha detenido.", "Aborted": "Abortado", @@ -536,6 +539,7 @@ "Available Borg targets:": "Destinos Borg disponibles:", "Available Disks on Host": "Discos disponibles en el host", "Available ISO Images": "Imágenes ISO disponibles", + "Available PBS Version": "Versión PBS disponible", "Available PBS repositories:": "Repositorios de PBS disponibles:", "Available PVE Version": "Versión PVE disponible", "Available Shares:": "Recursos compartidos disponibles:", @@ -1518,6 +1522,7 @@ "Current NVIDIA inventory resolved: a refresh is required": "Actual inventario NVIDIA resuelto: un refresco es necesario", "Current NVIDIA inventory resolved: no refresh is required": "Inventario NVIDIA actual resuelto: ningún refresco es necesario", "Current Network Configuration": "Configuración de red actual", + "Current PBS Version": "Versión PBS actual", "Current PVE Version": "Versión PVE actual", "Current ProxMenux host scripts register remote shares as Proxmox storages using pvesm.": "Los scripts de host ProxMenux actuales registran recursos compartidos remotos como almacenamientos en Proxmox utilizando pvesm.", "Current ProxMenux version:": "Versión actual de ProxMenux:", @@ -1683,6 +1688,7 @@ "Detected Coral hardware:": "Hardware Coral detectado:", "Detected GPU(s):": "GPU detectadas:", "Detected Intel CPU": "CPU Intel detectada", + "Detected Proxmox Backup Server": "Proxmox Backup Server detectado", "Detected Proxmox VE": "Proxmox VE detectado", "Detected Proxmox VE 8.x - Installing Ceph Squid for Debian": "Proxmox VE 8.x detectado: instalación de Ceph Squid para Debian", "Detected Proxmox VE 9.x - Installing Ceph Squid for Debian Trixie": "Detectado Proxmox VE 9.x - Instalación de Ceph Squid para Debian Trixie", @@ -1843,6 +1849,7 @@ "Do you want to proceed with the system update?": "¿Quieres continuar con la actualización del sistema?", "Do you want to proceed?": "¿Quieres continuar?", "Do you want to reboot now?": "¿Quieres reiniciar ahora?", + "Do you want to remove the Proxmox subscription banner from the Proxmox Backup Server web interface?": "¿Desea eliminar el banner de suscripción de Proxmox de la interfaz web de Proxmox Backup Server?", "Do you want to remove the stale entry from vfio.conf and continue?": "¿Quiere eliminar la entrada obsoleta de vfio.conf y continuar?", "Do you want to remove the stale entry from vfio.conf?": "¿Quiere eliminar la entrada obsoleta de vfio.conf?", "Do you want to restart the network service now to apply changes?": "¿Quieres reiniciar el servicio de red ahora para aplicar los cambios?", @@ -2267,6 +2274,7 @@ "Failed to clone log2ram repository. Check /tmp/log2ram_install.log": "No se pudo clonar el repositorio log2ram. Compruebe /tmp/log2ram_install.log", "Failed to configure EFI disk": "No se pudo configurar el disco EFI", "Failed to configure IOMMU automatically.": "No se pudo configurar IOMMU automáticamente.", + "Failed to configure Proxmox Backup Server repositories": "No se han podido configurar los repositorios de Proxmox Backup Server", "Failed to configure Proxmox repositories": "No se pudieron configurar los repositorios de Proxmox", "Failed to configure TPM device in VM": "No se pudo configurar el dispositivo TPM en VM", "Failed to configure repositories.": "No se pudieron configurar los repositorios.", @@ -4152,6 +4160,7 @@ "No active Proxmox storage accepts OCI template archives.": "Ningún almacenamiento activo de Proxmox acepta archivos de plantilla OCI.", "No active exports or showmount not available": "No hay exportaciones activas o showmount no disponible", "No active session": "Ninguna sesión activa", + "No active subscription and no usable PBS repository. Noninteractive mode cannot change APT sources; configure them in Administration > Repositories.": "No hay suscripción activa ni un repositorio de PBS utilizable. El modo no interactivo no puede cambiar las fuentes de APT; configúrelas en Administration > Repositories.", "No active subscription and no usable PVE repository. Noninteractive mode cannot change APT sources; configure them in Node > Updates > Repositories.": "No hay suscripción activa ni un repositorio de PVE utilizable. El modo no interactivo no puede cambiar las fuentes de APT; configúralas en Nodo > Actualizaciones > Repositorios.", "No additional GPU can be added.": "No se puede agregar ninguna GPU adicional.", "No additional device needs to be added.": "No es necesario agregar ningún dispositivo adicional.", @@ -4525,7 +4534,7 @@ "PBS extraction failed": "Error en la extracción de PBS", "PBS host or IP address:": "Host de PBS o dirección IP:", "PBS package update": "Actualización de paquetes de PBS", - "PBS package upgrade completed. Check whether a reboot is required before your next backup window.": "Actualización de paquetes de PBS completada. Comprueba si es necesario reiniciar antes del próximo período de copias de seguridad.", + "PBS package upgrade completed. Check whether a reboot is required before your next backup window.": "Actualización de paquetes de PBS completada. Comprueba si hace falta reiniciar antes de la próxima ventana de backups.", "PBS package upgrade failed. Review the APT output before retrying.": "La actualización de paquetes de PBS ha fallado. Revisa la salida de APT antes de volver a intentarlo.", "PBS upload": "Subida a PBS", "PBS version and packages": "Versión y paquetes de PBS", @@ -4866,6 +4875,9 @@ "Proxmox 9 system update allready": "Actualización del sistema Proxmox 9 ya", "Proxmox APT repositories configured": "Repositorios Proxmox APT configurados", "Proxmox Backup Server (PBS) destinations": "Destinos de Proxmox Backup Server (PBS)", + "Proxmox Backup Server APT repositories configured": "Repositorios APT de Proxmox Backup Server configurados", + "Proxmox Backup Server Update": "Actualización de Proxmox Backup Server", + "Proxmox Backup Server update completed.": "Actualización de Proxmox Backup Server completada.", "Proxmox CIFS Storage Status:": "Estado de almacenamiento CIFS de Proxmox:", "Proxmox NFS Storage Status:": "Estado de almacenamiento NFS de Proxmox:", "Proxmox System Update": "Actualización del sistema Proxmox", @@ -5218,6 +5230,7 @@ "Repository helper unavailable. NVIDIA installation stopped.": "El asistente de repositorios no está disponible. La instalación de NVIDIA se ha detenido.", "Repository policy returned an unexpected result.": "La política de repositorios ha devuelto un resultado inesperado.", "Repository ready.": "Repositorio listo.", + "Repository sources changed, but APT package-list refresh failed. Operation stopped; sources were not rolled back. Inspect Administration > Repositories and retry apt-get update before continuing.": "Las fuentes de repositorio han cambiado, pero la actualización de la lista de paquetes de APT ha fallado. Operación detenida; las fuentes no se han revertido. Revise Administration > Repositories y vuelva a ejecutar apt-get update antes de continuar.", "Repository sources changed, but APT package-list refresh failed. Operation stopped; sources were not rolled back. Inspect Node > Updates > Repositories and retry apt-get update before continuing.": "Las fuentes de los repositorios han cambiado, pero la actualización de la lista de paquetes de APT ha fallado. La operación se ha detenido; las fuentes no se han revertido. Revisa Nodo > Actualizaciones > Repositorios y vuelve a ejecutar apt-get update antes de continuar.", "Repository switch declined; no APT source changed.": "Cambio de repositorio rechazado; no se ha modificado ninguna fuente de APT.", "Repository:": "Repositorio:", @@ -6758,6 +6771,7 @@ "This CIFS share is mounted with restrictive permissions.": "Este recurso compartido CIFS está montado con permisos restrictivos.", "This GPU is considered incompatible with GPU passthrough to a VM in ProxMenux.": "Esta GPU se considera incompatible con el passthrough de GPU a una VM en ProxMenux.", "This NFS share is fully restricted — even the host root cannot write to it.": "Este recurso compartido NFS está completamente restringido: ni siquiera la raíz del host puede escribir en él.", + "This Proxmox Backup Server has no active subscription, switch to the no-subscription repository? The inaccessible Enterprise PBS source will be disabled.": "Este Proxmox Backup Server no tiene una suscripción activa, ¿cambiar al repositorio sin suscripción? Se deshabilitará la fuente Enterprise de PBS, que no es accesible.", "This VM requires extra installation steps, see install guide at:\nhttps://github.com/community-scripts/ProxmoxVE/discussions/144": "Esta máquina virtual requiere pasos de instalación adicionales; consulte la guía de instalación en:\nhttps://github.com/community-scripts/ProxmoxVE/discussions/144", "This account is for {registry}. Enter an image from that registry.": "Esta cuenta es de {registry}. Escribe una imagen de ese registro.", "This action will:": "Esta acción:", @@ -6841,6 +6855,7 @@ "This script cannot be executed from the Proxmox web terminal.": "Este script no se puede ejecutar desde la terminal web de Proxmox.", "This script must be run as root.": "Este script debe ejecutarse como root.", "This script must be run on a Proxmox host.": "Este script debe ejecutarse en un host Proxmox.", + "This script will apply the following optimizations and advanced adjustments to your Proxmox Backup Server": "Este script aplicará las siguientes optimizaciones y ajustes avanzados a su Proxmox Backup Server", "This script will apply the following optimizations and advanced adjustments to your Proxmox VE server": "Este script aplicará las siguientes optimizaciones y ajustes avanzados a su servidor Proxmox VE", "This script will update your Proxmox VE system with advanced options:": "Este script actualizará su sistema Proxmox VE con opciones avanzadas:", "This session is running in the Monitor terminal. Running it from here would cut the connection mid-install and leave the switch in a broken state.": "Esta sesión se ejecuta en el terminal del Monitor. Ejecutarlo desde aquí cortaría la conexión a mitad de la instalación y dejaría el cambio en un estado roto.", @@ -7165,6 +7180,7 @@ "Upgrade assistant: Proxmox VE 8 → 9 (Trixie)": "Asistente de actualización: Proxmox VE 8 → 9 (Trixie)", "Upgrade canceled by user": "Actualización cancelada por el usuario", "Upgrade one node at a time": "Actualice un nodo a la vez", + "Upgrade the system, offering the": "Actualizar el sistema, ofreciendo el", "Upgrade to Proxmox VE 9": "Actualice a Proxmox VE 9", "Upgrading NVIDIA utils (Arch)...": "Actualizando las utilidades de NVIDIA (Arch)...", "Upload an encrypted copy of the key to PBS so you can recover it on a reinstalled host with just a passphrase?": "¿Cargar una copia cifrada de la clave a PBS para poder recuperarla en un host reinstalado con solo una frase de contraseña?", @@ -8084,6 +8100,7 @@ "was": "era", "was installed, but the kernel reports:": "se instaló, pero el kernel informa:", "when finished": "al terminar", + "when the server has no subscription (you will be asked)": "cuando el servidor no tiene suscripción (se le preguntará)", "will rebind the GPU to vfio-pci on the next reboot, breaking the driver that is about to be installed.": "volverá a vincular la GPU a vfio-pci en el próximo reinicio, rompiendo el controlador que está a punto de instalarse.", "wipefs failed on": "los borrados fallaron", "with": "con", diff --git a/scripts/global/remove-banner-pve-v3.sh b/scripts/global/remove-banner-pve-v3.sh index 4ddf7af2..92df1028 100644 --- a/scripts/global/remove-banner-pve-v3.sh +++ b/scripts/global/remove-banner-pve-v3.sh @@ -283,6 +283,10 @@ reload_services() { systemctl is-active --quiet pvedaemon 2>/dev/null && { systemctl reload pvedaemon 2>/dev/null || true } + # Proxmox Backup Server serves the same toolkit from its own proxy. + systemctl is-active --quiet proxmox-backup-proxy 2>/dev/null && { + systemctl reload proxmox-backup-proxy 2>/dev/null || systemctl restart proxmox-backup-proxy 2>/dev/null || true + } } main() { @@ -316,9 +320,12 @@ EOFAPT # Main function to remove subscription banner remove_subscription_banner_v3() { local pve_version - pve_version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+\.[0-9]+' | head -1 || echo "unknown") - - msg_info "$(translate "Detected Proxmox VE") ${pve_version} - $(translate "applying minimal banner patch")" + if command -v pveversion >/dev/null 2>&1; then + pve_version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+\.[0-9]+' | head -1 || echo "unknown") + msg_info "$(translate "Detected Proxmox VE") ${pve_version} - $(translate "applying minimal banner patch")" + else + msg_info "$(translate "Detected Proxmox Backup Server") - $(translate "applying minimal banner patch")" + fi diff --git a/scripts/global/repository-functions.sh b/scripts/global/repository-functions.sh index d6114085..7a2f3b86 100644 --- a/scripts/global/repository-functions.sh +++ b/scripts/global/repository-functions.sh @@ -62,3 +62,48 @@ ensure_repositories() { fi return 0 } + +# The no-subscription switch of a Proxmox Backup Server: the same policy and +# API as on PVE, offered only when the host has no active subscription. +ensure_pbs_repositories() { + local suite decision refresh_status + if [[ "$(pmx_platform_field supported)" != true ]]; then + msg_error "$(translate 'This operation supports PBS 3 on Debian 12 and PBS 4 on Debian 13.')" + return 1 + fi + suite=$(pmx_platform_field codename) + decision=$(repository_policy plan "$suite" pbs) || return 1 + case "$decision" in + preserve) return 0 ;; + offer) ;; + *) msg_error "$(translate 'Repository policy returned an unexpected result.')"; return 1 ;; + esac + if ! declare -F hybrid_yesno >/dev/null || { [[ ! -t 0 ]] && ! { declare -F is_web_mode >/dev/null && is_web_mode; }; }; then + msg_error "$(translate 'No active subscription and no usable PBS repository. Noninteractive mode cannot change APT sources; configure them in Administration > Repositories.')" + return 1 + fi + if ! hybrid_yesno "$(translate 'Proxmox repository')" \ + "$(translate 'This Proxmox Backup Server has no active subscription, switch to the no-subscription repository? The inaccessible Enterprise PBS source will be disabled.')" 14 90; then + msg_error "$(translate 'Repository switch declined; no APT source changed.')" + return 1 + fi + decision=$(repository_policy apply "$suite" pbs) || return 1 + [[ "$decision" == changed || "$decision" == preserve ]] || return 1 + if [[ "$decision" == changed ]]; then + if declare -F pmx_record_execution >/dev/null; then + local journal_function="${PMX_JOURNAL_FUNCTION:-}" journal_version="${PMX_JOURNAL_VERSION:-}" + pmx_journal_context "ensure_pbs_repositories" "" "${PMX_JOURNAL_SOURCE:-}" + pmx_record_execution "Switch to the PBS no-subscription repository" \ + "proxmox-backup-debug api set /nodes/localhost/apt/repositories --handle no-subscription" + PMX_JOURNAL_FUNCTION="$journal_function" PMX_JOURNAL_VERSION="$journal_version" + fi + if apt-get update; then + return 0 + else + refresh_status=$? + msg_error "$(translate 'Repository sources changed, but APT package-list refresh failed. Operation stopped; sources were not rolled back. Inspect Administration > Repositories and retry apt-get update before continuing.')" + return "$refresh_status" + fi + fi + return 0 +} diff --git a/scripts/global/repository_policy.py b/scripts/global/repository_policy.py index 7e0dca70..95f40d68 100644 --- a/scripts/global/repository_policy.py +++ b/scripts/global/repository_policy.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 -"""Proxmox repository policy via its own parsed APT repository API. +"""Proxmox VE and Proxmox Backup Server repository policy via their own parsed APT +repository API. Only `notfound` or `expired` permits an offered switch. Never print raw subscription or repository API responses: they can contain subscription keys or credentials. @@ -12,6 +13,19 @@ import sys ENDPOINT = '/nodes/localhost/apt/repositories' PVE_CHANNELS = {'pve-enterprise', 'pve-no-subscription', 'pve-test', 'pvetest'} +# Proxmox VE and Proxmox Backup Server share the repository API; each has its +# own command line, channels and place in the web interface. +PRODUCTS = { + 'pve': {'api': ['pvesh'], 'name': 'Proxmox VE', 'short': 'PVE', + 'channels': PVE_CHANNELS, 'enterprise': 'pve-enterprise', 'public': 'pve-no-subscription', + 'test': {'pve-no-subscription', 'pve-test', 'pvetest'}, + 'ui': 'Node > Updates > Repositories', 'ceph': True}, + 'pbs': {'api': ['proxmox-backup-debug', 'api'], 'name': 'Proxmox Backup Server', 'short': 'PBS', + 'channels': {'pbs-enterprise', 'pbs-no-subscription', 'pbs-test', 'pbstest'}, + 'enterprise': 'pbs-enterprise', 'public': 'pbs-no-subscription', + 'test': {'pbs-no-subscription', 'pbs-test', 'pbstest'}, + 'ui': 'Administration > Repositories', 'ceph': False}, +} def run(args): @@ -21,7 +35,16 @@ def run(args): raise ValueError(f'Unable to query or change Proxmox repository state ({args[0]}). Check the service and retry.') from exc -def subscription_status(): +def subscription_status(product='pve'): + if product == 'pbs': + # Only the status field is read; the answer also carries the server ID. + try: + status = json.loads(run(['proxmox-backup-manager', 'subscription', 'get', '--output-format', 'json'])).get('status') + except (ValueError, AttributeError): + status = None + if status not in ('active', 'notfound', 'expired'): + raise ValueError('Subscription status is not unambiguously active, notfound or expired. Check proxmox-backup-manager subscription get locally; no repository changed.') + return status # pvesubscription's CLI get printer emits sorted "key: value" lines, # including a secret key and server ID. Only parse the exact status line. output = run(['pvesubscription', 'get']) @@ -31,9 +54,10 @@ def subscription_status(): return statuses[0] -def repository_state(suite): +def repository_state(suite, product='pve'): + p = PRODUCTS[product] try: - data = json.loads(run(['pvesh', 'get', ENDPOINT, '--output-format', 'json'])) + data = json.loads(run([*p['api'], 'get', ENDPOINT, '--output-format', 'json'])) if not isinstance(data, dict) or not isinstance(data['files'], list) or not isinstance(data['errors'], list) or not isinstance(data['digest'], str) or not isinstance(data['standard-repos'], list): raise ValueError() if data['errors']: @@ -86,13 +110,14 @@ def repository_state(suite): entries.append((file['path'], index, row)) return data, entries except (KeyError, TypeError, ValueError, IndexError) as exc: - raise ValueError('Proxmox APT repository inventory is invalid or reports parse errors. Fix sources in Node > Updates > Repositories; no repository changed.') from exc + raise ValueError(f"Proxmox APT repository inventory is invalid or reports parse errors. Fix sources in {p['ui']}; no repository changed.") from exc -def evaluate(suite, apply=False): +def evaluate(suite, apply=False, product='pve'): if suite not in ('bookworm', 'trixie'): raise ValueError('Unsupported Proxmox suite; no repository changed.') - data, entries = repository_state(suite) + p = PRODUCTS[product] + data, entries = repository_state(suite, product) pve = [] ceph = [] debian = False @@ -102,40 +127,40 @@ def evaluate(suite, apply=False): if not row['Enabled'] or 'deb' not in row['Types']: continue components = set(row['Components']) - if components & PVE_CHANNELS: + if components & p['channels']: if suite not in row['Suites']: - wrong_pve.append(f"{' '.join(sorted(components & PVE_CHANNELS))} in {path} (suite {' '.join(row['Suites'])})") + wrong_pve.append(f"{' '.join(sorted(components & p['channels']))} in {path} (suite {' '.join(row['Suites'])})") pve.append((path, index, row)) - if 'main' in components and suite in row['Suites'] and not components & PVE_CHANNELS: + if 'main' in components and suite in row['Suites'] and not components & p['channels']: debian = True - if 'enterprise' in components and any('/ceph-' in uri for uri in row['URIs']): + if p['ceph'] and 'enterprise' in components and any('/ceph-' in uri for uri in row['URIs']): if suite not in row['Suites']: wrong_ceph.append(f"{path} (suite {' '.join(row['Suites'])})") ceph.append((path, index, row)) # Name the stale sources, never their URIs: those can carry credentials. if wrong_pve: - raise ValueError(f"This host runs Proxmox VE on {suite}, but these Proxmox VE repositories are for another suite: " - f"{'; '.join(wrong_pve)}. Disable or remove them in Node > Updates > Repositories and run the update again; no repository changed.") + raise ValueError(f"This host runs {p['name']} on {suite}, but these {p['name']} repositories are for another suite: " + f"{'; '.join(wrong_pve)}. Disable or remove them in {p['ui']} and run the update again; no repository changed.") if wrong_ceph: raise ValueError(f"This host runs Proxmox VE on {suite}, but these Enterprise Ceph repositories are for another suite: " f"{'; '.join(wrong_ceph)}. Correct them in Node > Updates > Repositories before switching; no repository changed.") - if any(set(row['Components']) & {'pve-no-subscription', 'pve-test', 'pvetest'} for _, _, row in pve): + if any(set(row['Components']) & p['test'] for _, _, row in pve): return 'preserve' # Entitlement lookup is only needed when considering a switch. Existing # public/test channels are the operator's choice, even if lookup fails. - status = subscription_status() # fail closed before any write + status = subscription_status(product) # fail closed before any write if status == 'active': if not pve: - raise ValueError('Host has an active subscription but no active PVE repository. Configure its Enterprise source in Node > Updates > Repositories; no repository changed.') + raise ValueError(f"Host has an active subscription but no active {p['short']} repository. Configure its Enterprise source in {p['ui']}; no repository changed.") return 'preserve' if not debian: raise ValueError('No active Debian base repository for this suite; configure it in the Proxmox repository UI before continuing.') - # No active PVE source or only inaccessible Enterprise. A mixed stanza + # No active product source or only inaccessible Enterprise. A mixed stanza # cannot be disabled without also disabling an unrelated component. - disable = [item for item in pve if 'pve-enterprise' in item[2]['Components']] + ceph + disable = [item for item in pve if p['enterprise'] in item[2]['Components']] + ceph for _, _, row in disable: - expected = {'pve-enterprise'} if 'pve-enterprise' in row['Components'] else {'enterprise'} + expected = {p['enterprise']} if p['enterprise'] in row['Components'] else {'enterprise'} if set(row['Components']) != expected: raise ValueError('Enterprise shares an APT stanza with other components. Split it in the Proxmox repository UI; no repository changed.') handles = [r.get('handle') for r in data['standard-repos'] if r.get('handle') == 'no-subscription'] @@ -149,9 +174,9 @@ def evaluate(suite, apply=False): try: for path, index, original in disable: next(file for file in expected if file['path'] == path)['repositories'][index]['Enabled'] = False - run(['pvesh', 'create', ENDPOINT, '--path', path, '--index', str(index), + run([*p['api'], 'create', ENDPOINT, '--path', path, '--index', str(index), '--enabled', '0', '--digest', data['digest']]) - refreshed, current = repository_state(suite) + refreshed, current = repository_state(suite, product) # Per-file digests change when Proxmox serializes the disabled entry. inventory = lambda files: sorted( ({key: value for key, value in file.items() if key != 'digest'} for file in files), @@ -159,23 +184,25 @@ def evaluate(suite, apply=False): if inventory(refreshed['files']) != inventory(expected): raise ValueError('Could not verify the expected repository inventory after Enterprise disable; possible concurrent edit. Inspect the Proxmox repository UI before retrying.') data = refreshed - run(['pvesh', 'set', ENDPOINT, '--handle', 'no-subscription', '--digest', data['digest']]) - _, current = repository_state(suite) + run([*p['api'], 'set', ENDPOINT, '--handle', 'no-subscription', '--digest', data['digest']]) + _, current = repository_state(suite, product) if not any(row['Enabled'] and 'deb' in row['Types'] and suite in row['Suites'] - and 'pve-no-subscription' in row['Components'] for _, _, row in current) \ + and p['public'] in row['Components'] for _, _, row in current) \ or any(p == path and i == index and row['Enabled'] for path, index, _ in disable for p, i, row in current): raise ValueError('Could not verify the switched repositories; inspect the Proxmox repository UI before retrying.') except ValueError as exc: - raise ValueError('Could not complete or verify the repository switch; changes may be partial and repository state is unknown. Inspect Node > Updates > Repositories before retrying; no automatic rollback was attempted.') from exc + raise ValueError(f"Could not complete or verify the repository switch; changes may be partial and repository state is unknown. Inspect {p['ui']} before retrying; no automatic rollback was attempted.") from exc return 'changed' def main(): try: - if len(sys.argv) != 3 or sys.argv[1] not in ('plan', 'apply'): - raise ValueError('Usage: repository_policy.py plan|apply bookworm|trixie') - print(evaluate(sys.argv[2], apply=sys.argv[1] == 'apply')) + if len(sys.argv) not in (3, 4) or sys.argv[1] not in ('plan', 'apply') \ + or (len(sys.argv) == 4 and sys.argv[3] not in PRODUCTS): + raise ValueError('Usage: repository_policy.py plan|apply bookworm|trixie [pve|pbs]') + product = sys.argv[3] if len(sys.argv) == 4 else 'pve' + print(evaluate(sys.argv[2], apply=sys.argv[1] == 'apply', product=product)) except ValueError as exc: print(str(exc), file=sys.stderr) return 1 diff --git a/scripts/global/update-pbs.sh b/scripts/global/update-pbs.sh new file mode 100644 index 00000000..54574676 --- /dev/null +++ b/scripts/global/update-pbs.sh @@ -0,0 +1,188 @@ +#!/bin/bash +# ========================================================== +# Proxmox Backup Server Update Script (post-install flows) +# ========================================================== + +LOCAL_SCRIPTS="/usr/local/share/proxmenux/scripts" +BASE_DIR="/usr/local/share/proxmenux" +UTILS_FILE="$BASE_DIR/utils.sh" +APT_ENV="env DEBIAN_FRONTEND=noninteractive LC_ALL=C LANG=C" + +if [[ -f "$UTILS_FILE" ]]; then + source "$UTILS_FILE" +fi +if [[ -f "$LOCAL_SCRIPTS/global/pmx_journal.sh" ]]; then + source "$LOCAL_SCRIPTS/global/pmx_journal.sh" +fi +source "$LOCAL_SCRIPTS/global/common-functions.sh" +source "$LOCAL_SCRIPTS/global/repository-functions.sh" + +load_language +initialize_cache + +update_pbs() { + local FUNC_VERSION="1.0" + pmx_journal_context "update_pbs" "$FUNC_VERSION" + + if ! pmx_is_pbs || [[ "$(pmx_platform_field supported)" != true ]]; then + msg_error "$(translate 'This operation supports PBS 3 on Debian 12 and PBS 4 on Debian 13.')" + return 1 + fi + + local start_time + start_time=$(date +%s) + local log_file="/var/log/proxmox-update-$(date +%Y%m%d-%H%M%S).log" + local screen_capture="/tmp/proxmenux_screen_capture_$$.txt" + local OS_CODENAME + OS_CODENAME=$(pmx_platform_field codename) + + { + echo "=== ProxMenux — Proxmox Backup Server update ===" + echo "Started: $(date -Iseconds)" + echo "Host: $(hostname)" + echo "Running: proxmox-backup-server $(dpkg-query -W -f='${Version}' proxmox-backup-server 2>/dev/null)" + } > "$log_file" + + if ! ensure_pbs_repositories; then + msg_error "$(translate "Failed to configure Proxmox Backup Server repositories")" + rm -f "$screen_capture" + return 1 + fi + + local available_space + available_space=$(df /var/cache/apt/archives | awk 'NR==2 {print int($4/1024)}') + if [ "$available_space" -lt 1024 ]; then + msg_error "$(translate "Insufficient disk space. Available: ${available_space}MB")" + return 1 + fi + + local update_output update_exit_code + update_output=$(apt-get -o APT::Update::Error-Mode=any update 2>&1) + update_exit_code=$? + { + echo + echo "--- apt-get update (exit $update_exit_code) ---" + printf '%s\n' "$update_output" + } >> "$log_file" + if [ $update_exit_code -ne 0 ]; then + msg_error "$(translate "APT refresh failed. Check the PBS subscription and repositories in Administration > Repositories. Log:") $log_file" + return 1 + fi + msg_ok "$(translate "Package lists updated successfully")" | tee -a "$screen_capture" + + local current_version available_version + current_version=$(dpkg-query -W -f='${Version}' proxmox-backup-server 2>/dev/null) + available_version=$(apt-cache policy proxmox-backup-server 2>/dev/null | awk '/Candidate:/ {print $2; exit}') + + local upgradable upgradable_raw upgradable_list security_updates + upgradable_raw=$($APT_ENV apt list --upgradable 2>/dev/null \ + | sed '1d' \ + | sed '/^\s*$/d') + upgradable=$(printf '%s' "$upgradable_raw" | grep -c . ) + security_updates=$(printf '%s\n' "$upgradable_raw" | grep -ci '\-security') + upgradable_list=$(printf '%s\n' "$upgradable_raw" | pmx_format_upgradable) + + { + echo + echo "--- Packages to upgrade ($upgradable) ---" + [ "$upgradable" -gt 0 ] && printf '%s\n' "$upgradable_list" + } >> "$log_file" + + local menu_text + menu_text="$(translate "System Update Information")\n\n" + menu_text+="$(translate "Current PBS Version"): $current_version\n" + if [ -n "$available_version" ] && [ "$available_version" != "$current_version" ]; then + menu_text+="$(translate "Available PBS Version"): $available_version\n" + fi + menu_text+="\n$(translate "Package Updates Available"): $upgradable\n" + menu_text+="$(translate "Security Updates"): $security_updates\n\n" + + if [ "$upgradable" -eq 0 ]; then + menu_text+="$(translate "System is already up to date")" + whiptail --title "$(translate "Update Status")" --msgbox "$menu_text" 15 70 + clear + show_proxmenux_logo + msg_title "$(translate "$SCRIPT_TITLE")" + cat "$screen_capture" + msg_ok "$(translate "System is already up to date. No update needed.")" + echo -e "\nSystem is already up to date." >> "$log_file" + rm -f "$screen_capture" + return 0 + fi + + menu_text+="$(translate "Packages to be upgraded"):\n$upgradable_list\n\n" + menu_text+="$(translate "Do you want to proceed with the system update?")" + local proceed=0 + whiptail --title "$(translate "Proxmox Backup Server Update")" --scrolltext --yesno "$menu_text" 24 78 || proceed=1 + + clear + show_proxmenux_logo + msg_title "$(translate "$SCRIPT_TITLE")" + cat "$screen_capture" + + if [ $proceed -ne 0 ]; then + msg_info2 "$(translate "Update cancelled by user")" + rm -f "$screen_capture" + return 0 + fi + + pmx_record_execution "Upgrade Proxmox Backup Server packages" "apt -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold full-upgrade" + local dpkg_mark upgrade_exit_code + dpkg_mark=$(date '+%Y-%m-%d %H:%M:%S') + echo -e "\n--- apt full-upgrade ---" >> "$log_file" + DEBIAN_FRONTEND=noninteractive apt -y \ + -o Dpkg::Options::='--force-confdef' \ + -o Dpkg::Options::='--force-confold' \ + full-upgrade 2> >(tee -a "$log_file" >&2) + upgrade_exit_code=$? + echo -e + { + echo + echo "--- Packages changed (exit $upgrade_exit_code) ---" + pmx_dpkg_changes_since "$dpkg_mark" + } >> "$log_file" + + clear + show_proxmenux_logo + msg_title "$(translate "$SCRIPT_TITLE")" + cat "$screen_capture" + rm -f "$screen_capture" + + if [ $upgrade_exit_code -ne 0 ]; then + msg_error "$(translate "System upgrade failed. Check log: $log_file")" + echo "Finished: $(date -Iseconds) — upgrade failed (exit $upgrade_exit_code)" >> "$log_file" + return 1 + fi + + pmx_record_execution "Remove unused packages" "apt-get -y autoremove" + apt-get -y autoremove > /dev/null 2>&1 || true + apt-get -y autoclean > /dev/null 2>&1 || true + msg_ok "$(translate "Cleanup finished")" + + local end_time duration minutes seconds + end_time=$(date +%s) + duration=$((end_time - start_time)) + minutes=$((duration / 60)) + seconds=$((duration % 60)) + + echo -e "${TAB}${BGN}$(translate "====== PBS UPDATE COMPLETED ======")${CL}" + echo -e "${TAB}${GN}⏱️ $(translate "Duration")${CL}: ${BL}${minutes}m ${seconds}s${CL}" + echo -e "${TAB}${GN}📄 $(translate "Log file")${CL}: ${BL}$log_file${CL}" + echo -e "${TAB}${GN}📦 $(translate "Packages upgraded")${CL}: ${BL}$upgradable${CL}" + echo -e "${TAB}${GN}🗄️ $(translate "Proxmox Backup Server")${CL}: ${BL}$(dpkg-query -W -f='${Version}' proxmox-backup-server 2>/dev/null) (Debian $OS_CODENAME)${CL}" + + { + echo + echo "Finished: $(date -Iseconds) — ${minutes}m ${seconds}s" + echo "Running: proxmox-backup-server $(dpkg-query -W -f='${Version}' proxmox-backup-server 2>/dev/null)" + } >> "$log_file" + + if [ -f /var/run/reboot-required ] || [ "$(uname -r)" != "$(ls -1 /boot/vmlinuz-* 2>/dev/null | sed 's|/boot/vmlinuz-||' | sort -V | tail -1)" ]; then + msg_warn "$(translate "A newer kernel is installed. Reboot before the next backup window to use it.")" + fi + msg_ok "$(translate "Proxmox Backup Server update completed.")" +} + +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + update_pbs +fi diff --git a/scripts/menus/menu_post_install.sh b/scripts/menus/menu_post_install.sh index 447583e7..d4e63cda 100644 --- a/scripts/menus/menu_post_install.sh +++ b/scripts/menus/menu_post_install.sh @@ -28,6 +28,12 @@ fi load_language initialize_cache +PMX_ON_PBS=false +if [[ -f "$LOCAL_SCRIPTS/global/platform-functions.sh" ]]; then + source "$LOCAL_SCRIPTS/global/platform-functions.sh" + pmx_is_pbs && PMX_ON_PBS=true +fi + # ========================================================== confirm_and_run() { local name="$1" @@ -55,8 +61,13 @@ confirm_automated_script() { local script_info="" - script_info+="$(translate "This script will apply the following optimizations and advanced adjustments to your Proxmox VE server"):\n\n" - script_info+="• $(translate "Configure") \Z4free repositories\Z0 $(translate "and upgrade the system (disables the enterprise repo)")\n" + if [[ "$PMX_ON_PBS" == true ]]; then + script_info+="$(translate "This script will apply the following optimizations and advanced adjustments to your Proxmox Backup Server"):\n\n" + script_info+="• $(translate "Upgrade the system, offering the") \Z4no-subscription repository\Z0 $(translate "when the server has no subscription (you will be asked)")\n" + else + script_info+="$(translate "This script will apply the following optimizations and advanced adjustments to your Proxmox VE server"):\n\n" + script_info+="• $(translate "Configure") \Z4free repositories\Z0 $(translate "and upgrade the system (disables the enterprise repo)")\n" + fi script_info+="• $(translate "Optionally remove") \Z4subscription banner\Z0 $(translate "from Proxmox web interface (you will be asked)")\n" script_info+="• $(translate "Optimize") \Z4memory\Z0, \Z4kernel\Z0, $(translate "and") \Z4network\Z0 $(translate "for better performance and stability")\n" script_info+="• $(translate "Install and configure") \Z4Log2RAM\Z0 $(translate "(only on SSD/NVMe) to protect your disk")\n" @@ -229,6 +240,12 @@ declare -a COMMUNITY_SCRIPTS=( "Proxmox VE Post Install|Helper-Scripts|bash -c \"\$(wget -qLO - https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/tools/pve/post-pve-install.sh); msg_success \\\"\$(translate 'Press ENTER to continue...')\\\"; read -r _\"" "Proxmox VE Microcode|Helper-Scripts|bash -c \"\$(wget -qLO - https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/tools/pve/microcode.sh); msg_success \\\"\$(translate 'Press ENTER to continue...')\\\"; read -r _\"" ) +if [[ "$PMX_ON_PBS" == true ]]; then + COMMUNITY_SCRIPTS=( + "Proxmox Backup Server Post Install|Helper-Scripts|bash -c \"\$(wget -qLO - https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/tools/pve/post-pbs-install.sh); msg_success \\\"\$(translate 'Press ENTER to continue...')\\\"; read -r _\"" + "Proxmox Backup Server Microcode|Helper-Scripts|bash -c \"\$(wget -qLO - https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/tools/pve/pbs-microcode.sh); msg_success \\\"\$(translate 'Press ENTER to continue...')\\\"; read -r _\"" + ) +fi # ========================================================== @@ -271,6 +288,12 @@ post_install_label() { "Proxmox VE Microcode") echo "Proxmox VE Microcode" ;; + "Proxmox Backup Server Post Install") + echo "Proxmox Backup Server Post Install" + ;; + "Proxmox Backup Server Microcode") + echo "Proxmox Backup Server Microcode" + ;; "Community Scripts") translate "Community Scripts" ;; diff --git a/scripts/menus/pbs_menu.sh b/scripts/menus/pbs_menu.sh index 86f30ca2..99a8d76c 100644 --- a/scripts/menus/pbs_menu.sh +++ b/scripts/menus/pbs_menu.sh @@ -2,8 +2,8 @@ # ========================================================== # ProxMenux - Proxmox Backup Server menu # ========================================================== -# PBS exposes compatible host tools; PVE guest/repository optimizations -# stay in the PVE menu. +# PBS exposes compatible host tools and its own post-install profile; +# PVE guest optimizations stay in the PVE menu. # ========================================================== BASE_DIR="/usr/local/share/proxmenux" LOCAL_SCRIPTS="$BASE_DIR/scripts" @@ -31,20 +31,22 @@ pbs_show() { while true; do choice=$(dialog --clear --backtitle "ProxMenux" --title "Proxmox Backup Server" \ - --menu "$(translate 'Select an option:')" 18 75 8 \ - 1 "$(translate 'PBS version and packages')" \ - 2 "$(translate 'PBS datastores')" \ - 3 "$(translate 'Recent PBS tasks')" \ - 4 "$(translate 'System Utilities Installer')" \ - 5 "$(translate 'Update PBS using configured repositories')" \ + --menu "$(translate 'Select an option:')" 19 75 9 \ + 1 "$(translate "Settings post-install Proxmox")" \ + 2 "$(translate 'PBS version and packages')" \ + 3 "$(translate 'PBS datastores')" \ + 4 "$(translate 'Recent PBS tasks')" \ + 5 "$(translate 'System Utilities Installer')" \ + 6 "$(translate 'Update PBS using configured repositories')" \ s "$(translate 'Settings')" \ 0 "$(translate 'Exit')" 2>&1 >/dev/tty) || break case "$choice" in - 1) pbs_show "$(translate 'PBS version and packages')" proxmox-backup-manager versions --verbose ;; - 2) pbs_show "$(translate 'PBS datastores')" proxmox-backup-manager datastore list ;; - 3) pbs_show "$(translate 'Recent PBS tasks')" proxmox-backup-manager task list --all true --limit 20 ;; - 4) bash "$LOCAL_SCRIPTS/utilities/system_utils.sh" ;; - 5) bash "$LOCAL_SCRIPTS/utilities/pbs_update.sh" ;; + 1) exec bash "$LOCAL_SCRIPTS/menus/menu_post_install.sh" ;; + 2) pbs_show "$(translate 'PBS version and packages')" proxmox-backup-manager versions --verbose ;; + 3) pbs_show "$(translate 'PBS datastores')" proxmox-backup-manager datastore list ;; + 4) pbs_show "$(translate 'Recent PBS tasks')" proxmox-backup-manager task list --all true --limit 20 ;; + 5) bash "$LOCAL_SCRIPTS/utilities/system_utils.sh" ;; + 6) bash "$LOCAL_SCRIPTS/utilities/pbs_update.sh" ;; s) exec bash "$LOCAL_SCRIPTS/menus/config_menu.sh" ;; 0) break ;; esac diff --git a/scripts/post_install/auto_post_install.sh b/scripts/post_install/auto_post_install.sh index 25e6289f..83f84a82 100644 --- a/scripts/post_install/auto_post_install.sh +++ b/scripts/post_install/auto_post_install.sh @@ -59,7 +59,12 @@ initialize_cache if [[ -f "$LOCAL_SCRIPTS/global/platform-functions.sh" ]]; then source "$LOCAL_SCRIPTS/global/platform-functions.sh" - pmx_require_pve || exit 1 +fi +# Proxmox Backup Server runs the same host optimizations; repositories, +# package upgrade and the subscription notice follow its own tooling. +PMX_ON_PBS=false +if declare -F pmx_is_pbs >/dev/null && pmx_is_pbs; then + PMX_ON_PBS=true fi # Global variables @@ -142,6 +147,10 @@ $(translate "Do you want to continue anyway?")" 13 70 apt_upgrade() { + if [[ "$PMX_ON_PBS" == true ]]; then + bash "$LOCAL_SCRIPTS/global/update-pbs.sh" + return + fi local pve_version pve_version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+' | head -1) @@ -171,6 +180,20 @@ apt_upgrade() { remove_subscription_banner() { local FUNC_VERSION="1.0" # description: Patch the Proxmox web UI to suppress the subscription dialog and register a successful patch. + if [[ "$PMX_ON_PBS" == true ]]; then + stop_spinner + if ! whiptail --title "Proxmox Backup Server Subscription Banner Removal" \ + --yesno "$(translate "Do you want to remove the Proxmox subscription banner from the Proxmox Backup Server web interface?")\n\n$(translate "Attention: Removing the subscription banner may cause issues in the web interface after a future update.")\n\n$(translate "If this happens, you can restore the backup from the 'Subscription Banner Removal' option in 'Uninstall optimizations'.")\n\n$(translate "Are you sure you want to continue?")" 14 75; then + msg_warn "Banner removal cancelled by user." + return 1 + fi + if ! bash "$LOCAL_SCRIPTS/global/remove-banner-pve-v3.sh"; then + msg_error "$(translate "Subscription banner removal failed")" + return 1 + fi + register_tool "subscription_banner" true "$FUNC_VERSION" + return 0 + fi local pve_version pve_version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+' | head -1) @@ -225,6 +248,17 @@ setup_proxmox_repositories() { msg_info2 "$(translate "Configuring Proxmox APT repositories...")" + if [[ "$PMX_ON_PBS" == true ]]; then + if ! ensure_pbs_repositories; then + msg_error "$(translate "Failed to configure Proxmox Backup Server repositories")" + register_tool "proxmox_repos" false "$FUNC_VERSION" + return 1 + fi + register_tool "proxmox_repos" true "$FUNC_VERSION" + msg_success "$(translate "Proxmox Backup Server APT repositories configured")" + return 0 + fi + if ! ensure_repositories; then msg_error "$(translate "Failed to configure Proxmox repositories")" register_tool "proxmox_repos" false "$FUNC_VERSION" @@ -524,7 +558,7 @@ force_apt_ipv4() { # ========================================================== apply_network_optimizations() { - local FUNC_VERSION="1.1" + local FUNC_VERSION="1.2" pmx_journal_context "apply_network_optimizations" "$FUNC_VERSION" # description: Tune TCP buffers, somaxconn, IPv4 hardening and disable rp_filter on fw bridges (PVE 9 compatible). msg_info "$(translate "Optimizing network settings...")" @@ -622,14 +656,16 @@ RemainAfterExit=yes WantedBy=multi-user.target EOF - pmx_write_file /etc/udev/rules.d/99-proxmenux-fwbr-tune.rules <<'EOF' + pmx_remove_file /etc/udev/rules.d/99-proxmenux-fwbr-tune.rules + + pmx_write_file /etc/udev/rules.d/99-zz-proxmenux-fwbr-tune.rules <<'EOF' ACTION=="add", SUBSYSTEM=="net", KERNEL=="fwbr*", RUN+="/usr/local/sbin/proxmenux-fwbr-tune %k" ACTION=="add", SUBSYSTEM=="net", KERNEL=="fwln*", RUN+="/usr/local/sbin/proxmenux-fwbr-tune %k" ACTION=="add", SUBSYSTEM=="net", KERNEL=="fwpr*", RUN+="/usr/local/sbin/proxmenux-fwbr-tune %k" ACTION=="add", SUBSYSTEM=="net", KERNEL=="tap*", RUN+="/usr/local/sbin/proxmenux-fwbr-tune %k" EOF - chmod 0644 /etc/udev/rules.d/99-proxmenux-fwbr-tune.rules - chown root:root /etc/udev/rules.d/99-proxmenux-fwbr-tune.rules + chmod 0644 /etc/udev/rules.d/99-zz-proxmenux-fwbr-tune.rules + chown root:root /etc/udev/rules.d/99-zz-proxmenux-fwbr-tune.rules systemctl daemon-reload >/dev/null 2>&1 || true udevadm control --reload-rules >/dev/null 2>&1 || true diff --git a/scripts/post_install/customizable_post_install.sh b/scripts/post_install/customizable_post_install.sh index ca60b752..39274146 100644 --- a/scripts/post_install/customizable_post_install.sh +++ b/scripts/post_install/customizable_post_install.sh @@ -53,7 +53,12 @@ initialize_cache if [[ -f "$LOCAL_SCRIPTS/global/platform-functions.sh" ]]; then source "$LOCAL_SCRIPTS/global/platform-functions.sh" - pmx_require_pve || exit 1 +fi +# On Proxmox Backup Server the menu lists only the options that apply to +# it; repositories, upgrade and the subscription notice use its tooling. +PMX_ON_PBS=false +if declare -F pmx_is_pbs >/dev/null && pmx_is_pbs; then + PMX_ON_PBS=true fi # Load shared global functions @@ -122,6 +127,17 @@ setup_proxmox_repositories() { msg_info2 "$(translate "Configuring Proxmox APT repositories...")" + if [[ "$PMX_ON_PBS" == true ]]; then + if ! ensure_pbs_repositories; then + msg_error "$(translate "Failed to configure Proxmox Backup Server repositories")" + register_tool "proxmox_repos" false "$FUNC_VERSION" + return 1 + fi + register_tool "proxmox_repos" true "$FUNC_VERSION" + msg_success "$(translate "Proxmox Backup Server APT repositories configured")" + return 0 + fi + if ! ensure_repositories; then msg_error "$(translate "Failed to configure Proxmox repositories")" register_tool "proxmox_repos" false "$FUNC_VERSION" @@ -248,6 +264,10 @@ EOF apt_upgrade() { + if [[ "$PMX_ON_PBS" == true ]]; then + bash "$LOCAL_SCRIPTS/global/update-pbs.sh" + return + fi local pve_version pve_version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+' | head -1) @@ -2376,6 +2396,14 @@ EOF remove_subscription_banner() { local FUNC_VERSION="1.0" # description: Patch the Proxmox web UI to suppress the "no valid subscription" dialog (PVE 8 + 9 variants supported). + if [[ "$PMX_ON_PBS" == true ]]; then + if ! bash "$LOCAL_SCRIPTS/global/remove-banner-pve-v3.sh"; then + msg_error "$(translate "Subscription banner removal failed")" + return 1 + fi + register_tool "subscription_banner" true "$FUNC_VERSION" + return 0 + fi local pve_version pve_version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+' | head -1) @@ -3143,8 +3171,11 @@ EOF } - ensure_aliases - msg_ok "$(translate "Aliases added to .bashrc")" + # The aliases call Proxmox VE tools. + if [[ "$PMX_ON_PBS" != true ]]; then + ensure_aliases + msg_ok "$(translate "Aliases added to .bashrc")" + fi msg_success "$(translate "Figurine installation and configuration completed successfully.")" register_tool "figurine" true "$FUNC_VERSION" @@ -3993,6 +4024,19 @@ main_menu() { "Optional|Install and configure Log2RAM|LOG2RAM" ) + if [[ "$PMX_ON_PBS" == true ]]; then + local -A pve_only=( + [GUESTAGENT]=1 [VFIO_IOMMU]=1 [OPENVSWITCH]=1 [VZDUMP]=1 [PVEAM]=1 + [CEPH]=1 [ENABLE_HA]=1 [OVHRTM]=1 [PIGZ]=1 + ) + local pbs_options=() option_key + for option in "${options[@]}"; do + option_key="${option##*|}" + [[ -n "${pve_only[$option_key]:-}" ]] || pbs_options+=("$option") + done + options=("${pbs_options[@]}") + fi + IFS=$'\n' sorted_options=($(for option in "${options[@]}"; do IFS='|' read -r category description function_name <<< "$option" printf "%d|%s|%s|%s\n" "${category_order[$category]:-999}" "$category" "$description" "$function_name" diff --git a/scripts/post_install/uninstall-tools.sh b/scripts/post_install/uninstall-tools.sh index 63b22fa1..1724f849 100644 --- a/scripts/post_install/uninstall-tools.sh +++ b/scripts/post_install/uninstall-tools.sh @@ -266,6 +266,12 @@ uninstall_subscription_banner() { fi fi + # Proxmox Backup Server ships the same widget toolkit without the PVE packages. + local -a ui_packages=(pve-manager proxmox-widget-toolkit libjs-extjs libpve-http-server-perl) + if [[ "$(dpkg-query -W -f='${Status}' proxmox-backup-server 2>/dev/null)" == "install ok installed" ]]; then + ui_packages=(proxmox-widget-toolkit libjs-extjs) + fi + if [[ "$restored" == false ]]; then msg_info "$(translate "Performing complete package reinstallation...")" @@ -274,7 +280,7 @@ uninstall_subscription_banner() { # Reinstall packages with force-confnew to restore original configs if apt-get --reinstall -o Dpkg::Options::="--force-confnew" install \ - pve-manager proxmox-widget-toolkit libjs-extjs libpve-http-server-perl -y >/dev/null 2>&1; then + "${ui_packages[@]}" -y >/dev/null 2>&1; then msg_ok "$(translate "Reinstalled Proxmox packages successfully")" restored=true else @@ -286,7 +292,7 @@ uninstall_subscription_banner() { # Second pass reinstallation to ensure everything is clean apt-get update >/dev/null 2>&1 - apt-get --reinstall install proxmox-widget-toolkit pve-manager libjs-extjs libpve-http-server-perl -y >/dev/null 2>&1 || true + apt-get --reinstall install "${ui_packages[@]}" -y >/dev/null 2>&1 || true fi msg_info "$(translate "Cleaning cached files...")" diff --git a/tests/test_repository_policy_pbs.py b/tests/test_repository_policy_pbs.py new file mode 100644 index 00000000..e0a71d90 --- /dev/null +++ b/tests/test_repository_policy_pbs.py @@ -0,0 +1,103 @@ +"""Fixture-only PBS policy checks: never call host proxmox-backup-* tools or edit host APT sources.""" +import importlib.util +import json +from pathlib import Path +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +POLICY = ROOT / 'scripts/global/repository_policy.py' + + +def repo(components, *, uri='https://enterprise.proxmox.com/debian/pbs', suite='trixie', enabled=True): + return {'Types': ['deb'], 'URIs': [uri], 'Suites': [suite], + 'Components': components.split(), 'Enabled': enabled} + + +class PBSRepositoryPolicyTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + spec = importlib.util.spec_from_file_location('repository_policy', POLICY) + cls.module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(cls.module) + + def setUp(self): + self.calls = [] + self.status = 'notfound' + self.repositories = { + 'files': [ + {'path': '/etc/apt/sources.list.d/pbs-enterprise.sources', 'repositories': [repo('pbs-enterprise')]}, + {'path': '/etc/apt/sources.list.d/debian.sources', + 'repositories': [repo('main contrib', uri='http://deb.debian.org/debian')]}, + ], + 'errors': [], 'digest': 'fixture-digest', + 'standard-repos': [{'handle': 'enterprise'}, {'handle': 'no-subscription'}, {'handle': 'test'}], + } + + def run_policy(self, apply=False): + def run(args): + self.calls.append(args) + if args[:3] == ['proxmox-backup-manager', 'subscription', 'get']: + return json.dumps({'status': self.status, 'serverid': 'FIXTURE-SECRET'}) + self.assertEqual(args[:2], ['proxmox-backup-debug', 'api']) + action = args[2] + if action == 'get': + return json.dumps(self.repositories) + self.assertEqual(args[args.index('--digest') + 1], self.repositories['digest']) + if action == 'create': + path = args[args.index('--path') + 1] + index = int(args[args.index('--index') + 1]) + next(f for f in self.repositories['files'] if f['path'] == path)['repositories'][index]['Enabled'] = False + elif action == 'set': + self.repositories['files'].append({'path': '/etc/apt/sources.list.d/proxmox.sources', + 'repositories': [repo('pbs-no-subscription', uri='http://download.proxmox.com/debian/pbs')]}) + self.repositories['digest'] += '+' + return '' + with patch.object(self.module, 'run', side_effect=run): + return self.module.evaluate('trixie', apply=apply, product='pbs') + + def writes(self): + return [c for c in self.calls if c[:2] == ['proxmox-backup-debug', 'api'] and c[2] in ('create', 'set')] + + def test_fresh_pbs_offers_switch_without_writing(self): + self.assertEqual(self.run_policy(), 'offer') + self.assertEqual(self.writes(), []) + self.assertFalse(any(c[0] in ('pvesh', 'pvesubscription') for c in self.calls)) + + def test_consented_switch_disables_pbs_enterprise_then_adds_no_subscription(self): + self.assertEqual(self.run_policy(apply=True), 'changed') + writes = self.writes() + self.assertEqual([c[2] for c in writes], ['create', 'set']) + self.assertEqual(writes[0][writes[0].index('--path') + 1], '/etc/apt/sources.list.d/pbs-enterprise.sources') + self.assertEqual(writes[0][writes[0].index('--enabled') + 1], '0') + self.assertEqual(writes[1][writes[1].index('--handle') + 1], 'no-subscription') + + def test_active_subscription_preserves_enterprise(self): + self.status = 'active' + self.assertEqual(self.run_policy(apply=True), 'preserve') + self.assertEqual(self.writes(), []) + + def test_existing_no_subscription_is_preserved_without_lookup(self): + self.repositories['files'][0]['repositories'] = [repo('pbs-no-subscription')] + self.assertEqual(self.run_policy(apply=True), 'preserve') + self.assertFalse(any(c[0] == 'proxmox-backup-manager' for c in self.calls)) + + def test_unrecognized_subscription_status_cannot_mutate(self): + self.status = 'invalid' + with self.assertRaisesRegex(ValueError, 'proxmox-backup-manager'): + self.run_policy(apply=True) + self.assertEqual(self.writes(), []) + + def test_pve_channels_do_not_count_as_pbs_sources(self): + self.repositories['files'][0]['repositories'] = [repo('pve-no-subscription')] + self.assertEqual(self.run_policy(), 'offer') + + def test_wrong_suite_points_to_pbs_interface(self): + self.repositories['files'][0]['repositories'] = [repo('pbs-enterprise', suite='bookworm')] + with self.assertRaisesRegex(ValueError, 'Administration > Repositories'): + self.run_policy() + self.assertEqual(self.writes(), []) + + +if __name__ == '__main__': + unittest.main() diff --git a/web/app/[locale]/docs/post-install/page.tsx b/web/app/[locale]/docs/post-install/page.tsx index e4bc8304..89332b87 100644 --- a/web/app/[locale]/docs/post-install/page.tsx +++ b/web/app/[locale]/docs/post-install/page.tsx @@ -89,10 +89,12 @@ export default async function PostInstallPage({ docs: { postInstall: { routes: Route[] related: { items: RelatedItem[] } + pbs?: { items: string[] } } } } const routes = messages.docs.postInstall.routes const relatedItems = messages.docs.postInstall.related.items + const pbsItems = messages.docs.postInstall.pbs?.items ?? [] const code = (chunks: React.ReactNode) => {chunks} const strong = (chunks: React.ReactNode) => {chunks} @@ -210,6 +212,14 @@ export default async function PostInstallPage({ {t.rich("community.body", { em, code, a: communityAnchor, link: externalRepoLink })}

+

{t("pbs.heading")}

+

{t.rich("pbs.body", { strong })}

+
    + {pbsItems.map((_, i) => ( +
  • {t.rich(`pbs.items.${i}`, { strong, em })}
  • + ))} +
+

{t("related.heading")}

    {relatedItems.map((item) => ( diff --git a/web/messages/en/docs/post-install/index.json b/web/messages/en/docs/post-install/index.json index e6e60c1f..46ab94f8 100644 --- a/web/messages/en/docs/post-install/index.json +++ b/web/messages/en/docs/post-install/index.json @@ -78,6 +78,17 @@ "heading": "Community scripts", "body": "The menu also exposes two third-party scripts from the community-scripts project: Proxmox VE Post Install and Proxmox VE Microcode. These are wrappers that wget | bash the original authors' scripts. They are not maintained by ProxMenux. See External Repositories for details and trust considerations before running them." }, + "pbs": { + "heading": "On Proxmox Backup Server", + "body": "On a Proxmox Backup Server host, the same menu opens from Settings post-install Proxmox in the PBS menu. Automated, Customizable, Apply Available Updates and Uninstall Optimizations work as on Proxmox VE, with these differences:", + "items": [ + "Repositories and upgrade: when the server has no subscription and only the Enterprise PBS repository is active, ProxMenux asks before switching to the PBS no-subscription repository. The switch goes through the server's own repository API, the same one behind Administration > Repositories. With an active subscription, or with a no-subscription or test repository already configured, the sources stay as they are.", + "Customizable list: the options that only apply to Proxmox VE are not listed: guest agent, VFIO IOMMU, Open vSwitch, vzdump backup speed, pigz, Appliance Manager, Ceph, High Availability and OVH Real Time Monitoring.", + "Subscription banner: the patch applies to the Proxmox Backup Server web interface, and Uninstall Optimizations restores it.", + "Figurine: installs without the Proxmox VE aliases.", + "Community scripts: the section offers Proxmox Backup Server Post Install and Proxmox Backup Server Microcode in place of the Proxmox VE scripts." + ] + }, "related": { "heading": "Related", "items": [ diff --git a/web/messages/es/docs/post-install/index.json b/web/messages/es/docs/post-install/index.json index 05bd570f..699abbdd 100644 --- a/web/messages/es/docs/post-install/index.json +++ b/web/messages/es/docs/post-install/index.json @@ -78,6 +78,17 @@ "heading": "Community scripts", "body": "El menú también expone dos scripts de terceros del proyecto community-scripts: Proxmox VE Post Install y Proxmox VE Microcode. Son wrappers que hacen wget | bash de los scripts de los autores originales. No los mantiene ProxMenux. Mira External Repositories para detalles y consideraciones de confianza antes de ejecutarlos." }, + "pbs": { + "heading": "En Proxmox Backup Server", + "body": "En un host Proxmox Backup Server, el mismo menú se abre desde Settings post-install Proxmox en el menú de PBS. Automatizado, Personalizable, Apply Available Updates y Uninstall Optimizations funcionan igual que en Proxmox VE, con estas diferencias:", + "items": [ + "Repositorios y actualización: cuando el servidor no tiene suscripción y solo está activo el repositorio Enterprise de PBS, ProxMenux pregunta antes de cambiar al repositorio sin suscripción de PBS. El cambio se hace con la API de repositorios del propio servidor, la misma que usa Administration > Repositories. Con una suscripción activa, o con un repositorio sin suscripción o de pruebas ya configurado, las fuentes se mantienen como están.", + "Lista personalizable: no aparecen las opciones que solo se aplican a Proxmox VE: agente de invitado, VFIO IOMMU, Open vSwitch, velocidad de backups vzdump, pigz, Appliance Manager, Ceph, alta disponibilidad y OVH Real Time Monitoring.", + "Banner de suscripción: el parche se aplica a la interfaz web de Proxmox Backup Server, y Uninstall Optimizations lo restaura.", + "Figurine: se instala sin los alias de Proxmox VE.", + "Scripts de la comunidad: la sección ofrece Proxmox Backup Server Post Install y Proxmox Backup Server Microcode en lugar de los scripts de Proxmox VE." + ] + }, "related": { "heading": "Relacionado", "items": [