fix: read completed update results without a live terminal

This commit is contained in:
VAIO73
2026-10-28 17:30:44 +00:00
parent 889409de8d
commit ba3fb1197f
14 changed files with 289 additions and 11 deletions
@@ -42,6 +42,7 @@ import {
} from "lucide-react"
import { ScriptTerminalModal } from "./script-terminal-modal"
import { HostUpdateJobs, type HostUpdateJob } from "./host-update-jobs"
import { HostUpdateResult } from "./host-update-result"
import { RestartAfterUpdate, ProxMenuxVersionCard } from "./host-update-status"
import { useT } from "@/lib/i18n/provider"
import { useIsPbs } from "@/lib/use-platform"
@@ -134,6 +135,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
const [showUpdateTerminal, setShowUpdateTerminal] = useState(false)
const [hostJob, setHostJob] = useState<{ runId: string; resume: boolean; kind: "host" } | null>(null)
const [hostJobs, setHostJobs] = useState<HostUpdateJob[]>([])
const [resultJobId, setResultJobId] = useState<string | null>(null)
const healthRequest = useRef<AbortController | null>(null)
const previousAutoExpanded = useRef<Set<string>>(new Set())
@@ -790,6 +792,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
<HostUpdateJobs open={open} activeId={showUpdateTerminal ? hostJob?.runId : undefined}
onJobs={setHostJobs} onSettled={() => { void fetchHealthDetails(true) }}
onResume={job => {
if (job.state === "complete") { setResultJobId(job.id); return }
setHostJob({ runId: job.id, resume: true, kind: "host" })
setShowUpdateTerminal(true)
}} />
@@ -1069,6 +1072,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
</div>
)}
</DialogContent>
<HostUpdateResult jobId={resultJobId} getApiUrl={getApiUrl} onClose={() => setResultJobId(null)} />
<ScriptTerminalModal
key={hostJob?.runId || "host-update"}
open={showUpdateTerminal}
+10 -4
View File
@@ -20,6 +20,7 @@ export function HostUpdateJobs({ open, activeId, onResume, onJobs, onSettled }:
}) {
const t = useT()
const [jobs, setJobs] = useState<HostUpdateJob[]>([])
const [hidden, setHidden] = useState<Set<string>>(new Set())
const callbacks = useRef({ onJobs, onSettled })
callbacks.current = { onJobs, onSettled }
const observed = useRef(new Set<string>())
@@ -47,15 +48,20 @@ export function HostUpdateJobs({ open, activeId, onResume, onJobs, onSettled }:
const timer = setInterval(() => { void read() }, 5000)
return () => { closed = true; clearInterval(timer) }
}, [open])
const visible = jobs.filter(job => job.id !== activeId)
const visible = jobs.filter(job => job.id !== activeId && !(job.state === "complete" && hidden.has(job.id)))
if (!visible.length) return null
return <div className="space-y-2" role="status">
{visible.map(job => <div key={job.id}
className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-violet-500/30 bg-violet-500/5 p-3">
<p className="text-sm">{t(`healthStatus.hostUpdate.${job.state === "complete" ? "finished" : job.state === "attention" ? "attention" : "running"}`)}</p>
<Button size="sm" variant="outline" onClick={() => onResume(job)}>
{t("healthStatus.hostUpdate.view")}
</Button>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={() => onResume(job)}>
{t(job.state === "complete" ? "healthStatus.hostUpdate.viewResult" : "healthStatus.hostUpdate.view")}
</Button>
{job.state === "complete" && <Button size="sm" variant="ghost" onClick={() => setHidden(previous => new Set([...previous, job.id]))}>
{t("healthStatus.hostUpdate.hideResult")}
</Button>}
</div>
</div>)}
</div>
}
@@ -0,0 +1,77 @@
"use client"
import { useEffect, useRef, useState } from "react"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "./ui/dialog"
import { Button } from "./ui/button"
import { getAuthToken } from "../lib/api-config"
import { scriptCompletion } from "../lib/script-completion"
import { useT } from "../lib/i18n/provider"
import "xterm/css/xterm.css"
export function HostUpdateResult({ jobId, getApiUrl, onClose }: {
jobId: string | null
getApiUrl: (path: string) => string
onClose: () => void
}) {
const t = useT()
const container = useRef<HTMLDivElement>(null)
const [revision, setRevision] = useState(0)
const [exitCode, setExitCode] = useState<number | null>(null)
const [failed, setFailed] = useState(false)
useEffect(() => {
if (!jobId) return
const controller = new AbortController()
let terminal: import("xterm").Terminal | undefined
let observer: ResizeObserver | undefined
let disposed = false
setExitCode(null); setFailed(false)
const timeout = setTimeout(() => controller.abort(), 10000)
void (async () => {
try {
const token = getAuthToken()
const response = await fetch(getApiUrl(`/api/host-update-jobs/${encodeURIComponent(jobId)}/result`), {
headers: token ? { Authorization: `Bearer ${token}` } : {}, signal: controller.signal,
})
if (!response.ok) throw Error("unavailable")
const value = await response.json()
if (value.state !== "complete" || !Number.isSafeInteger(value.exit_code)
|| typeof value.text !== "string" || value.text.length > 256 * 1024
|| typeof value.truncated !== "boolean") throw Error("invalid result")
const [{ Terminal }, { FitAddon }] = await Promise.all([import("xterm"), import("xterm-addon-fit")])
if (controller.signal.aborted || !container.current) return
terminal = new Terminal({ disableStdin: true, convertEol: true, fontSize: 12,
screenReaderMode: true, theme: { background: "#000000", foreground: "#dddddd" } })
const fit = new FitAddon()
terminal.loadAddon(fit); terminal.open(container.current); fit.fit()
if (value.truncated) terminal.writeln(t("vmLxc.updates.backgroundOutputTrimmed"))
terminal.write(value.text)
observer = new ResizeObserver(() => { if (!controller.signal.aborted) fit.fit() })
observer.observe(container.current)
setExitCode(value.exit_code)
} catch {
if (!disposed) setFailed(true)
} finally { clearTimeout(timeout) }
})()
return () => { disposed = true; controller.abort(); clearTimeout(timeout); observer?.disconnect(); terminal?.dispose() }
}, [jobId, getApiUrl, revision, t])
const kind = exitCode === null ? null : scriptCompletion(exitCode, 130).kind
return <Dialog open={jobId !== null} onOpenChange={open => { if (!open) onClose() }}>
<DialogContent className="max-w-4xl w-[95vw] max-h-[85vh] overflow-y-auto p-4 sm:p-5">
<DialogHeader><DialogTitle>{t("healthStatus.hostUpdate.resultTitle")}</DialogTitle>
<DialogDescription>{t("healthStatus.hostUpdate.resultReadOnly")}</DialogDescription></DialogHeader>
{failed && <div role="alert" className="text-sm text-amber-500">
<p>{t("healthStatus.hostUpdate.resultUnavailable")}</p>
<Button size="sm" variant="outline" className="mt-3" onClick={() => setRevision(value => value + 1)}>{t("healthStatus.hostUpdate.retryResult")}</Button>
</div>}
<div ref={container} className="h-[45vh] min-h-[180px] bg-black rounded overflow-hidden" />
<div className="flex items-center justify-between gap-3">
<p role="status" className={`text-xs ${exitCode === 0 ? "text-green-500" : "text-muted-foreground"}`}>
{kind === null ? t(failed ? "healthStatus.hostUpdate.resultUnavailable" : "healthStatus.hostUpdate.loadingResult")
: kind === "cancelled" ? t("scriptTerminal.cancelled") : exitCode === 0
? t("scriptTerminal.completedSuccessfully") : t("scriptTerminal.completedWithError", { code: exitCode })}
</p>
<Button size="sm" variant="outline" onClick={onClose}>{t("healthStatus.hostUpdate.closeResult")}</Button>
</div>
</DialogContent>
</Dialog>
}
+9 -1
View File
@@ -5334,9 +5334,17 @@
},
"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.",
"finished": "The last host update has finished. Its result is available.",
"attention": "The host update needs attention. It has not been stopped; reconnect to inspect it.",
"view": "View update",
"viewResult": "View result",
"hideResult": "Hide result",
"resultTitle": "Host update result",
"resultReadOnly": "Saved output of a completed update. Opening it does not run another update.",
"resultUnavailable": "The saved result could not be loaded. No update was started.",
"retryResult": "Reload result",
"loadingResult": "Loading saved result…",
"closeResult": "Close result",
"noReboot": "No reboot required",
"rebootMarker": "The operating system requires a reboot.",
"rebootUnknown": "Could not determine whether a reboot is required.",
+9 -1
View File
@@ -5206,9 +5206,17 @@
},
"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.",
"finished": "Posledná aktualizácia servera sa skončila. Jej výsledok je k dispozícii.",
"attention": "Aktualizácia servera potrebuje kontrolu. Nebola zastavená; otvor ju a skontroluj priebeh.",
"view": "Zobraziť aktualizáciu",
"viewResult": "Zobraziť výsledok",
"hideResult": "Skryť výsledok",
"resultTitle": "Výsledok aktualizácie servera",
"resultReadOnly": "Uložený výpis dokončenej aktualizácie. Jeho otvorenie nespustí ďalšiu aktualizáciu.",
"resultUnavailable": "Uložený výsledok sa nepodarilo načítať. Žiadna aktualizácia sa nespustila.",
"retryResult": "Znova načítať výsledok",
"loadingResult": "Načítava sa uložený výsledok…",
"closeResult": "Zavrieť výsledok",
"noReboot": "Reštart nie je potrebný",
"rebootMarker": "Operačný systém vyžaduje reštart.",
"rebootUnknown": "Nepodarilo sa zistiť, či je potrebný reštart.",
+18
View File
@@ -307,6 +307,7 @@ def get_full_health():
bg_key = '_bg_detailed'
bg_last = health_monitor.last_check_times.get(bg_key, 0)
bg_age = _time.time() - bg_last if type(bg_last) in (int, float) else float('inf')
publish_live = False
if 0 <= bg_age < 360 and bg_key in health_monitor.cached_results:
# Use cached result (at most ~5 min old)
@@ -318,12 +319,29 @@ def get_full_health():
else:
# No fresh cache, run live (first load or cache expired)
details = health_monitor.get_detailed_status()
if (not isinstance(details, dict) or not details.get('timestamp')
or details.get('overall') not in ('OK', 'INFO', 'WARNING', 'CRITICAL', 'UNKNOWN')
or not isinstance(details.get('summary'), str)
or not isinstance(details.get('details'), dict)
or not isinstance(details['details'].get('updates'), dict)):
raise ValueError('Health snapshot is incomplete')
# Publish an explicit check too: its caller may have invalidated
# the background cache after an update. A following read-only poll
# must see this result instead of waiting for another collector.
publish_live = True
# Installed/running kernel state changes outside Monitor jobs too.
# Overlay fresh read-only evidence without refreshing other checks or
# rewriting the background collector's snapshot/timestamp.
from kernel_reboot import refresh_health_reboot
details = refresh_health_reboot(details)
if publish_live:
checked_at = _time.time()
health_monitor.cached_results[bg_key] = details
health_monitor.cached_results['_bg_overall'] = {
'status': details['overall'], 'summary': details['summary']}
health_monitor.last_check_times[bg_key] = checked_at
health_monitor.last_check_times['_bg_overall'] = checked_at
active_errors = health_persistence.get_active_errors()
dismissed = health_persistence.get_dismissed_errors()
+9 -3
View File
@@ -1752,6 +1752,7 @@ def get_uptime():
_system_info_cache = {
'proxmox_version': None,
'proxmox_version_time': 0,
'proxmox_version_stamp': 0.0,
'available_updates': 0,
'available_updates_time': 0,
'available_updates_stamp': 0.0,
@@ -3507,14 +3508,16 @@ def get_coral_info():
def get_proxmox_version():
"""Get Proxmox version if available. Cached for 6 hours."""
"""Cache the installed PVE version until package state changes."""
global _system_info_cache
if is_pbs_host():
return get_platform().get('version')
now = time.time()
if _system_info_cache['proxmox_version'] is not None and \
now - _system_info_cache['proxmox_version_time'] < _SYSTEM_INFO_CACHE_TTL:
stamp = _apt_state_stamp()
age = now - _system_info_cache['proxmox_version_time']
if _system_info_cache['proxmox_version'] is not None and (0 <= age < _AVAILABLE_UPDATES_MIN_INTERVAL or
(stamp == _system_info_cache.get('proxmox_version_stamp') and 0 <= age < _SYSTEM_INFO_CACHE_TTL)):
return _system_info_cache['proxmox_version']
proxmox_version = None
@@ -3530,8 +3533,11 @@ def get_proxmox_version():
except Exception as e:
pass
if proxmox_version is None:
return None # Keep last-known cache intact, but do not present it as fresh.
_system_info_cache['proxmox_version'] = proxmox_version
_system_info_cache['proxmox_version_time'] = now
_system_info_cache['proxmox_version_stamp'] = stamp
return proxmox_version
def _apt_state_stamp():
+16
View File
@@ -215,6 +215,22 @@ def host_update_jobs_status():
return jsonify({'jobs': list_jobs()})
@terminal_bp.route('/api/host-update-jobs/<run_id>/result', methods=['GET'])
@require_admin_scope
def host_update_result(run_id):
"""Read a completed owner's retained output; never create or resume a job."""
from host_update_jobs import get
try:
view = get(run_id).read(0)
except (ValueError, KeyError):
return jsonify({'error': 'Host update result is unavailable'}), 404
if view['state'] != 'complete' or type(view['exit_code']) is not int:
return jsonify({'error': 'Host update has not completed'}), 409
response = jsonify({key: view[key] for key in ('state', 'exit_code', 'text', 'truncated')})
response.headers['Cache-Control'] = 'no-store'
return response
@sock.route('/ws/host-update/<run_id>')
def host_update_viewer(ws, run_id):
"""New fixed-script contract; existing script/LXC routes stay intact."""