"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" import { CheckCircle2, AlertTriangle, Info, ChevronRight } from "lucide-react" 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, unavailable = false, label, detail, icon }: { getApiUrl: Url; disabled: boolean; unavailable?: boolean; label: string; detail: string; icon: ReactNode }) { const t = useT() const [open, setOpen] = useState(false) const [context, setContext] = useState(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(null) useEffect(() => { alive.current = true; return () => { alive.current = false; postRequest.current?.abort() } }, []) useEffect(() => { 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() // A retained health snapshot must never grant permission after reconnect // or remount. This cheap read does not run APT or a full health check. const interval = setInterval(read, open ? 2000 : 30000) 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 || unavailable || !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
{icon}
{label}{detail}
{disabled &&

{t("healthStatus.restart.blocked")}

} {accepted &&

{t("healthStatus.restart.accepted")}

} {error && !open &&

{t("healthStatus.restart.unconfirmed")}

} {t("healthStatus.restart.title", { server: context?.hostname ?? "…" })} {t("healthStatus.restart.impact")} {(disabled || context?.reason === "busy") &&

{t("healthStatus.restart.blocked")}

} {context?.reason === "current" &&

{t("healthStatus.hostUpdate.noReboot")}

} {(error || context?.reason === "unknown") &&

{t("healthStatus.restart.unconfirmed")}

}
} 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(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" const versions =
{t("healthStatus.versions.installed")}
{value?.installed ?? t("healthStatus.versions.unverified")}
{t(state === "unknown" && value?.available ? "healthStatus.versions.lastKnown" : "healthStatus.versions.latest")}
{value?.available ?? t("healthStatus.versions.unverified")}
const metadata = <> {value &&

{t(`healthStatus.versions.${value.channel}`)}

} {value?.checked_at &&

{t("healthStatus.versions.checked", { time: new Date(value.checked_at).toLocaleString() })}

} if (state !== "available") return
{state === "current" ?
{state === "unknown" && versions} {metadata}
return

{t("healthStatus.versions.title")}

{versions} {metadata}
}