mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-07-31 12:56:24 +00:00
new version 1.2.4
This commit is contained in:
@@ -4,6 +4,7 @@ import { GeistSans } from "geist/font/sans"
|
||||
import { GeistMono } from "geist/font/mono"
|
||||
import { ThemeProvider } from "../components/theme-provider"
|
||||
import { PwaRegister } from "../components/pwa-register"
|
||||
import { PwaInstallPrompt } from "../components/pwa-install-prompt"
|
||||
import { Suspense } from "react"
|
||||
import "./globals.css"
|
||||
|
||||
@@ -48,6 +49,7 @@ export default function RootLayout({
|
||||
</ThemeProvider>
|
||||
</Suspense>
|
||||
<PwaRegister />
|
||||
<PwaInstallPrompt />
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
FileText,
|
||||
RefreshCw,
|
||||
Shield,
|
||||
Download,
|
||||
X,
|
||||
Clock,
|
||||
BellOff,
|
||||
@@ -39,6 +40,7 @@ import {
|
||||
Settings2,
|
||||
HelpCircle,
|
||||
} from "lucide-react"
|
||||
import { ScriptTerminalModal } from "./script-terminal-modal"
|
||||
|
||||
interface CategoryCheck {
|
||||
status: string
|
||||
@@ -122,6 +124,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [dismissingKey, setDismissingKey] = useState<string | null>(null)
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set())
|
||||
const [showUpdateTerminal, setShowUpdateTerminal] = useState(false)
|
||||
|
||||
const fetchHealthDetails = useCallback(async () => {
|
||||
setLoading(true)
|
||||
@@ -722,6 +725,23 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
No issues detected
|
||||
</div>
|
||||
)}
|
||||
{/* Only offer "Update Now" when the category is not
|
||||
already OK — hiding it when there's nothing
|
||||
pending prevents the operator from spawning a
|
||||
terminal that would only report "System is
|
||||
already up to date". */}
|
||||
{key === "updates" && status?.toUpperCase() !== "OK" && (
|
||||
<div className="flex justify-end px-3 py-2 pt-1">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setShowUpdateTerminal(true)}
|
||||
className="bg-purple-600/15 hover:bg-purple-600/25 border border-purple-500/40 text-purple-300 hover:text-purple-200"
|
||||
>
|
||||
<Download className="h-4 w-4 mr-1.5" />
|
||||
Update Now
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -848,6 +868,23 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
<ScriptTerminalModal
|
||||
open={showUpdateTerminal}
|
||||
onClose={() => {
|
||||
setShowUpdateTerminal(false)
|
||||
// Refresh health checks so the "System Updates" row updates
|
||||
// (pending count / kernel version) without waiting for the
|
||||
// next polling tick after the update finishes.
|
||||
fetchHealthDetails().catch(() => {})
|
||||
}}
|
||||
scriptPath="/usr/local/share/proxmenux/scripts/utilities/proxmox_update.sh"
|
||||
scriptName="proxmox_update"
|
||||
params={{
|
||||
EXECUTION_MODE: "web",
|
||||
}}
|
||||
title="Proxmox System Update"
|
||||
description="Runs apt-get update + dist-upgrade and post-update cleanup on the host."
|
||||
/>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -271,7 +271,7 @@ export function Login({ onLogin }: LoginProps) {
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground">ProxMenux Monitor v1.2.3</p>
|
||||
<p className="text-center text-sm text-muted-foreground">ProxMenux Monitor v1.2.4</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -836,7 +836,7 @@ export function ProxmoxDashboard() {
|
||||
</Tabs>
|
||||
|
||||
<footer className="mt-8 md:mt-12 pt-4 md:pt-6 border-t border-border text-center text-xs md:text-sm text-muted-foreground">
|
||||
<p className="font-medium mb-2">ProxMenux Monitor v1.2.3</p>
|
||||
<p className="font-medium mb-2">ProxMenux Monitor v1.2.4</p>
|
||||
<p>
|
||||
<a
|
||||
href="https://ko-fi.com/macrimi"
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { Plus, Share, X } from "lucide-react"
|
||||
|
||||
// ==========================================================
|
||||
// PwaInstallPrompt
|
||||
// ==========================================================
|
||||
// Bottom-sheet shown on mobile when the Monitor is opened in
|
||||
// a browser (not launched as an installed PWA). Two variants:
|
||||
// iOS Safari → manual 3-step instructions
|
||||
// Android → generic "browser menu → Add to Home Screen"
|
||||
//
|
||||
// No `beforeinstallprompt` handling: the Monitor no longer
|
||||
// registers a Service Worker (see `pwa-register.tsx`), so
|
||||
// Chromium never fires it. Installation is always manual.
|
||||
//
|
||||
// Never shown on desktop, or when already running standalone.
|
||||
// Dismissal options:
|
||||
// "Not now" → temporary, hidden for 30 days
|
||||
// "Don't show again" → permanent (no expiry)
|
||||
// Backdrop / X → session-only dismiss (reappears on
|
||||
// the next page load)
|
||||
// ==========================================================
|
||||
|
||||
const DISMISSED_FOREVER_KEY = "proxmenux-install-dismissed"
|
||||
const DISMISSED_UNTIL_KEY = "proxmenux-install-dismissed-until"
|
||||
const NOT_NOW_DAYS = 30
|
||||
|
||||
function isMobileDevice(): boolean {
|
||||
if (typeof window === "undefined") return false
|
||||
// Prefer feature detection (coarse pointer + touch) over UA sniffing,
|
||||
// and fall back to UA for the corner case where a mobile browser
|
||||
// reports fine pointer under a desktop-mode toggle.
|
||||
const coarse = window.matchMedia("(pointer: coarse)").matches
|
||||
const ua = navigator.userAgent
|
||||
const uaMobile = /Android|iPhone|iPad|iPod|Mobile|Opera Mini|BlackBerry|IEMobile/i.test(ua)
|
||||
return coarse || uaMobile
|
||||
}
|
||||
|
||||
function isStandalone(): boolean {
|
||||
if (typeof window === "undefined") return false
|
||||
const displayModeStandalone = window.matchMedia("(display-mode: standalone)").matches
|
||||
const iosStandalone = (window.navigator as Navigator & { standalone?: boolean }).standalone === true
|
||||
return displayModeStandalone || iosStandalone
|
||||
}
|
||||
|
||||
function isIOS(): boolean {
|
||||
if (typeof window === "undefined") return false
|
||||
const ua = navigator.userAgent
|
||||
// iPadOS 13+ reports as MacIntel — detect that too when maxTouchPoints > 1.
|
||||
const iPadMasqueradingAsMac =
|
||||
ua.includes("Macintosh") && (navigator as Navigator & { maxTouchPoints?: number }).maxTouchPoints! > 1
|
||||
return /iPhone|iPad|iPod/i.test(ua) || iPadMasqueradingAsMac
|
||||
}
|
||||
|
||||
export function PwaInstallPrompt() {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [platform, setPlatform] = useState<"ios" | "android" | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return
|
||||
if (!isMobileDevice() || isStandalone()) return
|
||||
|
||||
try {
|
||||
if (localStorage.getItem(DISMISSED_FOREVER_KEY) === "1") return
|
||||
const untilRaw = localStorage.getItem(DISMISSED_UNTIL_KEY)
|
||||
if (untilRaw) {
|
||||
const until = Number.parseInt(untilRaw, 10)
|
||||
// Corrupt / non-numeric values fall through and the prompt shows,
|
||||
// which is the safe default.
|
||||
if (Number.isFinite(until) && until > Date.now()) return
|
||||
}
|
||||
} catch {
|
||||
// localStorage unavailable (private mode etc.) — treat as not dismissed.
|
||||
}
|
||||
|
||||
setPlatform(isIOS() ? "ios" : "android")
|
||||
setOpen(true)
|
||||
}, [])
|
||||
|
||||
const handleNotNow = useCallback(() => {
|
||||
try {
|
||||
const until = Date.now() + NOT_NOW_DAYS * 24 * 60 * 60 * 1000
|
||||
localStorage.setItem(DISMISSED_UNTIL_KEY, String(until))
|
||||
} catch {
|
||||
// Best-effort; if localStorage fails the user will see the prompt
|
||||
// again next visit, which is the safe default.
|
||||
}
|
||||
setOpen(false)
|
||||
}, [])
|
||||
|
||||
const handleNeverAgain = useCallback(() => {
|
||||
try {
|
||||
localStorage.setItem(DISMISSED_FOREVER_KEY, "1")
|
||||
} catch {
|
||||
// Best-effort; if localStorage fails the user will see the prompt
|
||||
// again next visit, which is the safe default.
|
||||
}
|
||||
setOpen(false)
|
||||
}, [])
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
// Session-only dismiss: closing via X or backdrop does NOT persist,
|
||||
// so the prompt reappears on the next page load. Users who want to
|
||||
// silence it for longer must use "Not now" (30 d) or "Don't show again".
|
||||
setOpen(false)
|
||||
}, [])
|
||||
|
||||
if (!open || !platform) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="pwa-install-title"
|
||||
className="fixed inset-0 z-[100] flex items-end justify-center bg-black/60 backdrop-blur-sm animate-in fade-in duration-200"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) handleClose()
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-md rounded-t-2xl bg-background text-foreground shadow-2xl border-t border-border animate-in slide-in-from-bottom duration-300"
|
||||
style={{ paddingBottom: "max(1.25rem, env(safe-area-inset-bottom))" }}
|
||||
>
|
||||
<div className="relative px-5 pt-5">
|
||||
<div className="mx-auto mb-3 h-1 w-10 rounded-full bg-border" aria-hidden="true" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
aria-label="Close"
|
||||
className="absolute right-3 top-3 flex h-8 w-8 items-center justify-center rounded-full text-muted-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<div className="mb-4 flex items-start gap-3.5">
|
||||
<div className="flex h-[52px] w-[52px] shrink-0 items-center justify-center rounded-xl bg-muted p-1 shadow-md">
|
||||
<img src="/icon.svg" alt="ProxMenux Monitor" className="h-full w-full object-contain" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 id="pwa-install-title" className="text-[17px] font-bold leading-tight tracking-tight text-foreground">
|
||||
Install ProxMenux Monitor
|
||||
</h3>
|
||||
<p className="mt-1 text-[13px] leading-snug text-muted-foreground">
|
||||
{platform === "ios"
|
||||
? "Add the Monitor to your home screen for quick access."
|
||||
: "Add the Monitor as an app to launch it like a native application."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{platform === "ios" ? (
|
||||
<ol className="mb-4 flex flex-col gap-2" role="list">
|
||||
<li className="flex items-center gap-3 rounded-xl bg-primary/10 px-3.5 py-3 text-[13.5px] leading-tight">
|
||||
<span className="flex h-[22px] w-[22px] shrink-0 items-center justify-center rounded-full bg-primary text-[11px] font-bold text-primary-foreground">
|
||||
1
|
||||
</span>
|
||||
<span>
|
||||
Tap the{" "}
|
||||
<span className="inline-flex items-center gap-1 font-semibold text-primary">
|
||||
<Share className="h-4 w-4" aria-hidden="true" />
|
||||
Share
|
||||
</span>{" "}
|
||||
button in the bottom bar
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-3 rounded-xl bg-primary/10 px-3.5 py-3 text-[13.5px] leading-tight">
|
||||
<span className="flex h-[22px] w-[22px] shrink-0 items-center justify-center rounded-full bg-primary text-[11px] font-bold text-primary-foreground">
|
||||
2
|
||||
</span>
|
||||
<span>
|
||||
Choose{" "}
|
||||
<span className="inline-flex items-center gap-1 font-semibold text-primary">
|
||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||
Add to Home Screen
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-3 rounded-xl bg-primary/10 px-3.5 py-3 text-[13.5px] leading-tight">
|
||||
<span className="flex h-[22px] w-[22px] shrink-0 items-center justify-center rounded-full bg-primary text-[11px] font-bold text-primary-foreground">
|
||||
3
|
||||
</span>
|
||||
<span>
|
||||
Confirm by tapping <b>Add</b> in the top-right
|
||||
</span>
|
||||
</li>
|
||||
</ol>
|
||||
) : (
|
||||
<div className="mb-4 rounded-lg border border-border bg-muted/50 px-3.5 py-3 text-[13px] leading-relaxed text-muted-foreground">
|
||||
Open the browser menu <b className="text-foreground">⋮</b> →{" "}
|
||||
<b className="text-foreground">Add to Home Screen</b> → confirm by tapping{" "}
|
||||
<b className="text-foreground">Install</b>.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-1 flex flex-col gap-1 border-t border-border pt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNotNow}
|
||||
className="rounded-lg py-2.5 text-center text-[13.5px] font-semibold text-muted-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
Not now
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNeverAgain}
|
||||
className="rounded-lg py-2.5 text-center text-[13.5px] font-semibold text-amber-700 dark:text-amber-500 hover:bg-muted transition-colors"
|
||||
>
|
||||
Don't show again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,38 +2,27 @@
|
||||
|
||||
import { useEffect } from "react"
|
||||
|
||||
// ===========================================================
|
||||
// PwaRegister
|
||||
// ===========================================================
|
||||
// Registers /sw.js once on mount. Chrome (Android) only
|
||||
// surfaces the "Install app" prompt when a service worker
|
||||
// is active, so even though the SW itself does nothing
|
||||
// (network-only, no caching), its mere presence flips the
|
||||
// PWA-installability check from no-to-yes.
|
||||
//
|
||||
// Mounted from app/layout.tsx so it runs on every route.
|
||||
// ===========================================================
|
||||
// Unregisters any Service Worker registered against this origin on
|
||||
// mount. Having a SW active on the Monitor origin caused a mobile
|
||||
// dashboard-freeze bug over HTTPS + reverse proxy (setInterval polls
|
||||
// stopped firing real fetches under battery throttling). Confirmed
|
||||
// as the sole cause by the affected reporter on Brave and Firefox
|
||||
// Android. `sw.js` is kept in the tree so PWA offline support can
|
||||
// be revisited later without archaeology.
|
||||
export function PwaRegister() {
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return
|
||||
if (!("serviceWorker" in navigator)) return
|
||||
// Wait for the load event to avoid competing with the
|
||||
// initial render — the SW registration is not on the
|
||||
// critical render path.
|
||||
const register = () => {
|
||||
navigator.serviceWorker
|
||||
.register("/sw.js", { scope: "/" })
|
||||
.catch((err) => {
|
||||
// Surface the failure only in DevTools — silent in prod.
|
||||
console.warn("[pwa] service worker registration failed:", err)
|
||||
})
|
||||
}
|
||||
if (document.readyState === "complete") {
|
||||
register()
|
||||
} else {
|
||||
window.addEventListener("load", register, { once: true })
|
||||
return () => window.removeEventListener("load", register)
|
||||
}
|
||||
navigator.serviceWorker
|
||||
.getRegistrations()
|
||||
.then((regs) => {
|
||||
if (regs.length === 0) return
|
||||
return Promise.all(regs.map((r) => r.unregister()))
|
||||
})
|
||||
.catch(() => {
|
||||
// Best-effort — private browsing or a locked-down browser
|
||||
// may reject the API; nothing to do.
|
||||
})
|
||||
}, [])
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Dialog, DialogContent, DialogTitle } from "./ui/dialog"
|
||||
import { X, Sparkles, Thermometer, Activity, HardDrive, Shield, Globe, Cpu, Zap, Sliders, Wrench, RefreshCw, Server, BellOff, Bell, Calendar, DatabaseBackup } from "lucide-react"
|
||||
import { Checkbox } from "./ui/checkbox"
|
||||
|
||||
const APP_VERSION = "1.2.3" // Sync with AppImage/package.json
|
||||
const APP_VERSION = "1.2.4" // Sync with AppImage/package.json
|
||||
|
||||
interface ReleaseNote {
|
||||
date: string
|
||||
@@ -18,6 +18,21 @@ interface ReleaseNote {
|
||||
}
|
||||
|
||||
export const CHANGELOG: Record<string, ReleaseNote> = {
|
||||
"1.2.3": {
|
||||
date: "July 15, 2026",
|
||||
changes: {
|
||||
added: [
|
||||
"Backups integrated in the Monitor — a new first-class section to create, schedule and restore host backups against Local, PBS or Borg destinations from the Web dashboard. Jobs run on a proper systemd timer or attach to an existing PVE vzdump job with retention live-inherited from the parent. Encrypted PBS backups store a paired recovery blob next to each snapshot so a fresh install can always get the key back. After a reboot the tab shows a real-time restore progress card with milestones, per-component status (NVIDIA, Intel GPU tools, Coral, AMD tools), boot sanity warnings and a rollback delta listing anything on the host that wasn't in the backup.",
|
||||
"Network Flow diagram — a new live topology view on the Network tab showing NICs → host → bridges → LXCs / VMs with animated rx / tx pulses on every internal link, so the operator can see in real time how traffic distributes inside the host and which guests are pulling or pushing data.",
|
||||
"Physical Disks and Physical Interfaces cards redesigned — clearer per-item presentation on the Storage and Network tabs. USB-NVMe / USB-SATA enclosures reporting removable=0 (ASMedia, JMicron, Realtek, ASM105x) now walk sysfs to detect USB attachment, so the -d snt* pass-through is tried and the drive's real model, serial, temperature, power-on hours and health surface — instead of the bridge's chatter.",
|
||||
"Richer notifications out of the box — for users not running an AI agent, the templated body now identifies the affected object (which storage, which interface, which container), surfaces the top offenders with an \"…and N more\" tail when the list is long, and preserves the same identity in the recovery message. Users with AI enrichment enabled continue to get their tailored rewrite on top of this improved base.",
|
||||
],
|
||||
changed: [
|
||||
"Redesigned cards across Overview, VM / LXC, Storage and Network — layouts reworked for faster reading and denser, more practical information: key numbers surface at a glance, grouped by relevance, and the responsive grid now behaves cleanly from a phone up to an ultrawide.",
|
||||
"Health Monitor Thresholds — the Settings panel that controls per-category Warning and Critical levels (CPU, memory, temperature, storage, disks, ...) was reworked with clearer visual grouping and inline hints, so tuning a threshold now takes a couple of clicks instead of scrolling through a wall of numbers.",
|
||||
],
|
||||
},
|
||||
},
|
||||
"1.2.2": {
|
||||
date: "May 31, 2026",
|
||||
changes: {
|
||||
@@ -217,28 +232,12 @@ export const CHANGELOG: Record<string, ReleaseNote> = {
|
||||
|
||||
const CURRENT_VERSION_FEATURES = [
|
||||
{
|
||||
icon: <DatabaseBackup className="h-5 w-5" />,
|
||||
text: "Backups integrated in the Monitor — a new first-class section to create, schedule and restore host backups against Local, PBS or Borg destinations from the Web dashboard. Jobs run on a proper systemd timer or attach to an existing PVE vzdump job with retention live-inherited from the parent. Encrypted PBS backups store a paired recovery blob next to each snapshot so a fresh install can always get the key back. After a reboot the tab shows a real-time restore progress card with milestones, per-component status (NVIDIA, Intel GPU tools, Coral, AMD tools), boot sanity warnings and a rollback delta listing anything on the host that wasn't in the backup.",
|
||||
},
|
||||
{
|
||||
icon: <Activity className="h-5 w-5" />,
|
||||
text: "Network Flow diagram — a new live topology view on the Network tab showing NICs → host → bridges → LXCs / VMs with animated rx / tx pulses on every internal link, so the operator can see in real time how traffic distributes inside the host and which guests are pulling or pushing data.",
|
||||
icon: <RefreshCw className="h-5 w-5" />,
|
||||
text: "One-click host update from the Health Monitor — new Update Now button in the System Updates section runs the Proxmox update flow in an in-dashboard terminal, without leaving the browser.",
|
||||
},
|
||||
{
|
||||
icon: <Sparkles className="h-5 w-5" />,
|
||||
text: "Redesigned cards across Overview, VM / LXC, Storage and Network — layouts reworked for faster reading and denser, more practical information: key numbers surface at a glance, grouped by relevance, and the responsive grid now behaves cleanly from a phone up to an ultrawide.",
|
||||
},
|
||||
{
|
||||
icon: <HardDrive className="h-5 w-5" />,
|
||||
text: "Physical Disks and Physical Interfaces cards redesigned — clearer per-item presentation on the Storage and Network tabs. USB-NVMe / USB-SATA enclosures reporting removable=0 (ASMedia, JMicron, Realtek, ASM105x) now walk sysfs to detect USB attachment, so the -d snt* pass-through is tried and the drive's real model, serial, temperature, power-on hours and health surface — instead of the bridge's chatter.",
|
||||
},
|
||||
{
|
||||
icon: <Sliders className="h-5 w-5" />,
|
||||
text: "Health Monitor Thresholds — the Settings panel that controls per-category Warning and Critical levels (CPU, memory, temperature, storage, disks, ...) was reworked with clearer visual grouping and inline hints, so tuning a threshold now takes a couple of clicks instead of scrolling through a wall of numbers.",
|
||||
},
|
||||
{
|
||||
icon: <Bell className="h-5 w-5" />,
|
||||
text: "Richer notifications out of the box — for users not running an AI agent, the templated body now identifies the affected object (which storage, which interface, which container), surfaces the top offenders with an \"…and N more\" tail when the list is long, and preserves the same identity in the recovery message. Users with AI enrichment enabled continue to get their tailored rewrite on top of this improved base.",
|
||||
text: "In-app Install prompt for mobile — first-time visitors on Android and iOS Safari now see a bottom-sheet with clear steps for adding the Monitor to their home screen as a PWA.",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -3717,7 +3717,7 @@ ${observationsHtml}
|
||||
<!-- Footer -->
|
||||
<div class="rpt-footer">
|
||||
<div>Report generated by ProxMenux Monitor</div>
|
||||
<div>ProxMenux Monitor v1.2.3</div>
|
||||
<div>ProxMenux Monitor v1.2.4</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ProxMenux-Monitor",
|
||||
"version": "1.2.3",
|
||||
"version": "1.2.4",
|
||||
"description": "Proxmox System Monitoring Dashboard",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -101,7 +101,14 @@ def acknowledge_error():
|
||||
'security': 'security_check',
|
||||
'temperature': 'cpu_check',
|
||||
'network': 'network_check',
|
||||
# Both 'disks' (kept for compat) and 'storage' land on the
|
||||
# same cache — the two categories share `storage_check` in
|
||||
# health_monitor. Without the 'storage' entry, dismissing
|
||||
# a storage_unavailable / mount_stale / lxc_mount_low
|
||||
# error persisted but never invalidated the cache, so the
|
||||
# error stayed visible in the next fetch.
|
||||
'disks': 'storage_check',
|
||||
'storage': 'storage_check',
|
||||
'vms': 'vms_check',
|
||||
}
|
||||
cache_key = cache_key_map.get(category)
|
||||
|
||||
@@ -3614,26 +3614,32 @@ class HealthMonitor:
|
||||
if health_persistence.check_vm_running(vm_id):
|
||||
continue # Error auto-resolved if VM is now running
|
||||
|
||||
# Still active, add to details
|
||||
# Still active, add to details. `details` may be persisted
|
||||
# as SQL NULL / JSON null → deserializes to Python None, and
|
||||
# `dict.get('details', {})` returns None (not `{}`) in that
|
||||
# case. Coalesce explicitly to avoid `NoneType has no
|
||||
# attribute 'get'` (issue #255 in 1.2.3).
|
||||
details = error.get('details') or {}
|
||||
vm_details[error_key] = {
|
||||
'status': error['severity'],
|
||||
'reason': error['reason'],
|
||||
'id': error.get('details', {}).get('id', 'unknown'),
|
||||
'type': error.get('details', {}).get('type', 'VM/CT'),
|
||||
'id': details.get('id', 'unknown'),
|
||||
'type': details.get('type', 'VM/CT'),
|
||||
'first_seen': error['first_seen'],
|
||||
'dismissed': False,
|
||||
}
|
||||
issues.append(f"{error.get('details', {}).get('type', 'VM')} {error.get('details', {}).get('id', '')}: {error['reason']}")
|
||||
|
||||
issues.append(f"{details.get('type', 'VM')} {details.get('id', '')}: {error['reason']}")
|
||||
|
||||
# Process dismissed errors (show as INFO)
|
||||
for error in dismissed_vm_errors:
|
||||
error_key = error['error_key']
|
||||
if error_key not in vm_details: # Don't overwrite active errors
|
||||
details = error.get('details') or {}
|
||||
vm_details[error_key] = {
|
||||
'status': 'INFO',
|
||||
'reason': error['reason'],
|
||||
'id': error.get('details', {}).get('id', 'unknown'),
|
||||
'type': error.get('details', {}).get('type', 'VM/CT'),
|
||||
'id': details.get('id', 'unknown'),
|
||||
'type': details.get('type', 'VM/CT'),
|
||||
'first_seen': error['first_seen'],
|
||||
'dismissed': True,
|
||||
}
|
||||
|
||||
@@ -918,11 +918,22 @@ class HealthPersistence:
|
||||
# Try to infer category from the error_key prefix.
|
||||
category = ''
|
||||
# Order matters: more specific prefixes MUST come before shorter ones
|
||||
# e.g. 'security_updates' (updates) before 'security_' (security)
|
||||
# e.g. 'security_updates' (updates) before 'security_' (security),
|
||||
# and 'lxc_disk_low_' / 'zfs_pool_full_' (storage) before the shorter
|
||||
# 'disk_' / 'zfs_pool_' fallbacks that map to 'disks'.
|
||||
for cat, prefix in [('updates', 'security_updates'), ('updates', 'system_age'),
|
||||
('updates', 'pending_updates'), ('updates', 'kernel_pve'),
|
||||
('security', 'security_'),
|
||||
('pve_services', 'pve_service_'), ('vms', 'vmct_'), ('vms', 'vm_'), ('vms', 'ct_'),
|
||||
# ── Storage keys — HealthMonitor emits these under `storage` category
|
||||
# but they used to fall through to 'general' here because no prefix
|
||||
# matched, breaking the Dismiss flow (the acknowledge would persist
|
||||
# but the storage cache wouldn't be invalidated because the ack was
|
||||
# tagged with the wrong category).
|
||||
('storage', 'storage_unavailable_'), ('storage', 'mount_stale'),
|
||||
('storage', 'mount_readonly'), ('storage', 'lxc_disk_low_'),
|
||||
('storage', 'lxc_mount_low_'), ('storage', 'pve_storage_full_'),
|
||||
('storage', 'zfs_pool_full_'),
|
||||
('disks', 'disk_smart_'), ('disks', 'disk_'), ('disks', 'smart_'), ('disks', 'zfs_pool_'),
|
||||
('logs', 'log_'), ('network', 'net_'),
|
||||
('temperature', 'temp_')]:
|
||||
|
||||
@@ -983,6 +983,10 @@ class EmailChannel(NotificationChannel):
|
||||
elif group == 'backup':
|
||||
_add('VM/CT ID', data.get('vmid'), 'code')
|
||||
_add('Name', data.get('vmname'), 'bold')
|
||||
# Storage / destination — the piece a multi-PBS operator needs to
|
||||
# tell which target the backup ran against. Reported gap: emails
|
||||
# showed no way to distinguish which PBS failed with 2+ configured.
|
||||
_add('Storage', data.get('storage') or data.get('storage_name'), 'code')
|
||||
_add('Status', 'Failed' if 'fail' in event_type else 'Completed' if 'complete' in event_type else 'Started',
|
||||
'severity' if 'fail' in event_type else '')
|
||||
_add('Size', data.get('size'))
|
||||
@@ -1082,7 +1086,11 @@ class EmailChannel(NotificationChannel):
|
||||
)
|
||||
rows.append((esc('Important Packages'), pkg_html))
|
||||
_add('Current Version', data.get('current_version'), 'code')
|
||||
_add('New Version', data.get('new_version'), 'code')
|
||||
# `new_version` is the field used by generic package-update events;
|
||||
# driver-update templates (nvidia, coral) populate `latest_version`.
|
||||
# Read both so the tabular row is never empty when the template's
|
||||
# title/body already printed the new version.
|
||||
_add('New Version', data.get('new_version') or data.get('latest_version'), 'code')
|
||||
|
||||
# ── Other / unknown ──
|
||||
else:
|
||||
|
||||
@@ -396,6 +396,74 @@ def is_vzdump_active_on_host() -> bool:
|
||||
return found
|
||||
|
||||
|
||||
# ─── APT / dpkg activity gate ────────────────────────────────────
|
||||
# PVE services (pve-cluster, pveproxy, pvedaemon, corosync…) are
|
||||
# routinely killed and restarted as part of a package upgrade, so
|
||||
# every full-upgrade produces a burst of `service_fail` events that
|
||||
# are entirely expected. We gate `_check_service_failure` on this
|
||||
# helper so those events are silently dropped while apt is running
|
||||
# (plus a short grace window after it exits).
|
||||
|
||||
_APT_ACTIVE_MARKER = '/var/run/proxmenux-update-in-progress'
|
||||
_APT_FINISHED_MARKER = '/var/run/proxmenux-update-just-finished'
|
||||
_APT_GRACE_SECONDS = 60
|
||||
_DPKG_LOCK_FILE = '/var/lib/dpkg/lock-frontend'
|
||||
_APT_ACTIVE_CACHE_TTL = 3.0
|
||||
_apt_active_cache_ts = 0.0
|
||||
_apt_active_cache_value = False
|
||||
|
||||
|
||||
def is_apt_active_on_host() -> bool:
|
||||
"""Return True while apt/dpkg is running on this host.
|
||||
|
||||
Sources checked, in order:
|
||||
1. `/var/run/proxmenux-update-in-progress` — created by
|
||||
`scripts/utilities/proxmox_update.sh` around its full-upgrade
|
||||
call so ProxMenux-driven updates are always covered.
|
||||
2. `fuser` on `/var/lib/dpkg/lock-frontend` — covers a manual
|
||||
`apt`/`dpkg`/`apt-get` invocation by the operator, or any
|
||||
other tool holding the lock.
|
||||
3. Grace window (60 s) after `/var/run/proxmenux-update-just-finished`
|
||||
was touched — catches the tail of service_fail events that
|
||||
only reach the journal shortly after apt itself exits.
|
||||
|
||||
Cached 3 s so a burst of journal events doesn't spawn a fuser
|
||||
subprocess per line. Caller-safe: returns False on any error.
|
||||
"""
|
||||
global _apt_active_cache_ts, _apt_active_cache_value
|
||||
now = time.time()
|
||||
if now - _apt_active_cache_ts < _APT_ACTIVE_CACHE_TTL:
|
||||
return _apt_active_cache_value
|
||||
|
||||
active = False
|
||||
try:
|
||||
if os.path.exists(_APT_ACTIVE_MARKER):
|
||||
active = True
|
||||
elif os.path.exists(_APT_FINISHED_MARKER):
|
||||
try:
|
||||
if now - os.path.getmtime(_APT_FINISHED_MARKER) < _APT_GRACE_SECONDS:
|
||||
active = True
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
if not active:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['fuser', _DPKG_LOCK_FILE],
|
||||
capture_output=True, timeout=2,
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
active = True
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
||||
pass
|
||||
except Exception:
|
||||
active = False
|
||||
|
||||
_apt_active_cache_ts = now
|
||||
_apt_active_cache_value = active
|
||||
return active
|
||||
|
||||
|
||||
# ─── Journal Watcher (Real-time) ─────────────────────────────────
|
||||
|
||||
class JournalWatcher:
|
||||
@@ -801,7 +869,7 @@ class JournalWatcher:
|
||||
if re.search(pattern, msg, re.IGNORECASE):
|
||||
entity = 'node'
|
||||
entity_id = ''
|
||||
|
||||
|
||||
# Build a context-rich reason from the journal message.
|
||||
enriched = reason
|
||||
|
||||
@@ -959,7 +1027,7 @@ class JournalWatcher:
|
||||
enriched = f"{reason}\n{msg[:300]}"
|
||||
|
||||
data = {'reason': enriched, 'hostname': self._hostname}
|
||||
|
||||
|
||||
self._emit(event_type, severity, data, entity=entity, entity_id=entity_id)
|
||||
return
|
||||
|
||||
@@ -1051,6 +1119,14 @@ class JournalWatcher:
|
||||
|
||||
def _check_service_failure(self, msg: str, unit: str):
|
||||
"""Detect critical service failures with enriched context."""
|
||||
# Skip while apt/dpkg is running. PVE services (pve-cluster,
|
||||
# pveproxy, pvedaemon, corosync…) get killed and restarted as a
|
||||
# normal part of every package upgrade, so their `service_fail`
|
||||
# events during that window are expected noise, not real
|
||||
# failures. See `is_apt_active_on_host()` for detection details.
|
||||
if is_apt_active_on_host():
|
||||
return
|
||||
|
||||
# Filter out noise -- these are normal systemd transient units,
|
||||
# not real service failures worth alerting about.
|
||||
_NOISE_PATTERNS = [
|
||||
@@ -1800,7 +1876,51 @@ class TaskWatcher:
|
||||
except Exception as e:
|
||||
# Log error for debugging but return status as fallback
|
||||
return status
|
||||
|
||||
|
||||
def _get_task_context(self, upid: str, task_type: str) -> dict:
|
||||
"""Extract task-type-specific fields from a task log.
|
||||
|
||||
For snapshots and migrations the interesting metadata (snapshot
|
||||
name, target node) lives inside the task log body — not in the
|
||||
UPID itself. Without it, `snapshot_complete` bodies render as
|
||||
`Snapshot "" created` and `migration_complete` as `... migrated
|
||||
to node .` — both real rendering bugs.
|
||||
"""
|
||||
wanted = None
|
||||
if task_type in ('qmsnapshot', 'vzsnapshot'):
|
||||
wanted = 'snapshot'
|
||||
elif task_type in ('qmigrate', 'vzmigrate'):
|
||||
wanted = 'migration'
|
||||
if wanted is None:
|
||||
return {}
|
||||
try:
|
||||
parts = upid.split(':')
|
||||
if len(parts) < 5:
|
||||
return {}
|
||||
starttime_hex = parts[4]
|
||||
if not starttime_hex:
|
||||
return {}
|
||||
subdir = starttime_hex[-1].upper()
|
||||
log_path = os.path.join(self.TASK_DIR, subdir, upid)
|
||||
if not os.path.exists(log_path):
|
||||
return {}
|
||||
with open(log_path, 'r', errors='replace') as f:
|
||||
head = ''.join(f.readline() for _ in range(30))
|
||||
ctx: dict = {}
|
||||
if wanted == 'snapshot':
|
||||
m = (re.search(r"snapshot\s+['\"]([^'\"\n]{1,80})['\"]", head, re.IGNORECASE)
|
||||
or re.search(r"snapshot(?:\s+name)?\s+([A-Za-z0-9._\-]{1,80})", head, re.IGNORECASE))
|
||||
if m:
|
||||
ctx['snapshot_name'] = m.group(1).strip()
|
||||
elif wanted == 'migration':
|
||||
m = (re.search(r"to\s+node\s+([A-Za-z0-9._\-]{1,60})", head, re.IGNORECASE)
|
||||
or re.search(r"migration to\s+(?:node\s+)?([A-Za-z0-9._\-]{1,60})", head, re.IGNORECASE))
|
||||
if m:
|
||||
ctx['target_node'] = m.group(1).strip()
|
||||
return ctx
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
# Map PVE task types to our event types
|
||||
TASK_MAP = {
|
||||
'qmstart': ('vm_start', 'INFO'),
|
||||
@@ -2111,16 +2231,21 @@ class TaskWatcher:
|
||||
reason = self._get_task_log_reason(upid, status)
|
||||
else:
|
||||
reason = ''
|
||||
|
||||
|
||||
# Populate task-type-specific fields (target_node for migrations,
|
||||
# snapshot_name for snapshots) so the corresponding template bodies
|
||||
# don't render "to node ." or `Snapshot ""`.
|
||||
ctx = self._get_task_context(upid, task_type)
|
||||
|
||||
data = {
|
||||
'vmid': vmid,
|
||||
'vmname': vmname or f'ID {vmid}',
|
||||
'hostname': self._hostname,
|
||||
'user': user,
|
||||
'reason': reason,
|
||||
'target_node': '',
|
||||
'target_node': ctx.get('target_node', ''),
|
||||
'size': '',
|
||||
'snapshot_name': '',
|
||||
'snapshot_name': ctx.get('snapshot_name', ''),
|
||||
}
|
||||
|
||||
# Determine entity type from task type
|
||||
@@ -3977,7 +4102,14 @@ class ProxmoxHookWatcher:
|
||||
dur_m = re.search(r'Total running time:\s*(.+?)(?:\n|$)', message)
|
||||
if dur_m:
|
||||
data['duration'] = dur_m.group(1).strip()
|
||||
|
||||
# Extract storage / destination from the "starting new backup job" line
|
||||
# so the notification (email HTML, Telegram title) can show which
|
||||
# PBS / storage the backup landed on. Operators with multiple PBS
|
||||
# targets need this to diagnose which destination failed.
|
||||
storage_m = re.search(r'--storage\s+(\S+)', message)
|
||||
if storage_m:
|
||||
data['storage'] = storage_m.group(1).strip()
|
||||
|
||||
# Capture journal context for critical/warning events (helps AI provide better context)
|
||||
if severity in ('CRITICAL', 'WARNING') and event_type not in ('backup_complete', 'update_available'):
|
||||
# Build keywords from available data for journal search
|
||||
|
||||
@@ -646,22 +646,22 @@ TEMPLATES = {
|
||||
'default_enabled': False,
|
||||
},
|
||||
'backup_complete': {
|
||||
'title': '{hostname}: Backup complete — {vmname} ({vmid})',
|
||||
'body': 'Backup of {vmname} (ID: {vmid}) completed successfully.\nSize: {size}',
|
||||
'title': '{hostname} → {storage}: Backup complete — {vmname} ({vmid})',
|
||||
'body': 'Backup of {vmname} (ID: {vmid}) completed successfully on {storage}.\nSize: {size}',
|
||||
'label': 'Backup complete',
|
||||
'group': 'backup',
|
||||
'default_enabled': True,
|
||||
},
|
||||
'backup_warning': {
|
||||
'title': '{hostname}: Backup complete with warnings — {vmname} ({vmid})',
|
||||
'body': 'Backup of {vmname} (ID: {vmid}) completed but encountered warnings.\nWarnings: {reason}',
|
||||
'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)',
|
||||
'group': 'backup',
|
||||
'default_enabled': True,
|
||||
},
|
||||
'backup_fail': {
|
||||
'title': '{hostname}: Backup FAILED — {vmname} ({vmid})',
|
||||
'body': 'Backup of {vmname} (ID: {vmid}) failed.\nReason: {reason}',
|
||||
'title': '{hostname} → {storage}: Backup FAILED — {vmname} ({vmid})',
|
||||
'body': 'Backup of {vmname} (ID: {vmid}) failed on {storage}.\nReason: {reason}',
|
||||
'label': 'Backup FAILED',
|
||||
'group': 'backup',
|
||||
'default_enabled': True,
|
||||
|
||||
Reference in New Issue
Block a user