2026-07-05 16:50:06 +02:00
"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 [ ]
}
2026-07-20 19:11:29 +02:00
interface DataPoolsImport {
ok : string [ ]
forced : string [ ]
partial : string [ ]
missing : string [ ]
failed : string [ ]
finished_at? : string
log_path? : string
}
2026-07-05 16:50:06 +02:00
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
2026-07-20 19:11:29 +02:00
data_pools_import? : DataPoolsImport
2026-07-05 16:50:06 +02:00
}
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 < string , string > = {
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
}
}
2026-07-05 16:58:27 +02:00
// 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.
2026-07-05 16:50:06 +02:00
const computeEta = ( state : RestoreState ) : string = > {
if ( state . status !== "running" ) return "—"
2026-07-05 16:58:27 +02:00
if ( ! state . steps_done || state . steps_done <= 0 ) return "estimating time…"
2026-07-05 16:50:06 +02:00
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 )
2026-07-05 16:58:27 +02:00
if ( eta < 60 ) return ` ~ ${ eta } s left `
if ( eta < 3600 ) return ` ~ ${ Math . round ( eta / 60 ) } m left `
return ` ~ ${ Math . round ( eta / 3600 ) } h left `
2026-07-05 16:50:06 +02:00
}
// ── Small building blocks ─────────────────────────────────────
const StatusBadge : React.FC < { status : string } > = ( { status } ) = > {
if ( status === "running" )
return (
< Badge className = "bg-blue-500/10 border-blue-500/40 text-blue-300 gap-1" >
< Loader2 className = "h-3 w-3 animate-spin" / >
Restore in progress
< / Badge >
)
if ( status === "complete" )
return (
< Badge className = "bg-emerald-500/10 border-emerald-500/40 text-emerald-400 gap-1" >
< CheckCircle2 className = "h-3 w-3" / >
Restore complete
< / Badge >
)
if ( status === "failed" )
return (
< Badge className = "bg-red-500/10 border-red-500/40 text-red-400 gap-1" >
< XCircle className = "h-3 w-3" / >
Restore failed
< / Badge >
)
return < Badge variant = "outline" > { status } < / Badge >
}
const ComponentStatusIcon : React.FC < { status : string } > = ( { status } ) = > {
if ( status === "installing" )
return < Loader2 className = "h-3.5 w-3.5 animate-spin text-blue-400" / >
if ( status === "ok" )
return < CheckCircle2 className = "h-3.5 w-3.5 text-emerald-400" / >
return < XCircle className = "h-3.5 w-3.5 text-red-400" / >
}
// ── 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 (
< div className = "space-y-2" >
< div className = "flex items-center justify-between text-xs" >
< div className = "flex items-center gap-1 text-muted-foreground" >
< FileText className = "h-3.5 w-3.5" / >
{ path ? ? "no log yet" }
< / div >
< div className = "flex items-center gap-1" >
< Button
size = "sm"
variant = { filter === "all" ? "default" : "outline" }
className = "h-6 px-2 text-xs"
onClick = { ( ) = > setFilter ( "all" ) }
>
< ArrowDownAZ className = "h-3 w-3 mr-1" / >
Full
< / Button >
< Button
size = "sm"
variant = { filter === "issues" ? "default" : "outline" }
className = "h-6 px-2 text-xs"
onClick = { ( ) = > setFilter ( "issues" ) }
>
< Filter className = "h-3 w-3 mr-1" / >
Issues only
< / Button >
< / div >
< / div >
< ScrollArea className = "h-72 rounded-md border border-border bg-black/40" >
< pre className = "p-3 text-xs text-muted-foreground whitespace-pre-wrap font-mono leading-relaxed" >
{ isLoading ? "Loading…" : ( data ? . lines ? . join ( "\n" ) || "(no output)" ) }
< / pre >
< / ScrollArea >
< / div >
)
}
// ── 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 (
< div className = "text-xs text-muted-foreground" >
No entries exist on this host that weren ' t in the restored backup .
< / div >
)
}
const Row : React.FC < { label : string ; items : string [ ] ; cmd : ( id : string ) = > string } > = ( { label , items , cmd } ) = >
items . length === 0 ? null : (
< div className = "space-y-1" >
< div className = "text-xs font-medium text-muted-foreground" > { label } < / div >
< div className = "flex flex-wrap gap-1.5" >
{ items . map ( ( id ) = > (
< Badge key = { id } variant = "outline" className = "font-mono text-xs" >
{ id }
< / Badge >
) ) }
< / div >
{ items . length > 0 && (
< details className = "text-xs" >
< summary className = "cursor-pointer text-muted-foreground hover:text-foreground" >
Show manual cleanup commands
< / summary >
< pre className = "mt-1 p-2 rounded-md bg-black/40 text-xs text-muted-foreground font-mono" >
{ items . map ( cmd ) . join ( "\n" ) }
< / pre >
< / details >
) }
< / div >
)
return (
< div className = "space-y-3" >
< div className = "text-xs text-muted-foreground" >
These entries exist on this host but were NOT in the restored backup . Review before removing .
< / div >
< Row
label = "VMs created after the backup"
items = { vms }
cmd = { ( id ) = > ` qm stop ${ id } 2>/dev/null; qm destroy ${ id } --purge ` }
/ >
< Row
label = "LXCs created after the backup"
items = { lxcs }
cmd = { ( id ) = > ` pct stop ${ id } 2>/dev/null; pct destroy ${ id } --purge ` }
/ >
< Row
label = "Components installed after the backup"
items = { comps }
cmd = { ( name ) = > ` # uninstall ${ name } manually via ProxMenux → Hardware & GPU ` }
/ >
< / div >
)
}
// ── 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 (
< Dialog open = { open } onOpenChange = { ( v ) = > ! v && onClose ( ) } >
< DialogContent className = "max-w-3xl" >
< DialogHeader >
< DialogTitle className = "flex items-center gap-2" >
< RotateCcw className = "h-5 w-5 text-blue-500" / >
Post - restore progress
< StatusBadge status = { state . status } / >
< / DialogTitle >
< DialogDescription >
Started { formatIso ( state . started_at ) }
{ state . finished_at ? ` · finished ${ formatIso ( state . finished_at ) } ` : "" }
{ state . summary ? . duration ? ` · ${ state . summary . duration } ` : "" }
< / DialogDescription >
< / DialogHeader >
< div className = "space-y-4" >
< div className = "space-y-1" >
< div className = "flex justify-between text-xs text-muted-foreground" >
< span > { state . current_step || "—" } < / span >
< span >
{ state . steps_done } / { state . steps_total } steps
2026-07-05 16:58:27 +02:00
{ state . status === "running" && ` · ${ computeEta ( state ) } ` }
2026-07-05 16:50:06 +02:00
< / span >
< / div >
< div className = "h-2 rounded-full bg-muted overflow-hidden" >
< div
className = { ` h-full transition-all duration-500 ${
state . status === "failed" ? "bg-red-500" : state . status === "complete" ? "bg-emerald-500" : "bg-blue-500"
} ` }
style = { { width : ` ${ progressPct } % ` } }
/ >
< / div >
< / div >
{ state . components . length > 0 && (
< div className = "space-y-2" >
< div className = "text-sm font-medium flex items-center gap-2" >
< Cpu className = "h-4 w-4" / >
Components
< / div >
< div className = "space-y-1.5" >
{ state . components . map ( ( c ) = > (
< div
key = { c . name }
className = "flex items-center justify-between rounded-md border border-border bg-muted/30 px-3 py-2 text-xs"
>
< div className = "flex items-center gap-2" >
< ComponentStatusIcon status = { c . status } / >
< span className = "font-medium" > { formatComponent ( c . name ) } < / span >
< span className = "text-muted-foreground" > { c . status } < / span >
{ c . exit_code && < span className = "text-red-400" > exit { c . exit_code } < / span > }
< / div >
{ c . log && < span className = "text-muted-foreground font-mono" > { c . log } < / span > }
< / div >
) ) }
< / div >
< / div >
) }
{ state . sanity_warnings . length > 0 && (
< div className = "space-y-2" >
< div className = "text-sm font-medium flex items-center gap-2 text-amber-400" >
< AlertTriangle className = "h-4 w-4" / >
Boot sanity warnings
< / div >
< ul className = "list-disc list-inside text-xs text-muted-foreground space-y-1" >
{ state . sanity_warnings . map ( ( w ) = > (
< li key = { w } > { w } < / li >
) ) }
< / ul >
< / div >
) }
2026-07-20 19:11:29 +02:00
{ state . data_pools_import && < DataPoolsBlock section = { state . data_pools_import } / > }
2026-07-05 16:50:06 +02:00
< div className = "space-y-2" >
< div className = "text-sm font-medium" > Rollback delta < / div >
< RollbackDelta delta = { state . rollback_delta } / >
< / div >
< div className = "space-y-2" >
< div className = "text-sm font-medium" > Log < / div >
< LogViewer path = { state . log_path } historyOnly = { historyMode } / >
< / div >
< / div >
< DialogFooter >
< Button variant = "outline" onClick = { onClose } >
Close
< / Button >
< / DialogFooter >
< / DialogContent >
< / Dialog >
)
}
2026-07-21 18:26:11 +02:00
// Rendered inside RestoreDetailModal — one row per outcome category
// (imported / forced / partial skip / missing skip / failed).
2026-07-20 19:11:29 +02:00
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 (
< div className = "rounded-md border border-border bg-muted/30 px-3 py-2 text-xs" >
< div className = { ` font-medium ${ toneClass } flex items-center gap-2 ` } >
{ tone === "ok" && < CheckCircle2 className = "h-3.5 w-3.5" / > }
{ tone === "warn" && < AlertTriangle className = "h-3.5 w-3.5" / > }
{ tone === "error" && < XCircle className = "h-3.5 w-3.5" / > }
{ tone === "info" && < CheckCircle2 className = "h-3.5 w-3.5" / > }
< span > { label } < / span >
< span className = "text-muted-foreground" > ( { items . length } ) < / span >
< / div >
< div className = "mt-1 font-mono text-muted-foreground break-all" > { items . join ( ", " ) } < / div >
{ help && < div className = "mt-1 text-muted-foreground" > { help } < / div > }
< / div >
)
}
return (
< div className = "space-y-2" >
< div className = "text-sm font-medium flex items-center gap-2" >
< Cpu className = "h-4 w-4" / >
ZFS data pools — auto - import
< / div >
< div className = "space-y-1.5" >
< Row label = "Imported" tone = "ok" items = { section . ok } / >
< Row
label = "Imported (forced, foreign hostid)"
tone = "info"
items = { section . forced }
help = "New hostid grabbed onto the pool label — next boot imports clean."
/ >
< Row
label = "Skipped (some disks missing)"
tone = "warn"
items = { section . partial }
help = "Some vdev disks weren't found by /dev/disk/by-id. Pool NOT imported to avoid a degraded auto-import. Fix the disks or import manually with zpool import."
/ >
< Row
label = "Skipped (no disks present)"
tone = "warn"
items = { section . missing }
help = "None of the pool's disks are on this host. Move the disks over or import from a different host."
/ >
< Row
label = "Import failed"
tone = "error"
items = { section . failed }
help = "ZFS rejected the import even with -f. Inspect with `zpool import` and the log below."
/ >
< / div >
{ section . log_path && (
< div className = "text-xs text-muted-foreground font-mono" > Log : { section . log_path } < / div >
) }
< / div >
)
}
2026-07-05 16:50:06 +02:00
// ── 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 < string | null > ( null )
const { data : detailResp } = useSWR < { state : RestoreState } > (
detailFile ? ` /api/host-backups/restore/history?file= ${ encodeURIComponent ( detailFile ) } ` : null ,
fetcher ,
)
return (
< >
< Dialog open = { open } onOpenChange = { ( v ) = > ! v && onClose ( ) } >
< DialogContent className = "max-w-2xl" >
< DialogHeader >
< DialogTitle className = "flex items-center gap-2" >
< History className = "h-5 w-5" / >
Past restores
< / DialogTitle >
< DialogDescription >
Restores archived by the post - boot dispatcher . The latest 20 are kept .
< / DialogDescription >
< / DialogHeader >
< ScrollArea className = "h-96" >
< div className = "space-y-1.5" >
{ ( data ? . entries ? ? [ ] ) . length === 0 ? (
< div className = "text-sm text-muted-foreground py-6 text-center" > No past restores recorded . < / div >
) : (
( data ? . entries ? ? [ ] ) . map ( ( e ) = > (
< button
key = { e . file }
onClick = { ( ) = > setDetailFile ( e . file ) }
className = "w-full flex items-center justify-between rounded-md border border-border bg-muted/30 hover:bg-muted px-3 py-2 text-xs text-left"
>
< div className = "flex items-center gap-2" >
< StatusBadge status = { e . status } / >
< span className = "text-muted-foreground" >
{ e . started_at ? formatIso ( e . started_at ) : formatIso ( new Date ( e . mtime * 1000 ) . toISOString ( ) ) }
< / span >
{ e . duration && < span className = "text-muted-foreground" > · { e . duration } < / span > }
< / div >
< ChevronRight className = "h-4 w-4 text-muted-foreground" / >
< / button >
) )
) }
< / div >
< / ScrollArea >
< DialogFooter >
< Button variant = "outline" onClick = { onClose } >
Close
< / Button >
< / DialogFooter >
< / DialogContent >
< / Dialog >
{ detailFile && detailResp ? . state && (
< RestoreDetailModal
open = { ! ! detailFile }
onClose = { ( ) = > 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 (
< div className = "flex justify-end" >
< Button variant = "ghost" size = "sm" onClick = { ( ) = > setHistoryOpen ( true ) } >
< History className = "h-3.5 w-3.5 mr-1" / >
Past restores
< / Button >
< RestoreHistoryModal open = { historyOpen } onClose = { ( ) = > setHistoryOpen ( false ) } / >
< / div >
)
}
const hasWarnings = state . sanity_warnings . length > 0
2026-07-20 19:11:29 +02:00
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 )
2026-07-05 16:50:06 +02:00
const barColor =
state . status === "failed" ? "bg-red-500" : state . status === "complete" ? "bg-emerald-500" : "bg-blue-500"
return (
< >
< Card className = "bg-card border-border" >
< CardHeader className = "pb-3" >
< div className = "flex flex-wrap items-center justify-between gap-2" >
< CardTitle className = "text-base font-semibold flex items-center gap-2" >
< RotateCcw
className = { ` h-5 w-5 ${ state . status === "running" ? "text-blue-500 animate-spin" : "text-blue-500" } ` }
/ >
Post - restore progress
< StatusBadge status = { state . status } / >
{ hasWarnings && (
< Badge variant = "outline" className = "text-amber-400 border-amber-500/40 bg-amber-500/10 gap-1" >
< AlertTriangle className = "h-3 w-3" / >
{ state . sanity_warnings . length } boot warning { state . sanity_warnings . length === 1 ? "" : "s" }
< / Badge >
) }
2026-07-20 19:11:29 +02:00
{ poolCount > 0 && (
< Badge
variant = "outline"
className = {
poolWarnings > 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"
}
>
< Cpu className = "h-3 w-3" / >
{ poolCount } ZFS pool { poolCount === 1 ? "" : "s" }
{ poolWarnings > 0 && ` · ${ poolWarnings } need attention ` }
< / Badge >
) }
2026-07-05 16:50:06 +02:00
< / CardTitle >
< div className = "flex items-center gap-2" >
< Button size = "sm" variant = "outline" onClick = { ( ) = > setDetailOpen ( true ) } >
Details
< / Button >
< Button size = "sm" variant = "ghost" onClick = { ( ) = > setHistoryOpen ( true ) } >
< History className = "h-3.5 w-3.5 mr-1" / >
History
< / Button >
{ state . status !== "running" && (
< Button size = "sm" onClick = { dismiss } disabled = { dismissing } >
{ dismissing ? < Loader2 className = "h-3.5 w-3.5 animate-spin" / > : "Dismiss" }
< / Button >
) }
< / div >
< / div >
< / CardHeader >
< CardContent className = "space-y-3" >
< div className = "space-y-1" >
< div className = "flex justify-between text-xs text-muted-foreground" >
< span className = "truncate" >
{ state . current_step || "—" } · started { formatRelative ( state . started_at ) }
< / span >
< span >
{ state . steps_done } / { state . steps_total } steps
2026-07-05 16:58:27 +02:00
{ state . status === "running" && ` · ${ computeEta ( state ) } ` }
2026-07-05 16:50:06 +02:00
{ state . summary ? . duration && state . status !== "running" && ` · ${ state . summary . duration } ` }
< / span >
< / div >
< div className = "h-2 rounded-full bg-muted overflow-hidden" >
< div className = { ` h-full transition-all duration-500 ${ barColor } ` } style = { { width : ` ${ progressPct } % ` } } / >
< / div >
< / div >
{ state . summary && (
< div className = "grid grid-cols-2 md:grid-cols-4 gap-2 text-xs" >
< div className = "rounded-md border border-border bg-muted/30 px-2 py-1.5" >
< div className = "text-muted-foreground" > Guests < / div >
< div className = "font-medium" > { state . summary . guests } < / div >
< / div >
< div className = "rounded-md border border-border bg-muted/30 px-2 py-1.5" >
< div className = "text-muted-foreground" > Bind - mount stubs < / div >
< div className = "font-medium" > { state . summary . stubs } < / div >
< / div >
< div className = "rounded-md border border-border bg-muted/30 px-2 py-1.5" >
< div className = "text-muted-foreground" > Stale nodes cleaned < / div >
< div className = "font-medium" > { state . summary . stale_nodes } < / div >
< / div >
< div className = "rounded-md border border-border bg-muted/30 px-2 py-1.5" >
< div className = "text-muted-foreground" > Components < / div >
< div className = "font-medium" > { state . summary . components } < / div >
< / div >
< / div >
) }
< / CardContent >
< / Card >
< RestoreDetailModal open = { detailOpen } onClose = { ( ) = > setDetailOpen ( false ) } state = { state } / >
< RestoreHistoryModal open = { historyOpen } onClose = { ( ) = > setHistoryOpen ( false ) } / >
< / >
)
}
export default RestoreProgressCard