feat: add guarded host restart and read-only ProxMenux version status

This commit is contained in:
VAIO73
2026-10-28 17:30:44 +00:00
parent 503a946d85
commit 52cd21dcb1
11 changed files with 631 additions and 1 deletions
@@ -42,6 +42,7 @@ import {
} from "lucide-react"
import { ScriptTerminalModal } from "./script-terminal-modal"
import { HostUpdateJobs, type HostUpdateJob } from "./host-update-jobs"
import { RestartAfterUpdate, ProxMenuxVersionCard } from "./host-update-status"
import { useT } from "@/lib/i18n/provider"
import { useIsPbs } from "@/lib/use-platform"
@@ -370,6 +371,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
"UP": t("healthStatus.details.up"),
"Kernel/PVE up to date": t("healthStatus.details.kernelUpToDate"),
"No reboot required": t("healthStatus.hostUpdate.noReboot"),
"No pending updates": t("healthStatus.details.noPendingUpdates"),
"Reboot required by the operating system": t("healthStatus.hostUpdate.rebootMarker"),
"Could not determine whether a reboot is required": t("healthStatus.hostUpdate.rebootUnknown"),
"Proxmox VE is up to date": t("healthStatus.details.proxmoxUpToDate"),
@@ -689,6 +691,11 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
{Object.entries(checks)
.filter(([, checkData]) => checkData.installed !== false)
.map(([checkKey, checkData]) => {
if (categoryKey === "updates" && checkKey === "reboot_required" && checkData.status === "INFO") {
return <RestartAfterUpdate key={checkKey} getApiUrl={getApiUrl}
disabled={hostJobs.some(job => job.state !== "complete")} label={formatCheckLabel(checkKey)}
detail={translateHealthText(checkData.detail)} icon={getStatusIcon(checkData.status, "sm")} />
}
const isDismissable = checkData.dismissable === true
const checkStatus = checkData.status?.toUpperCase() || "OK"
@@ -896,6 +903,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
pending prevents the operator from spawning a
terminal that would only report "System is
already up to date". */}
{key === "updates" && <ProxMenuxVersionCard getApiUrl={getApiUrl} />}
{key === "updates" && Number(categoryData?.count ?? 0) > 0 && (
<div className="flex justify-end px-3 py-2 pt-1">
<Button
+157
View File
@@ -0,0 +1,157 @@
"use client"
import { useEffect, useRef, useState, type ReactNode } from "react"
import * as Confirm from "@radix-ui/react-alert-dialog"
import { Button } from "./ui/button"
import { getAuthToken } from "@/lib/api-config"
import { useT } from "@/lib/i18n/provider"
function headers() {
const token = getAuthToken()
return { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}) }
}
type Url = (path: string) => string
type Context = { hostname: string; boot_id: string; allowed: boolean; reason: string }
const compact = "h-10 sm:h-[34px] px-3 text-xs"
export function RestartAfterUpdate({ getApiUrl, disabled, label, detail, icon }: {
getApiUrl: Url; disabled: boolean; label: string; detail: string; icon: ReactNode
}) {
const t = useT()
const [open, setOpen] = useState(false)
const [context, setContext] = useState<Context | null>(null)
const [submitted, setSubmitted] = useState(false)
const [pending, setPending] = useState(false)
const [accepted, setAccepted] = useState(false)
const [error, setError] = useState(false)
const sending = useRef(false)
const alive = useRef(true)
const postRequest = useRef<AbortController | null>(null)
useEffect(() => { alive.current = true; return () => { alive.current = false; postRequest.current?.abort() } }, [])
useEffect(() => {
if (!open) return
let disposed = false, reading = false
let active: AbortController | undefined
setContext(null); setSubmitted(false); setError(false); sending.current = false
const read = async () => {
if (reading) return
reading = true
const controller = new AbortController(); active = controller
const timeout = setTimeout(() => controller.abort(), 6000)
try {
const response = await fetch(getApiUrl("/api/health/reboot-context"), { headers: headers(), signal: controller.signal })
if (!response.ok) throw Error("unavailable")
const value: Context = await response.json()
if (!value || typeof value.hostname !== "string" || typeof value.allowed !== "boolean" || typeof value.boot_id !== "string" || !value.boot_id) throw Error("invalid")
if (!disposed) { setContext(value); setError(false) }
} catch { if (!disposed) { setContext(null); setError(true) } }
finally { clearTimeout(timeout); reading = false }
}
void read()
const interval = setInterval(read, 2000)
return () => { disposed = true; clearInterval(interval); active?.abort() }
}, [open, getApiUrl])
useEffect(() => {
if (!accepted) return
let disposed = false, reading = false
const controller = new AbortController()
const interval = setInterval(async () => {
if (reading) return
reading = true
try {
const response = await fetch(getApiUrl("/api/health/reboot-after-update/status"), { headers: headers(), signal: controller.signal })
if (!response.ok) return
const value = await response.json()
if (!disposed && (value.state === "failed" || value.state === "cancelled")) { setAccepted(false); setError(true) }
} catch { /* Loss of connection does not prove reboot completion. */ }
finally { reading = false }
}, 2000)
const timeout = setTimeout(() => { clearInterval(interval); controller.abort() }, 10000)
return () => { disposed = true; controller.abort(); clearInterval(interval); clearTimeout(timeout) }
}, [accepted, getApiUrl])
const confirm = async () => {
if (disabled || !context?.allowed || sending.current || submitted) return
sending.current = true; setSubmitted(true); setPending(true); setError(false)
const controller = new AbortController(); postRequest.current = controller
const timeout = setTimeout(() => controller.abort(), 15000)
try {
const response = await fetch(getApiUrl("/api/health/reboot-after-update"), {
method: "POST", headers: headers(), body: JSON.stringify({ confirm: true, hostname: context.hostname, boot_id: context.boot_id }), signal: controller.signal,
})
if (response.status !== 202 || (await response.json()).accepted !== true) throw Error("unconfirmed")
if (alive.current) { setAccepted(true); setOpen(false) }
} catch { if (alive.current) setError(true) }
finally { clearTimeout(timeout); postRequest.current = null; if (alive.current) setPending(false) }
}
return <section className="py-1.5 px-2 sm:px-3">
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
<div className="flex items-start gap-2 flex-1 min-w-0 text-[10px] sm:text-xs"><span className="shrink-0">{icon}</span>
<div><span className="font-medium mr-2">{label}</span><span className="text-muted-foreground break-words">{detail}</span></div></div>
<Button variant="outline" className={`${compact} self-start sm:shrink-0 border-amber-500/40 text-amber-500`}
disabled={disabled || accepted} onClick={() => setOpen(true)}>{t("healthStatus.restart.button")}</Button>
</div>
{disabled && <p className="mt-2 text-xs text-muted-foreground">{t("healthStatus.restart.blocked")}</p>}
{accepted && <p role="status" className="mt-2 text-xs text-blue-500">{t("healthStatus.restart.accepted")}</p>}
{error && !open && <p role="alert" className="mt-2 text-xs text-amber-500">{t("healthStatus.restart.unconfirmed")}</p>}
<Confirm.Root open={open} onOpenChange={setOpen}><Confirm.Portal>
<Confirm.Overlay className="fixed inset-0 bg-black/75 z-[70]" />
<Confirm.Content className="fixed z-[80] w-[92vw] max-w-[520px] rounded-lg border bg-card text-foreground p-5 shadow-lg" style={{ top: "50%", left: "50%", transform: "translate(-50%,-50%)" }}>
<Confirm.Title className="text-base font-semibold">{t("healthStatus.restart.title", { server: context?.hostname ?? "…" })}</Confirm.Title>
<Confirm.Description className="mt-3 text-sm text-muted-foreground">{t("healthStatus.restart.impact")}</Confirm.Description>
{(disabled || context?.reason === "busy") && <p className="mt-3 text-xs text-amber-500">{t("healthStatus.restart.blocked")}</p>}
{context?.reason === "current" && <p className="mt-3 text-xs text-muted-foreground">{t("healthStatus.hostUpdate.noReboot")}</p>}
{(error || context?.reason === "unknown") && <p role="alert" className="mt-3 text-xs text-amber-500">{t("healthStatus.restart.unconfirmed")}</p>}
<div className="mt-5 flex justify-end gap-2.5">
<Confirm.Cancel asChild><Button variant="outline" className={compact} disabled={pending}>{t("healthStatus.restart.cancel")}</Button></Confirm.Cancel>
<Confirm.Action asChild><Button variant="destructive" className={compact} disabled={disabled || !context?.allowed || submitted}
onClick={event => { event.preventDefault(); void confirm() }}>{t("healthStatus.restart.confirm")}</Button></Confirm.Action>
</div>
</Confirm.Content>
</Confirm.Portal></Confirm.Root>
</section>
}
type Versions = { installed: string | null; available: string | null; state: "available" | "current" | "unknown"; checked_at: string | null; channel: "stable" | "beta" }
export function ProxMenuxVersionCard({ getApiUrl }: { getApiUrl: Url }) {
const t = useT()
const [value, setValue] = useState<Versions | null>(null)
useEffect(() => {
let disposed = false, reading = false
let active: AbortController | undefined
const read = async () => {
if (reading || document.visibilityState !== "visible") return
reading = true
const controller = new AbortController(); active = controller
const timeout = setTimeout(() => controller.abort(), 6000)
try {
const response = await fetch(getApiUrl("/api/health/proxmenux-version"), { headers: headers(), signal: controller.signal })
if (!response.ok) throw Error("unavailable")
const data: Versions = await response.json()
if (!["available", "current", "unknown"].includes(data?.state)) throw Error("invalid")
const validVersion = (version: unknown) => typeof version === "string" && /^\d+(?:\.\d+){1,5}$/.test(version)
if (!["stable", "beta"].includes(data.channel) ||
(data.installed !== null && !validVersion(data.installed)) ||
(data.available !== null && !validVersion(data.available)) ||
(data.state !== "unknown" && (!validVersion(data.installed) || !validVersion(data.available) || !data.checked_at)) ||
(data.checked_at !== null && !Number.isFinite(Date.parse(data.checked_at)))) throw Error("invalid")
if (!disposed) setValue(data)
} catch { if (!disposed) setValue(previous => previous ? { ...previous, state: "unknown" } : null) }
finally { clearTimeout(timeout); reading = false }
}
void read()
const interval = setInterval(read, 300000)
window.addEventListener("focus", read); document.addEventListener("visibilitychange", read)
return () => { disposed = true; active?.abort(); clearInterval(interval); window.removeEventListener("focus", read); document.removeEventListener("visibilitychange", read) }
}, [getApiUrl])
const state = value?.state ?? "unknown"
return <section aria-label={t("healthStatus.versions.title")} className="mt-3 mx-2 sm:mx-3 rounded-lg border bg-muted/10 p-3 text-[10px] sm:text-xs">
<div className="flex flex-wrap items-center justify-between gap-2 mb-3"><h3 className="font-semibold">{t("healthStatus.versions.title")}</h3>
<span className={state === "unknown" ? "text-amber-500" : state === "available" ? "text-blue-500" : "text-green-500"}>{t(`healthStatus.versions.${state}`)}</span></div>
<dl className="grid grid-cols-2 gap-4">
<div><dt className="text-muted-foreground mb-1">{t("healthStatus.versions.installed")}</dt><dd className="font-mono font-semibold">{value?.installed ?? t("healthStatus.versions.unverified")}</dd></div>
<div><dt className="text-muted-foreground mb-1">{t(state === "unknown" && value?.available ? "healthStatus.versions.lastKnown" : "healthStatus.versions.latest")}</dt><dd className="font-mono font-semibold">{value?.available ?? t("healthStatus.versions.unverified")}</dd></div>
</dl>
{value && <p className="mt-3 text-muted-foreground">{t(`healthStatus.versions.${value.channel}`)}</p>}
{value?.checked_at && <p className="mt-1 text-muted-foreground">{t("healthStatus.versions.checked", { time: new Date(value.checked_at).toLocaleString() })}</p>}
</section>
}
+24
View File
@@ -5309,6 +5309,29 @@
}
},
"healthStatus": {
"restart": {
"button": "Restart server",
"title": "Restart server {server}?",
"impact": "Restarting temporarily interrupts the server, virtual machines, containers and their services.",
"cancel": "Cancel",
"confirm": "Confirm restart",
"blocked": "Restart will be available after the host update finishes.",
"accepted": "Restart request accepted. Check that the server returns online.",
"unconfirmed": "The restart request could not be verified. Check the server before trying again."
},
"versions": {
"title": "ProxMenux update",
"installed": "Installed version",
"latest": "Available version",
"lastKnown": "Last known available version",
"available": "Update available",
"current": "ProxMenux is up to date",
"unknown": "Could not verify the available version",
"unverified": "Unverified",
"stable": "Stable channel",
"beta": "Beta channel",
"checked": "Last successful check: {time}"
},
"hostUpdate": {
"running": "The host update continues even if you sign out. Reopen it after signing in.",
"finished": "A host update has finished. View its result before starting another.",
@@ -5403,6 +5426,7 @@
"runningKernelUpdate": "Kernel update available for running {kernel}",
"kernelPveUpdates": "Kernel updates available: {kernelCount}; Proxmox updates available: {pveCount}",
"pendingPackageUpdates": "Package updates pending: {count}",
"noPendingUpdates": "No pending updates",
"updatesAptError": "Failed to check for updates (apt-get error)",
"updatesAptTimeout": "apt-get timed out - repository may be unreachable",
"kernelPackagesMore": "{technicalIdentity} (+{count} more)",
+24
View File
@@ -5181,6 +5181,29 @@
}
},
"healthStatus": {
"restart": {
"button": "Reštartovať server",
"title": "Reštartovať server {server}?",
"impact": "Reštart dočasne preruší dostupnosť servera, virtuálnych strojov, kontajnerov a ich služieb.",
"cancel": "Zrušiť",
"confirm": "Potvrdiť reštart",
"blocked": "Reštart bude dostupný po dokončení aktualizácie servera.",
"accepted": "Požiadavka na reštart bola prijatá. Over, že sa server znova sprístupní.",
"unconfirmed": "Požiadavku na reštart sa nepodarilo overiť. Pred ďalším pokusom skontroluj server."
},
"versions": {
"title": "Aktualizácia ProxMenuxu",
"installed": "Nainštalovaná verzia",
"latest": "Dostupná verzia",
"lastKnown": "Posledná známa dostupná verzia",
"available": "Dostupná aktualizácia",
"current": "ProxMenux je aktuálny",
"unknown": "Dostupnú verziu sa nepodarilo overiť",
"unverified": "Neoverené",
"stable": "Stabilný kanál",
"beta": "Beta kanál",
"checked": "Posledná úspešná kontrola: {time}"
},
"hostUpdate": {
"running": "Aktualizácia servera pokračuje aj po odhlásení. Po prihlásení ju môžeš znova otvoriť.",
"finished": "Aktualizácia servera sa skončila. Pred ďalším spustením si pozri jej výsledok.",
@@ -5314,6 +5337,7 @@
"runningKernelUpdate": "K dispozícii je aktualizácia jadra pre spustenie {kernel}",
"kernelPveUpdates": "Dostupné aktualizácie jadra: {kernelCount}; dostupné aktualizácie Proxmox: {pveCount}",
"pendingPackageUpdates": "Čakajúce aktualizácie balíka: {count}",
"noPendingUpdates": "Žiadne čakajúce aktualizácie",
"updatesAptError": "Nepodarilo sa skontrolovať aktualizácie (chyba apt-get)",
"updatesAptTimeout": "Časový limit apt-get vypršal - úložisko môže byť nedostupné",
"kernelPackagesMore": "{technicalIdentity} (+{count} viac)",
+48 -1
View File
@@ -2,7 +2,7 @@
Flask routes for health monitoring with persistence support
"""
from flask import Blueprint, jsonify, request
from flask import Blueprint, jsonify, request, current_app
from health_monitor import health_monitor
from health_persistence import health_persistence
from jwt_middleware import require_auth, require_admin_scope
@@ -17,6 +17,53 @@ except ImportError:
health_bp = Blueprint('health', __name__)
@health_bp.route('/api/health/proxmenux-version', methods=['GET'])
@require_auth
def get_proxmenux_version():
from proxmenux_version_status import version_status
return jsonify(version_status())
@health_bp.route('/api/health/reboot-context', methods=['GET'])
@require_admin_scope
def get_reboot_context():
from host_update_controls import reboot_context
return jsonify(reboot_context())
@health_bp.route('/api/health/reboot-after-update', methods=['POST'])
@require_admin_scope
def reboot_after_update():
import socket
data = request.get_json(silent=True)
if not isinstance(data, dict) or data.get('confirm') is not True or data.get('hostname') != socket.gethostname() or not isinstance(data.get('boot_id'), str) or not data['boot_id']:
return jsonify({'reason': 'confirmation'}), 400
power = current_app.extensions.get('host_update_power')
if not power:
return jsonify({'reason': 'unavailable'}), 503
from host_update_controls import reboot_context, COMMAND, UNIT
context = reboot_context()
if context.get('boot_id') != data['boot_id']:
return jsonify({'reason': 'confirmation'}), 409
if not context['allowed']:
return jsonify({'reason': context['reason']}), 409
ok, error = power['run'](UNIT, list(COMMAND), description='ProxMenux: reboot after host update',
extra_properties=['RuntimeMaxSec=75'])
if not ok:
return jsonify({'reason': 'unavailable'}), error.get('status', 500)
return jsonify({'accepted': True}), 202
@health_bp.route('/api/health/reboot-after-update/status', methods=['GET'])
@require_admin_scope
def reboot_after_update_status():
power = current_app.extensions.get('host_update_power')
if not power:
return jsonify({'state': 'unknown'}), 503
from host_update_controls import UNIT
return jsonify(power['state'](UNIT))
@health_bp.route('/api/health/status', methods=['GET'])
@require_auth
def get_health_status():
+3
View File
@@ -2748,6 +2748,9 @@ def _action_state(unit_name):
return result_dict
app.extensions['host_update_power'] = {'run': _action_run, 'state': _action_state}
def _action_cancel(unit_name):
"""Stop a running transient action unit. Idempotent — succeeds on
units that are not running (or don't exist).
+57
View File
@@ -0,0 +1,57 @@
"""Guarded reboot-after-update context; existing power API is unchanged."""
import fcntl
import os
from pathlib import Path
import socket
import stat
from kernel_reboot import reboot_status
from host_update_jobs import list_jobs
LOCK = '/run/proxmenux-host-update.lock'
BOOT_ID = Path('/proc/sys/kernel/random/boot_id')
UNIT = 'proxmenux-action-reboot-after-update'
# The unit obtains the SAME lock as CLI/legacy/web updates. Holding it for
# the shutdown transition closes the precheck-to-launch race. If an updater
# wins that race, flock exits75 and systemctl reboot is never invoked.
COMMAND = ['/usr/bin/flock', '-n', '-E', '75', LOCK, '/bin/sh', '-c',
'/bin/systemctl reboot && /bin/sleep 60']
def reboot_context():
result = dict(hostname=socket.gethostname(), allowed=False, reason='unknown')
try:
boot_id = BOOT_ID.read_text().strip()
if not boot_id:
return result
result['boot_id'] = boot_id
except OSError:
return result
try:
jobs = list_jobs()
if not isinstance(jobs, list) or any(not isinstance(job, dict) for job in jobs):
return result
except Exception:
return result
if any(job.get('state') != 'complete' for job in jobs):
return dict(result, reason='busy')
try:
fd = os.open(LOCK, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
except FileNotFoundError:
fd = None
except OSError:
return result
if fd is not None:
try:
if not stat.S_ISREG(os.fstat(fd).st_mode):
return result
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
return dict(result, reason='busy')
except OSError:
return result
finally:
os.close(fd)
reboot = reboot_status()
return dict(result, allowed=reboot['state']=='required',
reason='required' if reboot['state']=='required' else 'current' if reboot['state']=='current' else 'unknown')
@@ -0,0 +1,56 @@
"""Read-only version information. No installer or configuration writes."""
from datetime import datetime, timezone
from pathlib import Path
import re
import threading
import time
import urllib.request
ROOT = Path('/usr/local/share/proxmenux')
URLS = {
'stable': 'https://raw.githubusercontent.com/MacRimi/ProxMenux/main/version.txt',
'beta': 'https://raw.githubusercontent.com/MacRimi/ProxMenux/develop/beta_version.txt',
}
TTL = 1800
RETRY = 60
_lock = threading.Lock()
_cache = {}
def _version(text):
if not isinstance(text, str) or not re.fullmatch(r'\d{1,8}(?:\.\d{1,8}){1,5}', text):
raise ValueError('Invalid version')
parts = tuple(map(int, text.split('.')))
return parts + (0,) * (6-len(parts))
def version_status():
result = dict(state='unknown', installed=None, available=None, checked_at=None, channel='stable')
try:
beta = ROOT/'beta_version.txt'
channel = 'beta' if beta.exists() else 'stable'
result['channel'] = channel
installed = (beta if channel == 'beta' else ROOT/'version.txt').read_text().strip()
_version(installed)
result.update(installed=installed, channel=channel)
except (OSError, ValueError):
return result # No remote probe without a usable installed version.
with _lock:
now = time.monotonic()
cache = _cache.get(channel, {})
age = now-cache.get('attempt', -float('inf'))
if not 0 <= age < (RETRY if cache.get('failed') else TTL):
try:
req = urllib.request.Request(URLS[channel], headers={'User-Agent': 'ProxMenux-Monitor'})
with urllib.request.urlopen(req, timeout=3) as response:
latest = response.read(129).decode('utf-8').strip()
_version(latest)
cache = dict(attempt=time.monotonic(), version=latest, failed=False,
checked_at=datetime.now(timezone.utc).isoformat())
except (OSError, ValueError, UnicodeError):
cache = dict(cache, attempt=time.monotonic(), failed=True)
_cache[channel] = cache
result.update(available=cache.get('version'), checked_at=cache.get('checked_at'))
if cache.get('version') and not cache.get('failed'):
result['state'] = 'available' if _version(cache['version']) > _version(installed) else 'current'
return result