"use client" // Live inline card + detail modal for the post-boot restore. // // apply_cluster_postboot.sh writes /var/lib/proxmenux/restore-state.json // as it works through the milestones (apply cluster config, initramfs, // grub, per-component reinstalls, sanity check, finalize). The Flask // endpoints /api/host-backups/restore/{status,dismiss,history,log} // expose that state to this component. While the restore is running we // poll every 2s; once it's terminal (complete|failed) we back off to // 30s so the card can still be re-opened as a summary. Once the // operator hits Dismiss the card collapses and the History button // keeps the run browsable. import { useMemo, useState } from "react" import useSWR from "swr" import { Card, CardContent, CardHeader, CardTitle } from "./ui/card" import { Button } from "./ui/button" import { Badge } from "./ui/badge" import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, } from "./ui/dialog" import { ScrollArea } from "./ui/scroll-area" import { Loader2, CheckCircle2, XCircle, AlertTriangle, History, RotateCcw, ChevronRight, Cpu, FileText, ArrowDownAZ, Filter, } from "lucide-react" import { fetchApi } from "../lib/api-config" // ── Shape contracts with the backend ────────────────────────── interface RestoreComponent { name: string status: "installing" | "ok" | "failed" log: string exit_code?: string } interface RestoreSummary { hostname: string guests: string stubs: string stale_nodes: string components: string duration: string } interface RestoreRollback { vms_to_remove?: string[] lxcs_to_remove?: string[] components_to_uninstall?: string[] } interface DataPoolsImport { ok: string[] forced: string[] partial: string[] missing: string[] failed: string[] finished_at?: string log_path?: string } interface RestoreState { status: "running" | "complete" | "failed" started_at: string finished_at: string | null current_step: string steps_done: number steps_total: number log_path: string components: RestoreComponent[] rollback_delta: RestoreRollback sanity_warnings: string[] summary: RestoreSummary | null acknowledged: boolean duration?: string data_pools_import?: DataPoolsImport } interface HistoryEntry { file: string mtime: number status: string started_at: string | null finished_at: string | null duration: string | null } const fetcher = (url: string) => fetchApi(url) const COMPONENT_LABEL: Record = { nvidia_driver: "NVIDIA driver", amdgpu_top: "amdgpu_top", intel_gpu_tools: "Intel GPU tools", coral_driver: "Google Coral TPU driver", } const formatComponent = (name: string) => COMPONENT_LABEL[name] ?? name const formatIso = (iso: string | null | undefined) => { if (!iso) return "—" try { return new Date(iso).toLocaleString() } catch { return iso } } const formatRelative = (iso: string) => { try { const then = new Date(iso).getTime() const now = Date.now() const diff = Math.max(0, Math.round((now - then) / 1000)) if (diff < 60) return `${diff}s ago` if (diff < 3600) return `${Math.round(diff / 60)}m ago` if (diff < 86400) return `${Math.round(diff / 3600)}h ago` return `${Math.round(diff / 86400)}d ago` } catch { return iso } } // Rough time-remaining estimate derived from steps_done + elapsed. // Best-effort: at step 0 there's no data yet, so it returns // "estimating time…". After the run is terminal, "—". The output is // a full phrase so the caller doesn't have to add suffix words that // only make sense on some branches. const computeEta = (state: RestoreState): string => { if (state.status !== "running") return "—" if (!state.steps_done || state.steps_done <= 0) return "estimating time…" const elapsedSec = Math.max(1, Math.round((Date.now() - new Date(state.started_at).getTime()) / 1000)) const perStep = elapsedSec / state.steps_done const remaining = Math.max(0, state.steps_total - state.steps_done) const eta = Math.round(perStep * remaining) if (eta < 60) return `~${eta}s left` if (eta < 3600) return `~${Math.round(eta / 60)}m left` return `~${Math.round(eta / 3600)}h left` } // ── Small building blocks ───────────────────────────────────── const StatusBadge: React.FC<{ status: string }> = ({ status }) => { if (status === "running") return ( Restore in progress ) if (status === "complete") return ( Restore complete ) if (status === "failed") return ( Restore failed ) return {status} } const ComponentStatusIcon: React.FC<{ status: string }> = ({ status }) => { if (status === "installing") return if (status === "ok") return return } // ── Log viewer ──────────────────────────────────────────────── const LogViewer: React.FC<{ path: string | null; historyOnly?: boolean }> = ({ path, historyOnly }) => { const [filter, setFilter] = useState<"all" | "issues">("all") const swrKey = path ? `/api/host-backups/restore/log?filter=${filter}&tail=600${historyOnly ? `&path=${encodeURIComponent(path)}` : ""}` : null const { data, isLoading } = useSWR<{ lines: string[]; total_lines: number; path: string | null }>( swrKey, fetcher, { refreshInterval: historyOnly ? 0 : 4000 }, ) return (
{path ?? "no log yet"}
          {isLoading ? "Loading…" : (data?.lines?.join("\n") || "(no output)")}
        
) } // ── Rollback delta widget ───────────────────────────────────── const RollbackDelta: React.FC<{ delta: RestoreRollback | undefined }> = ({ delta }) => { const vms = delta?.vms_to_remove ?? [] const lxcs = delta?.lxcs_to_remove ?? [] const comps = delta?.components_to_uninstall ?? [] if (!vms.length && !lxcs.length && !comps.length) { return (
No entries exist on this host that weren't in the restored backup.
) } const Row: React.FC<{ label: string; items: string[]; cmd: (id: string) => string }> = ({ label, items, cmd }) => items.length === 0 ? null : (
{label}
{items.map((id) => ( {id} ))}
{items.length > 0 && (
Show manual cleanup commands
              {items.map(cmd).join("\n")}
            
)}
) return (
These entries exist on this host but were NOT in the restored backup. Review before removing.
`qm stop ${id} 2>/dev/null; qm destroy ${id} --purge`} /> `pct stop ${id} 2>/dev/null; pct destroy ${id} --purge`} /> `# uninstall ${name} manually via ProxMenux → Hardware & GPU`} />
) } // ── Detail modal ────────────────────────────────────────────── const RestoreDetailModal: React.FC<{ open: boolean onClose: () => void state: RestoreState historyMode?: boolean }> = ({ open, onClose, state, historyMode }) => { const progressPct = state.steps_total > 0 ? Math.round((state.steps_done / state.steps_total) * 100) : 0 return ( !v && onClose()}> Post-restore progress Started {formatIso(state.started_at)} {state.finished_at ? ` · finished ${formatIso(state.finished_at)}` : ""} {state.summary?.duration ? ` · ${state.summary.duration}` : ""}
{state.current_step || "—"} {state.steps_done}/{state.steps_total} steps {state.status === "running" && ` · ${computeEta(state)}`}
{state.components.length > 0 && (
Components
{state.components.map((c) => (
{formatComponent(c.name)} {c.status} {c.exit_code && exit {c.exit_code}}
{c.log && {c.log}}
))}
)} {state.sanity_warnings.length > 0 && (
Boot sanity warnings
    {state.sanity_warnings.map((w) => (
  • {w}
  • ))}
)} {state.data_pools_import && }
Rollback delta
Log
) } // Rendered inside RestoreDetailModal — one row per outcome category // (imported / forced / partial skip / missing skip / failed). const DataPoolsBlock: React.FC<{ section: DataPoolsImport }> = ({ section }) => { const total = section.ok.length + section.forced.length + section.partial.length + section.missing.length + section.failed.length if (total === 0) return null const Row: React.FC<{ label: string tone: "ok" | "warn" | "info" | "error" items: string[] help?: string }> = ({ label, tone, items, help }) => { if (items.length === 0) return null const toneClass = tone === "ok" ? "text-emerald-400" : tone === "warn" ? "text-amber-400" : tone === "error" ? "text-red-400" : "text-blue-400" return (
{tone === "ok" && } {tone === "warn" && } {tone === "error" && } {tone === "info" && } {label} ({items.length})
{items.join(", ")}
{help &&
{help}
}
) } return (
ZFS data pools — auto-import
{section.log_path && (
Log: {section.log_path}
)}
) } // ── History browser modal ───────────────────────────────────── const RestoreHistoryModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, onClose }) => { const { data } = useSWR<{ entries: HistoryEntry[] }>(open ? "/api/host-backups/restore/history" : null, fetcher) const [detailFile, setDetailFile] = useState(null) const { data: detailResp } = useSWR<{ state: RestoreState }>( detailFile ? `/api/host-backups/restore/history?file=${encodeURIComponent(detailFile)}` : null, fetcher, ) return ( <> !v && onClose()}> Past restores Restores archived by the post-boot dispatcher. The latest 20 are kept.
{(data?.entries ?? []).length === 0 ? (
No past restores recorded.
) : ( (data?.entries ?? []).map((e) => ( )) )}
{detailFile && detailResp?.state && ( setDetailFile(null)} state={detailResp.state} historyMode /> )} ) } // ── Main inline card ────────────────────────────────────────── export const RestoreProgressCard: React.FC = () => { const { data, mutate } = useSWR<{ state: RestoreState | null }>( "/api/host-backups/restore/status", fetcher, { refreshInterval: (latest) => (latest?.state?.status === "running" ? 2000 : 30000), revalidateOnFocus: true, }, ) const [detailOpen, setDetailOpen] = useState(false) const [historyOpen, setHistoryOpen] = useState(false) const [dismissing, setDismissing] = useState(false) const state = data?.state ?? null const progressPct = useMemo(() => { if (!state || state.steps_total <= 0) return 0 return Math.round((state.steps_done / state.steps_total) * 100) }, [state]) const dismiss = async () => { if (!state) return setDismissing(true) try { await fetchApi("/api/host-backups/restore/dismiss", { method: "POST" }) await mutate() } finally { setDismissing(false) } } // Hidden entirely when: no restore run has ever happened, OR the // last run is terminal AND acknowledged. History button is still // reachable from the main card header (rendered elsewhere). if (!state) return null if (state.status !== "running" && state.acknowledged) { return (
setHistoryOpen(false)} />
) } const hasWarnings = state.sanity_warnings.length > 0 const pools = state.data_pools_import const poolCount = (pools?.ok.length ?? 0) + (pools?.forced.length ?? 0) + (pools?.partial.length ?? 0) + (pools?.missing.length ?? 0) + (pools?.failed.length ?? 0) const poolWarnings = (pools?.partial.length ?? 0) + (pools?.missing.length ?? 0) + (pools?.failed.length ?? 0) const barColor = state.status === "failed" ? "bg-red-500" : state.status === "complete" ? "bg-emerald-500" : "bg-blue-500" return ( <>
Post-restore progress {hasWarnings && ( {state.sanity_warnings.length} boot warning{state.sanity_warnings.length === 1 ? "" : "s"} )} {poolCount > 0 && ( 0 ? "text-amber-400 border-amber-500/40 bg-amber-500/10 gap-1" : "text-emerald-400 border-emerald-500/40 bg-emerald-500/10 gap-1" } > {poolCount} ZFS pool{poolCount === 1 ? "" : "s"} {poolWarnings > 0 && ` · ${poolWarnings} need attention`} )}
{state.status !== "running" && ( )}
{state.current_step || "—"} · started {formatRelative(state.started_at)} {state.steps_done}/{state.steps_total} steps {state.status === "running" && ` · ${computeEta(state)}`} {state.summary?.duration && state.status !== "running" && ` · ${state.summary.duration}`}
{state.summary && (
Guests
{state.summary.guests}
Bind-mount stubs
{state.summary.stubs}
Stale nodes cleaned
{state.summary.stale_nodes}
Components
{state.summary.components}
)} setDetailOpen(false)} state={state} /> setHistoryOpen(false)} /> ) } export default RestoreProgressCard