mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-02 10:52:22 +00:00
Preserve host updates across sign-out and report pending kernel activation
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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}`
|
||||
|
||||
Reference in New Issue
Block a user