mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-07-27 02:48: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"
|
||||
|
||||
217
AppImage/components/pwa-install-prompt.tsx
Normal file
217
AppImage/components/pwa-install-prompt.tsx
Normal file
@@ -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,
|
||||
|
||||
58
CHANGELOG.md
58
CHANGELOG.md
@@ -1,4 +1,62 @@
|
||||
|
||||
## 2026-07-17
|
||||
|
||||
### New version ProxMenux v1.2.4
|
||||
|
||||
This release adds two in-dashboard improvements — a one-click Proxmox update trigger from the Health Monitor and a mobile PWA install prompt — and closes seven notification content/delivery fixes, the mobile dashboard freeze over HTTPS + reverse proxy that slipped into 1.2.3, and two Health panel bugs reported within hours of that release.
|
||||
|
||||
---
|
||||
|
||||
## 🩺 Update Now button in Health Monitor
|
||||
|
||||
- New **Update Now** button inside the Health Monitor modal, under the **System Updates** section.
|
||||
- Runs the standard Proxmox update flow (`apt update` + `dist-upgrade` + post-update cleanup) in an in-dashboard terminal — no need to open a shell.
|
||||
- Only appears when updates are pending; when the system is up to date the button stays hidden.
|
||||
- On close, the Health Monitor refreshes so the pending-update count and kernel row update immediately.
|
||||
- The underlying script is context-aware: on an already-configured production host it respects the operator's custom repositories (never disables enterprise/ceph, never deletes legacy sources, never purges alternate NTP, never force-installs zfsutils/chrony); on a bare host it lays down only the missing base repos. It also detects a newly installed kernel that isn't the running one and prompts for reboot at the end.
|
||||
- During the upgrade, `service_fail` notifications for PVE services (pve-cluster, pveproxy, corosync…) are suppressed — their restart is a normal part of the upgrade cycle. Suppression extends 60 s past apt exit so the trailing restart events don't leak through.
|
||||
|
||||
---
|
||||
|
||||
## 📱 In-app Install prompt for mobile
|
||||
|
||||
- First-time visitors on **Android** (Chrome / Brave) and **iOS Safari** now see a bottom-sheet with clear instructions for adding the Monitor as a PWA to their home screen.
|
||||
- Two dismissal levels: **Not now** (temporary, reappears in 30 days) and **Don't show again** (permanent, stored in `localStorage`).
|
||||
- Never shown on desktop, or once the Monitor is already running standalone.
|
||||
|
||||
---
|
||||
|
||||
## 🔔 Notification content — five rendering fixes
|
||||
|
||||
- **Backup destination now visible** — VM/CT backup emails and Telegram messages include the storage / PBS target in both the title and body row, so operators with multiple backup destinations can tell at a glance which one succeeded or failed.
|
||||
- **Migration bodies no longer render `migrated to node .`** — the actual target node is pulled from the PVE task log for `qmigrate` / `vzmigrate` events.
|
||||
- **Snapshot bodies no longer render `Snapshot "" created`** — the real snapshot name is pulled from the PVE task log for `qmsnapshot` / `vzsnapshot`.
|
||||
- **Generic `system_problem` notifications carry the real reason** — PVE messages that fall into the generic bucket include the original payload body, replacing the useless *"A system-level problem has been detected."*
|
||||
- **NVIDIA / Coral driver update emails show the new version** — the *New Version* row in the HTML body used to render empty due to a template/renderer field-name mismatch (`latest_version` vs `new_version`). Fixed.
|
||||
|
||||
---
|
||||
|
||||
## 🩹 Health panel fixes
|
||||
|
||||
- **Dismiss silently failed on storage alerts** — clicking Dismiss on a `storage_unavailable`, `mount_stale`, `mount_readonly`, `lxc_disk_low`, `lxc_mount_low`, `pve_storage_full` or `zfs_pool_full` error persisted the ack in the database but never invalidated the Monitor's cache because the event was tagged with category `general` instead of `storage`. The error stayed visible after dismiss. Both the category inference and the cache invalidation map fixed.
|
||||
- **VMs & Containers stuck on `UNKNOWN` with `'NoneType' object has no attribute 'get'`** (#255) — any persisted error with a `NULL` `details` column crashed the entire VM/CT check on every scan. The category stayed UNKNOWN permanently and Dismiss couldn't silence it because there was no specific `error_key` to acknowledge. Fixed by explicit `error.get('details') or {}` coalescing in both persistence loops.
|
||||
|
||||
---
|
||||
|
||||
## 🛠 Mobile & webhook fixes
|
||||
|
||||
- **Mobile dashboard freeze over HTTPS + reverse proxy** — the Service Worker introduced in v1.2.3 (`sw.js`, needed for PWA installability) interacted with mobile browsers' battery-saving background throttling and caused polling fetches to stop firing after a page refresh. Dashboard values (CPU / RAM / temperature) froze at their last reading until a hard cache wipe. Fixed by auto-cleaning any registered Service Worker on load. PWA installability is now driven entirely by the new in-app install prompt above.
|
||||
- **Webhook auth extended to VPN / CGNAT interface IPs** — the internal webhook (`/api/notifications/webhook`) previously trusted only requests from `127.0.0.1` / `::1`. Users with a Tailscale, WireGuard, LAN or IPv6 FQDN pointing at the host saw PVE Test buttons return `401 missing_timestamp`. The webhook now treats any of the host's own interface IPs as trusted local-loopback, including IPv4-mapped-in-IPv6 form (`::ffff:100.x.x.x`) which Flask reports on dual-stack binds.
|
||||
|
||||
---
|
||||
|
||||
## 🙏 Acknowledgments
|
||||
|
||||
- **@pepenai** — reported the mobile dashboard freeze on his HTTPS + reverse proxy setup that led to the Service Worker cleanup path.
|
||||
- **Pepo** — reported the `401 missing_timestamp` on his PVE Test button that led to the webhook self-IP allowlist and the v4-mapped fix.
|
||||
- **@ash34** (#255) — reported the `VM/CT check unavailable: 'NoneType' object has no attribute 'get'` that led to the defensive coalescing of the `details` column.
|
||||
|
||||
|
||||
## 2026-07-14
|
||||
|
||||
### New version ProxMenux v1.2.3
|
||||
|
||||
@@ -85,87 +85,167 @@ lvm_repair_check() {
|
||||
|
||||
cleanup_duplicate_repos_pve9() {
|
||||
msg_info "$(translate "Cleaning up duplicate repositories...")"
|
||||
|
||||
local sources_file="/etc/apt/sources.list"
|
||||
local temp_file=$(mktemp)
|
||||
local cleaned_count=0
|
||||
declare -A seen_repos
|
||||
|
||||
if [ ! -s "$sources_file" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||
if [[ "$line" =~ ^[[:space:]]*# ]] || [[ -z "$line" ]]; then
|
||||
echo "$line" >> "$temp_file"
|
||||
continue
|
||||
fi
|
||||
|
||||
if [[ "$line" =~ ^deb ]]; then
|
||||
read -r _ url dist components <<< "$line"
|
||||
local key="${url}_${dist}"
|
||||
if [[ -v "seen_repos[$key]" ]]; then
|
||||
echo "# $line" >> "$temp_file"
|
||||
cleaned_count=$((cleaned_count + 1))
|
||||
msg_info "$(translate "Commented duplicate: $url $dist")"
|
||||
local sources_file="/etc/apt/sources.list"
|
||||
local cleaned_count=0
|
||||
|
||||
# Helper: extract a DEB822 field's value from a .sources file. Handles
|
||||
# both `Field: value` and folded continuations. Returns the FIRST value
|
||||
# only (URIs / Suites / Components with multiple entries are read as a
|
||||
# single whitespace-separated string that callers split themselves).
|
||||
_deb822_get() {
|
||||
local file="$1" field="$2"
|
||||
[[ -f "$file" ]] || return 1
|
||||
awk -v F="$field" '
|
||||
BEGIN{ IGNORECASE=1 }
|
||||
/^[[:space:]]*$/{ next }
|
||||
/^[^[:space:]]/{
|
||||
if (match($0, "^"F"[[:space:]]*:[[:space:]]*")) {
|
||||
print substr($0, RSTART+RLENGTH)
|
||||
exit
|
||||
}
|
||||
}
|
||||
' "$file"
|
||||
}
|
||||
|
||||
# Helper: back up a file once before modifying, so an accidental
|
||||
# comment-out is always recoverable next to the original.
|
||||
_backup_once() {
|
||||
local file="$1"
|
||||
[[ -f "$file" ]] || return 0
|
||||
local ts backup
|
||||
ts=$(date +%Y%m%d_%H%M%S)
|
||||
backup="${file}.proxmenux-backup.${ts}"
|
||||
[[ -f "$backup" ]] || cp -a "$file" "$backup"
|
||||
}
|
||||
|
||||
# ── Phase 1 — comment intra-file duplicates in sources.list by URL+Suite ──
|
||||
if [ -s "$sources_file" ]; then
|
||||
local temp_file
|
||||
temp_file=$(mktemp)
|
||||
declare -A seen_repos
|
||||
local file_changed=0
|
||||
|
||||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||
if [[ "$line" =~ ^[[:space:]]*# ]] || [[ -z "$line" ]]; then
|
||||
echo "$line" >> "$temp_file"
|
||||
continue
|
||||
fi
|
||||
if [[ "$line" =~ ^deb ]]; then
|
||||
read -r _ url dist components <<< "$line"
|
||||
local key="${url}_${dist}"
|
||||
# Portable "is this associative-array key set?" — `[[ -v arr[key] ]]`
|
||||
# is only reliable in bash 4.4+; `${var+set}` works everywhere.
|
||||
if [[ -n "${seen_repos[$key]+set}" ]]; then
|
||||
echo "# $line" >> "$temp_file"
|
||||
cleaned_count=$((cleaned_count + 1))
|
||||
file_changed=1
|
||||
msg_info "$(translate "Commented duplicate: $url $dist")"
|
||||
else
|
||||
echo "$line" >> "$temp_file"
|
||||
seen_repos[$key]="$components"
|
||||
fi
|
||||
else
|
||||
echo "$line" >> "$temp_file"
|
||||
seen_repos[$key]="$components"
|
||||
fi
|
||||
done < "$sources_file"
|
||||
|
||||
if [[ "$file_changed" -eq 1 ]]; then
|
||||
_backup_once "$sources_file"
|
||||
mv "$temp_file" "$sources_file"
|
||||
chmod 644 "$sources_file"
|
||||
else
|
||||
echo "$line" >> "$temp_file"
|
||||
fi
|
||||
done < "$sources_file"
|
||||
|
||||
mv "$temp_file" "$sources_file"
|
||||
chmod 644 "$sources_file"
|
||||
|
||||
|
||||
if [ -f "/etc/apt/sources.list.d/proxmox.sources" ]; then
|
||||
|
||||
|
||||
|
||||
if grep -q "^deb.*download\.proxmox\.com" "$sources_file"; then
|
||||
sed -i '/^deb.*download\.proxmox\.com/s/^/# /' "$sources_file"
|
||||
cleaned_count=$((cleaned_count + 1))
|
||||
fi
|
||||
|
||||
for list_file in /etc/apt/sources.list.d/pve-*.list; do
|
||||
if [ -f "$list_file" ] && [[ "$list_file" != "/etc/apt/sources.list.d/pve-enterprise.list" ]]; then
|
||||
if grep -q "^deb" "$list_file"; then
|
||||
sed -i 's/^deb/# deb/g' "$list_file"
|
||||
cleaned_count=$((cleaned_count + 1))
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -f "/etc/apt/sources.list.d/debian.sources" ]; then
|
||||
|
||||
if grep -q "^deb.*deb\.debian\.org" "$sources_file"; then
|
||||
sed -i '/^deb.*deb\.debian\.org/s/^/# /' "$sources_file"
|
||||
cleaned_count=$((cleaned_count + 1))
|
||||
|
||||
fi
|
||||
|
||||
if grep -q "^deb.*security\.debian\.org" "$sources_file"; then
|
||||
sed -i '/^deb.*security\.debian\.org/s/^/# /' "$sources_file"
|
||||
cleaned_count=$((cleaned_count + 1))
|
||||
|
||||
fi
|
||||
rm -f "$temp_file"
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
# ── Phase 2 — comment lines duplicating what proxmox.sources already declares ──
|
||||
# Comparison is EXACT on URL + Suite + at least one Component match, so a
|
||||
# legitimate custom repo the user added under `download.proxmox.com` (e.g.
|
||||
# /debian/pbs, /debian/ceph-squid, or the same URL pinned to a different
|
||||
# suite) is preserved untouched.
|
||||
if [ -f "/etc/apt/sources.list.d/proxmox.sources" ]; then
|
||||
local pmx_uri pmx_suite pmx_comps
|
||||
pmx_uri=$(_deb822_get /etc/apt/sources.list.d/proxmox.sources URIs)
|
||||
pmx_suite=$(_deb822_get /etc/apt/sources.list.d/proxmox.sources Suites)
|
||||
pmx_comps=$(_deb822_get /etc/apt/sources.list.d/proxmox.sources Components)
|
||||
|
||||
_match_and_comment() {
|
||||
local target_file="$1" uri="$2" suite="$3" comps="$4"
|
||||
[[ -f "$target_file" ]] || return 0
|
||||
[[ -n "$uri" && -n "$suite" && -n "$comps" ]] || return 0
|
||||
local base_uri="${uri#http://}"
|
||||
base_uri="${base_uri#https://}"
|
||||
base_uri="${base_uri%/}"
|
||||
local first_comp
|
||||
first_comp=$(awk '{print $1}' <<< "$comps")
|
||||
local matched=0
|
||||
while IFS= read -r ln; do
|
||||
[[ "$ln" =~ ^[[:space:]]*# ]] && continue
|
||||
[[ "$ln" =~ ^deb ]] || continue
|
||||
read -r _ line_url line_suite line_comps <<< "$ln"
|
||||
local ln_base="${line_url#http://}"
|
||||
ln_base="${ln_base#https://}"
|
||||
ln_base="${ln_base%/}"
|
||||
[[ "$ln_base" == "$base_uri" ]] || continue
|
||||
[[ "$line_suite" == "$suite" ]] || continue
|
||||
[[ " $line_comps " == *" $first_comp "* ]] || continue
|
||||
matched=1
|
||||
break
|
||||
done < "$target_file"
|
||||
if [[ "$matched" -eq 1 ]]; then
|
||||
_backup_once "$target_file"
|
||||
# Anchored sed: reconstruct the exact deb prefix to avoid
|
||||
# eating unrelated lines. Escape URL for regex safety.
|
||||
local esc_uri esc_suite esc_comp
|
||||
esc_uri=$(printf '%s' "$uri" | sed 's/[][\.^$*/]/\\&/g')
|
||||
esc_suite=$(printf '%s' "$suite" | sed 's/[][\.^$*/]/\\&/g')
|
||||
esc_comp=$(printf '%s' "$first_comp" | sed 's/[][\.^$*/]/\\&/g')
|
||||
sed -i -E "/^deb[[:space:]]+${esc_uri}[[:space:]]+${esc_suite}[[:space:]]+.*(^| )${esc_comp}( |$)/s/^/# /" "$target_file"
|
||||
cleaned_count=$((cleaned_count + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
_match_and_comment "$sources_file" "$pmx_uri" "$pmx_suite" "$pmx_comps"
|
||||
|
||||
# Only walk a fixed allowlist of known-legacy PVE list files. Any
|
||||
# other pve-*.list on disk is assumed to be user-authored (custom
|
||||
# mirror, backports, staging) and left alone.
|
||||
local legacy_pve_lists=(
|
||||
/etc/apt/sources.list.d/pve-public-repo.list
|
||||
/etc/apt/sources.list.d/pve-install-repo.list
|
||||
/etc/apt/sources.list.d/pve-no-subscription.list
|
||||
)
|
||||
for legacy in "${legacy_pve_lists[@]}"; do
|
||||
_match_and_comment "$legacy" "$pmx_uri" "$pmx_suite" "$pmx_comps"
|
||||
done
|
||||
|
||||
# Same exact-match approach for debian.sources vs sources.list.
|
||||
if [ -f "/etc/apt/sources.list.d/debian.sources" ]; then
|
||||
local dbn_uri dbn_suite dbn_comps
|
||||
dbn_uri=$(_deb822_get /etc/apt/sources.list.d/debian.sources URIs)
|
||||
dbn_suite=$(_deb822_get /etc/apt/sources.list.d/debian.sources Suites)
|
||||
dbn_comps=$(_deb822_get /etc/apt/sources.list.d/debian.sources Components)
|
||||
# `Suites` in debian.sources holds multiple ("trixie trixie-updates"),
|
||||
# walk each so both duplicates get commented if present.
|
||||
for suite_iter in $dbn_suite; do
|
||||
_match_and_comment "$sources_file" "$dbn_uri" "$suite_iter" "$dbn_comps"
|
||||
done
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Phase 3 — remove ONLY the well-known legacy files, and only when the
|
||||
# modern replacement already exists ──
|
||||
if [ -f "/etc/apt/sources.list.d/proxmox.sources" ]; then
|
||||
for old_file in /etc/apt/sources.list.d/pve-public-repo.list /etc/apt/sources.list.d/pve-install-repo.list; do
|
||||
if [ -f "$old_file" ]; then
|
||||
_backup_once "$old_file"
|
||||
rm -f "$old_file"
|
||||
cleaned_count=$((cleaned_count + 1))
|
||||
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
if [ $cleaned_count -gt 0 ]; then
|
||||
msg_ok "$(translate "Cleaned up $cleaned_count duplicate/old repositories")"
|
||||
apt-get update > /dev/null 2>&1 || true
|
||||
@@ -193,7 +273,7 @@ cleanup_duplicate_repos_pve9_() {
|
||||
if [[ "$line" =~ ^deb ]]; then
|
||||
read -r _ url dist components <<< "$line"
|
||||
local key="${url}_${dist}"
|
||||
if [[ -v "seen_repos[$key]" ]]; then
|
||||
if [[ -n "${seen_repos[$key]+set}" ]]; then
|
||||
echo "# $line" >> "$temp_file"
|
||||
cleaned_count=$((cleaned_count + 1))
|
||||
else
|
||||
@@ -276,7 +356,7 @@ cleanup_duplicate_repos_pve8() {
|
||||
if [[ "$line" =~ ^[[:space:]]*deb ]]; then
|
||||
read -r _ url dist components <<< "$line"
|
||||
local key="${url}_${dist}"
|
||||
if [[ -v "seen_repos[$key]" ]]; then
|
||||
if [[ -n "${seen_repos[$key]+set}" ]]; then
|
||||
echo "# $line" >> "$temp_file"
|
||||
cleaned_count=$((cleaned_count + 1))
|
||||
else
|
||||
|
||||
267
scripts/global/update-pve-safe.sh
Normal file
267
scripts/global/update-pve-safe.sh
Normal file
@@ -0,0 +1,267 @@
|
||||
#!/bin/bash
|
||||
# ==========================================================
|
||||
# Proxmox VE Update Script — Safe / Non-Invasive Variant
|
||||
# ==========================================================
|
||||
# Author : MacRimi
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : GPL-3.0
|
||||
# ==========================================================
|
||||
# Description:
|
||||
# Update path intended for a Proxmox host ALREADY in
|
||||
# production. Unlike scripts/global/update-pve8.sh and
|
||||
# update-pve9_2.sh (invoked by post_install), this variant
|
||||
# NEVER modifies the operator's own configuration:
|
||||
#
|
||||
# - Does NOT disable Enterprise / Ceph repositories
|
||||
# - Does NOT delete legacy repo files
|
||||
# - Does NOT overwrite proxmox.sources / debian.sources
|
||||
# when they already exist
|
||||
# - Does NOT purge alternative NTP services
|
||||
# - Does NOT force-install zfsutils / chrony /
|
||||
# proxmox-backup-restore-image
|
||||
# - Does NOT write no-firmware-warnings.conf
|
||||
#
|
||||
# What it DOES:
|
||||
# 1. Sanity checks (disk space, network)
|
||||
# 2. ensure_repositories() — only when repos are MISSING
|
||||
# 3. apt-get update, with automatic GPG key import when apt
|
||||
# reports NO_PUBKEY (any repo, user's or ours)
|
||||
# 4. cleanup_duplicate_repos() — exact URL+Suite+Component
|
||||
# match against proxmox.sources / debian.sources; leaves
|
||||
# unrelated custom `download.proxmox.com/*` and
|
||||
# user-authored pve-*.list files alone; backs each file
|
||||
# up before modifying
|
||||
# 5. Detect pending upgrades + security count
|
||||
# 6. Confirmation dialog
|
||||
# 7. apt-get full-upgrade with --force-confdef / --force-confold
|
||||
# (never overwrites the operator's edited config files)
|
||||
# 8. lvm_repair_check() — refreshes VG metadata when disks
|
||||
# passed through to guest VMs (DSM, TrueNAS, …) come back
|
||||
# with old PV headers
|
||||
# 9. apt-get autoremove + autoclean
|
||||
#
|
||||
# Reboot detection is handled by the caller (utilities/proxmox_update.sh).
|
||||
# ==========================================================
|
||||
|
||||
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
|
||||
|
||||
load_language
|
||||
initialize_cache
|
||||
|
||||
download_common_functions() {
|
||||
if ! source "$LOCAL_SCRIPTS/global/common-functions.sh"; then
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ensure_repositories() lives with the install helpers.
|
||||
source_install_functions() {
|
||||
local f="$LOCAL_SCRIPTS/global/utils-install-functions.sh"
|
||||
if [[ -f "$f" ]]; then
|
||||
source "$f"
|
||||
fi
|
||||
}
|
||||
|
||||
update_pve_safe() {
|
||||
local pve_version
|
||||
pve_version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+' | head -1)
|
||||
if [[ -z "$pve_version" ]]; then
|
||||
msg_error "$(translate "Unable to detect Proxmox version")"
|
||||
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"
|
||||
# Screen capture: replay the pre-upgrade context lines after `clear`
|
||||
# so the operator keeps the visual history around the noisy apt run.
|
||||
local screen_capture="/tmp/proxmenux_screen_capture_$$.txt"
|
||||
: > "$screen_capture"
|
||||
|
||||
download_common_functions
|
||||
source_install_functions
|
||||
|
||||
{
|
||||
msg_info2 "$(translate "Detected: Proxmox VE $pve_version — running safe update path")"
|
||||
} | tee -a "$screen_capture"
|
||||
|
||||
# ── 1. Sanity checks ──
|
||||
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")"
|
||||
echo -e
|
||||
msg_success "$(translate "Press Enter to return to menu...")"
|
||||
read -r
|
||||
rm -f "$screen_capture"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! ping -c 1 download.proxmox.com >/dev/null 2>&1; then
|
||||
msg_error "$(translate "Cannot reach Proxmox repositories")"
|
||||
echo -e
|
||||
msg_success "$(translate "Press Enter to return to menu...")"
|
||||
read -r
|
||||
rm -f "$screen_capture"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# ── 2. ensure_repositories: adds base Proxmox+Debian repos only if
|
||||
# they don't already exist. On a configured host this is a no-op. ──
|
||||
if declare -f ensure_repositories >/dev/null 2>&1; then
|
||||
ensure_repositories
|
||||
fi
|
||||
|
||||
# ── 3. apt-get update with automatic key recovery ──
|
||||
local update_output update_exit_code
|
||||
update_output=$(apt-get update 2>&1)
|
||||
update_exit_code=$?
|
||||
|
||||
if [ $update_exit_code -eq 0 ]; then
|
||||
msg_ok "$(translate "Package lists updated successfully")" | tee -a "$screen_capture"
|
||||
else
|
||||
if echo "$update_output" | grep -Eq "NO_PUBKEY|GPG error"; then
|
||||
local key
|
||||
key=$(echo "$update_output" | sed -n 's/.*NO_PUBKEY \([0-9A-F]\{8,40\}\).*/\1/p' | head -1)
|
||||
if [ -n "$key" ]; then
|
||||
mkdir -p /etc/apt/keyrings
|
||||
if command -v gpg >/dev/null 2>&1; then
|
||||
if gpg --batch --keyserver keyserver.ubuntu.com --recv-keys "$key" \
|
||||
&& gpg --batch --export "$key" | gpg --dearmor -o "/etc/apt/keyrings/${key}.gpg"; then
|
||||
msg_ok "$(translate "Imported missing GPG key: $key")" | tee -a "$screen_capture"
|
||||
else
|
||||
msg_warn "$(translate "Keyrings method failed; trying apt-key fallback")"
|
||||
apt-key adv --keyserver keyserver.ubuntu.com --recv-keys "$key" >/dev/null 2>&1 || true
|
||||
fi
|
||||
else
|
||||
msg_warn "$(translate "gpg not found; trying apt-key fallback")"
|
||||
apt-key adv --keyserver keyserver.ubuntu.com --recv-keys "$key" >/dev/null 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
if apt-get update > "$log_file" 2>&1; then
|
||||
msg_ok "$(translate "Package lists updated after GPG fix")" | tee -a "$screen_capture"
|
||||
else
|
||||
msg_error "$(translate "Failed to update package lists. Check log: $log_file")"
|
||||
rm -f "$screen_capture"
|
||||
return 1
|
||||
fi
|
||||
elif echo "$update_output" | grep -Eq "404|Failed to fetch"; then
|
||||
msg_warn "$(translate "Some repositories are not available, continuing with available ones...")"
|
||||
else
|
||||
msg_error "$(translate "Failed to update package lists. Check log: $log_file")"
|
||||
echo "Error details: $update_output"
|
||||
rm -f "$screen_capture"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── 4. Precise duplicate cleanup (exact URL+Suite+Component match,
|
||||
# backs up files before modifying). Skipped if unavailable. ──
|
||||
if declare -f cleanup_duplicate_repos >/dev/null 2>&1; then
|
||||
cleanup_duplicate_repos
|
||||
fi
|
||||
|
||||
# ── 5-6. Detect + confirm ──
|
||||
local current_pve_version available_pve_version upgradable security_updates
|
||||
current_pve_version=$(pveversion 2>/dev/null | grep -oP 'pve-manager/\K[0-9]+\.[0-9]+\.[0-9]+' | head -1)
|
||||
available_pve_version=$(apt-cache policy pve-manager 2>/dev/null | grep -oP 'Candidate: \K[0-9]+\.[0-9]+\.[0-9]+' | head -1)
|
||||
upgradable=$($APT_ENV apt list --upgradable 2>/dev/null | sed '1d' | sed '/^\s*$/d' | wc -l)
|
||||
security_updates=$($APT_ENV apt list --upgradable 2>/dev/null | sed '1d' | grep -ci '\-security')
|
||||
|
||||
local menu_text
|
||||
menu_text="$(translate "System Update Information")\n\n"
|
||||
menu_text+="$(translate "Current PVE Version"): $current_pve_version\n"
|
||||
if [ -n "$available_pve_version" ] && [ "$available_pve_version" != "$current_pve_version" ]; then
|
||||
menu_text+="$(translate "Available PVE Version"): $available_pve_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
|
||||
apt-get -y autoremove >/dev/null 2>&1 || true
|
||||
apt-get -y autoclean >/dev/null 2>&1 || true
|
||||
rm -f "$screen_capture"
|
||||
return 0
|
||||
fi
|
||||
|
||||
menu_text+="$(translate "Do you want to proceed with the system update?")"
|
||||
if ! whiptail --title "$(translate "Proxmox Update")" --yesno "$menu_text" 18 70; then
|
||||
msg_info2 "$(translate "Update cancelled by user")"
|
||||
apt-get -y autoremove >/dev/null 2>&1 || true
|
||||
apt-get -y autoclean >/dev/null 2>&1 || true
|
||||
rm -f "$screen_capture"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# ── 7. Full upgrade — --force-confdef/confold preserves user-edited configs ──
|
||||
# Redraw the ProxMenux frame before apt starts printing so the operator
|
||||
# keeps the visual context around the noisy upgrade output.
|
||||
clear
|
||||
show_proxmenux_logo
|
||||
msg_title "$(translate "$SCRIPT_TITLE")"
|
||||
cat "$screen_capture"
|
||||
|
||||
# apt's own progress bar (Progress: [ %]) prints on stderr and only
|
||||
# when stdout is a TTY. We pipe stderr through tee to keep a log copy
|
||||
# while letting apt keep its interactive stdout, so the native bar
|
||||
# keeps rendering at the bottom of the terminal as the user expects.
|
||||
DEBIAN_FRONTEND=noninteractive apt -y \
|
||||
-o Dpkg::Options::='--force-confdef' \
|
||||
-o Dpkg::Options::='--force-confold' \
|
||||
full-upgrade 2> >(tee -a "$log_file" >&2)
|
||||
local upgrade_exit_code=$?
|
||||
echo -e
|
||||
|
||||
# Redraw once more so the wrap-up (LVM check, cleanup, summary) reads
|
||||
# cleanly instead of scrolling under half-a-screen of apt noise.
|
||||
clear
|
||||
show_proxmenux_logo
|
||||
msg_title "$(translate "$SCRIPT_TITLE")"
|
||||
cat "$screen_capture"
|
||||
|
||||
if [ $upgrade_exit_code -ne 0 ]; then
|
||||
msg_error "$(translate "System upgrade failed. Check log: $log_file")"
|
||||
rm -f "$screen_capture"
|
||||
return 1
|
||||
fi
|
||||
|
||||
msg_ok "$(translate "System upgrade completed")"
|
||||
|
||||
# ── 8. LVM header repair (only touches VGs actually flagged as stale) ──
|
||||
if declare -f lvm_repair_check >/dev/null 2>&1; then
|
||||
lvm_repair_check
|
||||
fi
|
||||
|
||||
# ── 9. Final cleanup ──
|
||||
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 "====== PVE 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 VE")${CL}: ${BL}${available_pve_version:-$current_pve_version}${CL}"
|
||||
|
||||
msg_ok "$(translate "Proxmox VE safe update completed")"
|
||||
rm -f "$screen_capture"
|
||||
}
|
||||
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
update_pve_safe
|
||||
fi
|
||||
@@ -6,32 +6,26 @@
|
||||
# Copyright : (c) 2024 MacRimi
|
||||
# License : GPL-3.0
|
||||
# https://github.com/MacRimi/ProxMenux/blob/main/LICENSE
|
||||
# Version : 1.0
|
||||
# Version : 1.1
|
||||
# ==========================================================
|
||||
# Description:
|
||||
# Wrapper that detects the running Proxmox major version and
|
||||
# delegates to the matching worker script:
|
||||
# - PVE 8 -> scripts/global/update-pve8.sh
|
||||
# - PVE 9 -> scripts/global/update-pve9_2.sh
|
||||
# After the worker finishes, runs the post-update cleanup
|
||||
# (apt-get autoremove + autoclean) and prompts for an immediate
|
||||
# reboot if the kernel was updated or /var/run/reboot-required
|
||||
# was created.
|
||||
# Update wrapper for a Proxmox host ALREADY in production.
|
||||
# Delegates to `scripts/global/update-pve-safe.sh`, a non-
|
||||
# invasive worker that only performs operations safe for a
|
||||
# configured host — no repo overwriting, no service purging,
|
||||
# no forced package installs. See the header of the worker
|
||||
# for the full list of what it does and what it deliberately
|
||||
# refuses to do.
|
||||
#
|
||||
# Features (delegated to worker scripts):
|
||||
# - APT repository hygiene (Proxmox + Debian)
|
||||
# - Removal of duplicate / conflicting sources
|
||||
# - Switch to the no-subscription Proxmox repository
|
||||
# - Full apt update + dist-upgrade
|
||||
# - Installs essential packages if missing (zfsutils, chrony, ...)
|
||||
# - LVM / storage sanity checks and header repair
|
||||
# - Removes conflicting time-sync packages
|
||||
# - Post-update system cleanup
|
||||
# - Reboot prompt when kernel changed
|
||||
# ==========================================================
|
||||
# After the worker finishes this script:
|
||||
# - Runs apt-get autoremove + autoclean (final cleanup)
|
||||
# - Prompts for an immediate reboot when the kernel was
|
||||
# updated or /var/run/reboot-required was created
|
||||
#
|
||||
# The goal of this script is to simplify and secure the update process for Proxmox,
|
||||
# reduce manual intervention, and prevent common repository and package errors.
|
||||
# NOTE: For a fresh Proxmox install use post_install
|
||||
# (scripts/post_install/{auto,customizable}_post_install.sh),
|
||||
# which invokes the aggressive `update-pve{8,9_2}.sh` workers
|
||||
# that set up repositories and essentials from scratch.
|
||||
# ==========================================================
|
||||
|
||||
BASE_DIR="/usr/local/share/proxmenux"
|
||||
@@ -51,6 +45,23 @@ export SCRIPT_TITLE="Proxmox system update"
|
||||
|
||||
NECESSARY_REBOOT=1
|
||||
|
||||
# Suppress the Monitor's `service_fail` notifications while apt is running.
|
||||
# PVE services (pve-cluster, pveproxy, pvedaemon, corosync…) get killed and
|
||||
# restarted as part of the upgrade — those events are expected, not real
|
||||
# failures. `notification_events.is_apt_active_on_host()` reads these
|
||||
# markers; keep the names in sync between the two files.
|
||||
_PROXMENUX_UPDATE_MARKER="/var/run/proxmenux-update-in-progress"
|
||||
_PROXMENUX_UPDATE_FINISHED_MARKER="/var/run/proxmenux-update-just-finished"
|
||||
|
||||
_proxmenux_update_cleanup() {
|
||||
rm -f "$_PROXMENUX_UPDATE_MARKER"
|
||||
# Grace-window marker: journal events landing shortly after apt exits
|
||||
# (e.g. the pve-cluster restart) are still gated on this file's mtime.
|
||||
touch "$_PROXMENUX_UPDATE_FINISHED_MARKER"
|
||||
}
|
||||
trap _proxmenux_update_cleanup EXIT
|
||||
touch "$_PROXMENUX_UPDATE_MARKER"
|
||||
|
||||
apt_upgrade() {
|
||||
local pve_version pve_raw
|
||||
# Capture both stdout and the rc so a failure is visible in the
|
||||
@@ -70,17 +81,11 @@ apt_upgrade() {
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ "$pve_version" -ge 9 ]]; then
|
||||
show_proxmenux_logo
|
||||
msg_title "$(translate "$SCRIPT_TITLE")"
|
||||
bash "$LOCAL_SCRIPTS/global/update-pve9_2.sh"
|
||||
|
||||
else
|
||||
show_proxmenux_logo
|
||||
msg_title "$(translate "Proxmox system update")"
|
||||
bash "$LOCAL_SCRIPTS/global/update-pve8.sh"
|
||||
|
||||
fi
|
||||
show_proxmenux_logo
|
||||
msg_title "$(translate "$SCRIPT_TITLE")"
|
||||
# Single worker for both PVE 8 and 9 — it detects the version itself
|
||||
# and only performs operations safe on a production host.
|
||||
bash "$LOCAL_SCRIPTS/global/update-pve-safe.sh"
|
||||
|
||||
|
||||
}
|
||||
@@ -90,10 +95,28 @@ apt_upgrade() {
|
||||
check_reboot() {
|
||||
NECESSARY_REBOOT=0
|
||||
|
||||
# Standard Debian mechanism — needs `needrestart` (or a package that
|
||||
# explicitly creates it in its postinst) to be present. Not shipped
|
||||
# by default on many PVE installs, so we complement with the kernel
|
||||
# fallback below.
|
||||
if [ -f /var/run/reboot-required ]; then
|
||||
NECESSARY_REBOOT=1
|
||||
fi
|
||||
if grep -q "linux-image" "$log_file" 2>/dev/null; then
|
||||
|
||||
# Fallback: compare the running kernel with the most recent
|
||||
# proxmox-kernel-* / pve-kernel-* installed. If a newer kernel package
|
||||
# is on disk but the box is still on the old one, a reboot is required
|
||||
# regardless of whether needrestart flagged it.
|
||||
local running_kernel newest_kernel
|
||||
running_kernel=$(uname -r)
|
||||
newest_kernel=$(
|
||||
dpkg-query -W -f='${Status}\t${Package}\n' 'proxmox-kernel-*-pve-signed' 'pve-kernel-*-pve' 2>/dev/null \
|
||||
| awk -F'\t' '/^install ok installed\t/ { print $2 }' \
|
||||
| sed -E 's/^(proxmox|pve)-kernel-//; s/-signed$//' \
|
||||
| sort -V \
|
||||
| tail -1
|
||||
)
|
||||
if [ -n "$newest_kernel" ] && [ "$newest_kernel" != "$running_kernel" ]; then
|
||||
NECESSARY_REBOOT=1
|
||||
fi
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ export default async function ShowVersionInformationPage({
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-semibold mt-10 mb-4 text-gray-900">{t("sampleHeading")}</h2>
|
||||
<pre className="rounded-md bg-gray-100 p-4 overflow-x-auto text-xs font-mono text-gray-800 leading-relaxed border border-gray-200">{`Current ProxMenux version: 1.2.3
|
||||
<pre className="rounded-md bg-gray-100 p-4 overflow-x-auto text-xs font-mono text-gray-800 leading-relaxed border border-gray-200">{`Current ProxMenux version: 1.2.4
|
||||
|
||||
Installation type:
|
||||
✓ Translation Version (Multi-language support)
|
||||
|
||||
@@ -1,4 +1,61 @@
|
||||
|
||||
## 2026-07-17
|
||||
|
||||
### Nueva versión ProxMenux v1.2.4
|
||||
|
||||
Esta versión suma dos mejoras visibles en el propio dashboard — un botón para lanzar la actualización de Proxmox desde el Monitor de Salud, y un prompt para invitar a los usuarios móviles a instalar el Monitor como PWA — y cierra siete correcciones de contenido y entrega de notificaciones, el bloqueo del dashboard móvil sobre HTTPS + reverse proxy.
|
||||
---
|
||||
|
||||
## 🩺 Botón Update Now en el Monitor de Salud
|
||||
|
||||
- Nuevo botón **Update Now** dentro del modal del Monitor de Salud, en la sección **System Updates**.
|
||||
- Ejecuta el flujo estándar de actualización de Proxmox (`apt update` + `dist-upgrade` + limpieza posterior) en una terminal dentro del dashboard — sin necesidad de abrir una shell.
|
||||
- Solo aparece cuando hay actualizaciones pendientes; si el sistema está al día, el botón queda oculto.
|
||||
- Al cerrar, el Monitor de Salud se refresca automáticamente para que el contador de actualizaciones pendientes y la fila del kernel se actualicen al instante.
|
||||
- El script subyacente distingue el contexto: si se ejecuta sobre un host ya en producción, respeta los repositorios personalizados del operador (no desactiva enterprise/ceph, no borra sources legacy, no purga NTP alternativos ni fuerza instalar zfsutils/chrony); si detecta un servidor virgen crea los repos base necesarios. Detecta también si hay un kernel nuevo instalado distinto del que está corriendo y pide el reboot al terminar.
|
||||
- Durante la actualización se suprimen las notificaciones de `service_fail` de servicios PVE (pve-cluster, pveproxy, corosync…) porque su reinicio es parte normal del upgrade; volvían a notificarse hasta 60 s después de que apt termine.
|
||||
|
||||
---
|
||||
|
||||
## 📱 Prompt de instalación en la app para móvil
|
||||
|
||||
- Los visitantes por primera vez en **Android** (Chrome / Brave) e **iOS Safari** ven ahora una bottom-sheet con instrucciones claras para añadir el Monitor a su pantalla de inicio como PWA.
|
||||
- Dos niveles de descarte: **Not now** (temporal, reaparece a los 30 días) y **Don't show again** (permanente, almacenado en `localStorage`).
|
||||
- No se muestra en escritorio, ni cuando el Monitor ya se está ejecutando como aplicación instalada.
|
||||
|
||||
---
|
||||
|
||||
## 🔔 Contenido de notificaciones — cinco correcciones de renderizado
|
||||
|
||||
- **Destino de backup ahora visible** — los correos y mensajes de Telegram de backup de VM/CT incluyen el storage / destino PBS tanto en el título como en la tabla del cuerpo, para que los operadores con varios destinos de backup puedan saber de un vistazo cuál funcionó o falló.
|
||||
- **Los cuerpos de migración ya no muestran `migrated to node .`** — el nodo destino real se extrae del log de la tarea PVE para eventos `qmigrate` / `vzmigrate`.
|
||||
- **Los cuerpos de snapshot ya no muestran `Snapshot "" created`** — el nombre real del snapshot se extrae del log de la tarea PVE para `qmsnapshot` / `vzsnapshot`.
|
||||
- **Las notificaciones genéricas `system_problem` llevan la razón real** — los mensajes PVE que caen en el bucket genérico incluyen el cuerpo original del payload, reemplazando el inútil *"Se ha detectado un problema a nivel del sistema."*
|
||||
- **Los correos de actualización de driver NVIDIA / Coral muestran la nueva versión** — la fila *New Version* en el cuerpo HTML aparecía vacía por un desajuste de nombres entre el template y el renderer (`latest_version` vs `new_version`). Corregido.
|
||||
|
||||
---
|
||||
|
||||
## 🩹 Correcciones del panel de salud
|
||||
|
||||
- **Dismiss silencioso sobre alertas de storage** — al pulsar Dismiss en un error tipo `storage_unavailable`, `mount_stale`, `mount_readonly`, `lxc_disk_low`, `lxc_mount_low`, `pve_storage_full` o `zfs_pool_full`, la acción se persistía en la DB pero el caché del Monitor no se invalidaba porque el evento se catalogaba como categoría `general` en lugar de `storage`. Resultado: el error seguía visible tras el descarte. Corregidos ambos, la inferencia de categoría y el mapa de invalidación de caché.
|
||||
- **VMs & Containers atascado en `UNKNOWN` con `'NoneType' object has no attribute 'get'`** (#255) — cualquier error persistido con la columna `details` a `NULL` en la DB provocaba que el chequeo completo de VM/CT lanzara excepción en cada scan. La categoría se quedaba UNKNOWN permanentemente y el Dismiss no podía silenciarla porque no había un `error_key` específico al que reaccionar. Corregido con coalescing explícito `error.get('details') or {}` en los dos bucles de persistencia.
|
||||
|
||||
---
|
||||
|
||||
## 🛠 Correcciones en móvil y webhook
|
||||
|
||||
- **Dashboard congelado en móvil sobre HTTPS + reverse proxy** — el Service Worker introducido en v1.2.3 (`sw.js`, necesario para la instalabilidad PWA) interactuaba con la limitación en segundo plano de los navegadores móviles para ahorrar batería y hacía que las peticiones de polling dejaran de dispararse tras un refresh. Los valores del dashboard (CPU / RAM / temperatura) se congelaban en la última lectura hasta que se limpiara la caché manualmente. Corregido auto-limpiando cualquier Service Worker registrado al cargar. La instalabilidad PWA se gestiona ahora íntegramente desde el nuevo prompt in-app descrito arriba.
|
||||
- **Autenticación de webhook extendida a IPs de VPN / CGNAT** — el webhook interno (`/api/notifications/webhook`) confiaba anteriormente solo en peticiones desde `127.0.0.1` / `::1`. Los usuarios con un FQDN de Tailscale, WireGuard, LAN o IPv6 apuntando al host veían botones Test de PVE devolver `401 missing_timestamp`. El webhook ahora trata cualquier IP de una interfaz local del host como local-loopback de confianza, incluyendo la forma IPv4 mapeada en IPv6 (`::ffff:100.x.x.x`) que Flask reporta en bindings dual-stack.
|
||||
|
||||
---
|
||||
|
||||
## 🙏 Agradecimientos
|
||||
|
||||
- **@pepenai** — reportó el bloqueo del dashboard en móvil en su setup con HTTPS + reverse proxy, lo que llevó al path de limpieza del Service Worker.
|
||||
- **Pepo** — reportó el `401 missing_timestamp` en su botón Test de PVE, lo que llevó al allowlist de IPs propias del host y a la corrección v4-mapped.
|
||||
- **@ash34** (#255) — reportó el `VM/CT check unavailable: 'NoneType' object has no attribute 'get'` que llevó al coalescing defensivo de la columna `details`.
|
||||
|
||||
|
||||
## 2026-07-14
|
||||
|
||||
### Nueva versión ProxMenux v1.2.3
|
||||
|
||||
Reference in New Issue
Block a user