Preserve host updates across sign-out and report pending kernel activation

This commit is contained in:
VAIO73
2026-10-28 17:30:44 +00:00
parent 2d4b0cb746
commit f40e0a53e1
24 changed files with 1605 additions and 18 deletions
+28 -3
View File
@@ -41,6 +41,7 @@ import {
HelpCircle,
} from "lucide-react"
import { ScriptTerminalModal } from "./script-terminal-modal"
import { HostUpdateJobs, type HostUpdateJob } from "./host-update-jobs"
import { useT } from "@/lib/i18n/provider"
import { useIsPbs } from "@/lib/use-platform"
@@ -129,6 +130,8 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
const [dismissingKey, setDismissingKey] = useState<string | null>(null)
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set())
const [showUpdateTerminal, setShowUpdateTerminal] = useState(false)
const [hostJob, setHostJob] = useState<{ runId: string; resume: boolean; kind: "host" } | null>(null)
const [hostJobs, setHostJobs] = useState<HostUpdateJob[]>([])
const fetchHealthDetails = useCallback(async (force = false) => {
setLoading(true)
@@ -330,6 +333,9 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
"Active": t("healthStatus.details.active"),
"UP": t("healthStatus.details.up"),
"Kernel/PVE up to date": t("healthStatus.details.kernelUpToDate"),
"No reboot required": t("healthStatus.hostUpdate.noReboot"),
"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"),
"No security updates pending": t("healthStatus.details.noSecurityUpdates"),
"No container startup errors": t("healthStatus.details.noContainerErrors"),
@@ -341,6 +347,8 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
if (Object.prototype.hasOwnProperty.call(exact, value)) return exact[value]
let match = value.match(/^Latency ([\d.]+)ms to gateway$/)
const reboot = value.match(/^Reboot required: running (.+); installed (.+)$/)
if (reboot) return t("healthStatus.hostUpdate.rebootKernel", { running: reboot[1], installed: reboot[2] })
if (match) return t("healthStatus.details.gatewayLatency", { latency: match[1] })
match = value.match(/^(\d+) failed login attempts in 24h$/)
if (match) return t("healthStatus.details.failedLogins", { count: match[1] })
@@ -623,7 +631,7 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
"smart_health", "io_errors", "zfs_pools", "lvm_volumes", "lvm_check", "connectivity",
"qmp_communication", "container_startup", "vm_startup", "oom_killer", "cluster_mode",
"log_error_cascade", "log_error_spike", "log_persistent_errors", "log_critical_errors",
"pve_version", "security_updates", "system_age", "pending_updates", "kernel_pve", "uptime",
"pve_version", "security_updates", "system_age", "pending_updates", "kernel_pve", "uptime", "reboot_required",
"certificates", "login_attempts", "fail2ban", "proxmox_storages",
])
if (knownKeys.has(key)) return t(`healthStatus.checks.${key}`)
@@ -711,6 +719,13 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
</DialogDescription>
</DialogHeader>
<HostUpdateJobs open={open} activeId={showUpdateTerminal ? hostJob?.runId : undefined}
onJobs={setHostJobs} onSettled={() => { void fetchHealthDetails(true) }}
onResume={job => {
setHostJob({ runId: job.id, resume: true, kind: "host" })
setShowUpdateTerminal(true)
}} />
{loading && (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
@@ -845,11 +860,17 @@ 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" && status?.toUpperCase() !== "OK" && (
{key === "updates" && Number(categoryData?.count ?? 0) > 0 && (
<div className="flex justify-end px-3 py-2 pt-1">
<Button
size="sm"
onClick={() => setShowUpdateTerminal(true)}
disabled={hostJobs.some(job => job.state !== "complete")}
onClick={() => {
const runId = window.crypto?.randomUUID?.()
|| `host-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`
setHostJob({ runId, resume: false, kind: "host" })
setShowUpdateTerminal(true)
}}
className="bg-purple-600/15 hover:bg-purple-600/25 border border-purple-500/40 text-purple-300 hover:text-purple-200"
>
<ArrowUpCircle className="h-4 w-4 mr-1.5" />
@@ -968,7 +989,11 @@ export function HealthStatusModal({ open, onOpenChange, getApiUrl }: HealthStatu
)}
</DialogContent>
<ScriptTerminalModal
key={hostJob?.runId || "host-update"}
open={showUpdateTerminal}
backgroundUpdate={hostJob || undefined}
cancelledExitCode={130}
onComplete={() => { void fetchHealthDetails(true) }}
onClose={() => {
setShowUpdateTerminal(false)
// Force a fresh read (cache-busting via ?refresh=1) so the
+61
View File
@@ -0,0 +1,61 @@
"use client"
import { useEffect, useRef, useState } from "react"
import { Button } from "./ui/button"
import { fetchApi } from "../lib/api-config"
import { useT } from "../lib/i18n/provider"
export type HostUpdateJob = {
id: string
state: "starting" | "running" | "attention" | "finalizing" | "complete"
exit_code: number | null
}
export function HostUpdateJobs({ open, activeId, onResume, onJobs, onSettled }: {
open: boolean
activeId?: string
onResume: (job: HostUpdateJob) => void
onJobs: (jobs: HostUpdateJob[]) => void
onSettled: () => void
}) {
const t = useT()
const [jobs, setJobs] = useState<HostUpdateJob[]>([])
const callbacks = useRef({ onJobs, onSettled })
callbacks.current = { onJobs, onSettled }
const observed = useRef(new Set<string>())
useEffect(() => {
if (!open) return
let closed = false
const read = async () => {
try {
const data = await fetchApi<{ jobs: HostUpdateJob[] }>("/api/host-update-jobs")
if (closed) return
const next = Array.isArray(data.jobs) ? data.jobs : []
setJobs(next)
callbacks.current.onJobs(next)
for (const job of next) {
if (job.state === "complete" && !observed.current.has(job.id)) {
observed.current.add(job.id)
callbacks.current.onSettled()
}
}
} catch {
// Authentication recovery belongs to fetchApi. Never retry a start.
}
}
void read()
const timer = setInterval(() => { void read() }, 5000)
return () => { closed = true; clearInterval(timer) }
}, [open])
const visible = jobs.filter(job => job.id !== activeId)
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>)}
</div>
}
+17 -5
View File
@@ -54,8 +54,8 @@ interface ScriptTerminalModalProps {
completedSuccessfullyMessage?: string
completedWithErrorMessage?: (exitCode: number) => string
cancelledExitCode?: number
// Only LXC Updates opts into a separate server-owned job transport.
backgroundUpdate?: { runId: string; vmid: number; resume: boolean }
// Explicit server-owned transports; ordinary /ws/script keeps its contract.
backgroundUpdate?: { runId: string; vmid?: number; resume: boolean; kind?: "host" }
// Optional callback fired when the script's WebSocket closes
// (script_runner sends an exit code and then closes). Lets the
// parent auto-dismiss the modal — used by host-backup's Restore
@@ -178,7 +178,9 @@ export function ScriptTerminalModal({
}
}, 30000)
const initMessage = {
const initMessage = backgroundUpdate?.kind === "host" ? {
resume: true, offset: outputOffsetRef.current,
} : {
script_path: scriptPath,
params: paramsRef.current,
...(backgroundUpdate ? { resume: true, vmid: backgroundUpdate.vmid,
@@ -221,6 +223,10 @@ export function ScriptTerminalModal({
try {
const msg = JSON.parse(event.data)
if (msg.type === "host_update_state") {
if (msg.state === "attention") termRef.current?.writeln(t("healthStatus.hostUpdate.attention"))
return
}
if (msg.type === "job_output") {
setIsWaitingNextInteraction(false)
if (waitingTimeoutRef.current) clearTimeout(waitingTimeoutRef.current)
@@ -429,7 +435,9 @@ export function ScriptTerminalModal({
}
}, 30000)
const initMessage = {
const initMessage = backgroundUpdate?.kind === "host" ? {
resume: backgroundUpdate.resume, offset: outputOffsetRef.current,
} : {
script_path: scriptPath,
params: paramsRef.current,
...(backgroundUpdate ? { resume: backgroundUpdate.resume,
@@ -478,6 +486,10 @@ export function ScriptTerminalModal({
try {
const msg = JSON.parse(event.data)
if (msg.type === "host_update_state") {
if (msg.state === "attention") term.writeln(t("healthStatus.hostUpdate.attention"))
return
}
if (msg.type === "job_output") {
setIsWaitingNextInteraction(false)
if (waitingTimeoutRef.current) clearTimeout(waitingTimeoutRef.current)
@@ -752,7 +764,7 @@ export function ScriptTerminalModal({
const getScriptWebSocketUrl = (sid: string): string => {
const route = backgroundUpdate
? `/ws/lxc-update/${encodeURIComponent(backgroundUpdate.runId)}`
? `/ws/${backgroundUpdate.kind === "host" ? "host-update" : "lxc-update"}/${encodeURIComponent(backgroundUpdate.runId)}`
: `/ws/script/${sid}`
if (typeof window === "undefined") {
return `ws://localhost:${API_PORT}${route}`