mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-07-26 18:38:30 +00:00
create 1.2.2.3 beta
This commit is contained in:
@@ -46,6 +46,7 @@ import {
|
||||
History,
|
||||
} from "lucide-react"
|
||||
import { ScriptTerminalModal } from "./script-terminal-modal"
|
||||
import { RestoreProgressCard } from "./restore-progress-card"
|
||||
import { fetchApi, getApiUrl } from "../lib/api-config"
|
||||
import { fetchTerminalTicket } from "../lib/terminal-ws"
|
||||
import { formatStorage, formatBytes } from "../lib/utils"
|
||||
@@ -415,6 +416,12 @@ export function HostBackup() {
|
||||
|
||||
return (
|
||||
<div className="space-y-4 md:space-y-6">
|
||||
{/* ── Post-restore progress card ────────────────────
|
||||
Renders only while a restore is running or its
|
||||
summary hasn't been acknowledged. Once dismissed,
|
||||
it collapses to a "Past restores" ghost button. */}
|
||||
<RestoreProgressCard />
|
||||
|
||||
{/* ── Scheduled jobs ───────────────────────────────── */}
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
|
||||
@@ -2347,12 +2354,21 @@ function CreateJobDialog({
|
||||
if (mode === "attach" && !selectedPveJob) return
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
// "Import existing" mode: push the user-supplied keyfile to the
|
||||
// canonical shared path BEFORE anything else. This mirrors the
|
||||
// shell wizard where hb_pbs_import_keyfile runs before recovery
|
||||
// setup and before the job .env is written. If the upload fails
|
||||
// we bail loudly instead of silently downgrading to "generate new".
|
||||
if (backend === "pbs" && pbsEncryptMode === "existing") {
|
||||
// Encryption submit paths:
|
||||
// • enabled + keyfile already on disk → mode = "existing", no upload
|
||||
// (backend reuses the canonical file at _PBS_KEYFILE_PATH).
|
||||
// • enabled + no keyfile + Generate → mode = "new", backend creates it.
|
||||
// • enabled + no keyfile + Import → upload the file first, then
|
||||
// mode = "existing" so the backend uses the freshly-installed key.
|
||||
//
|
||||
// Only the third branch actually needs to POST to
|
||||
// /api/host-backups/pbs-encryption/import — the second is handled
|
||||
// server-side, and the first has nothing to upload.
|
||||
const needsImport =
|
||||
backend === "pbs" &&
|
||||
pbsEncryptMode === "existing" &&
|
||||
!pbsRecoveryStatus?.has_keyfile
|
||||
if (needsImport) {
|
||||
if (!pbsImportFile) {
|
||||
setError("Pick a keyfile to import.")
|
||||
setSubmitting(false)
|
||||
@@ -2995,51 +3011,100 @@ function CreateJobDialog({
|
||||
PBS organises backups into named groups, each with its own retention. Leave blank to use the automatic default for this host (recommended).
|
||||
</p>
|
||||
</div>
|
||||
{/* PBS client-side encryption — same flow as the
|
||||
shell wizard. The keyfile can be freshly generated
|
||||
per host or imported from an existing one the
|
||||
operator uses across every host. */}
|
||||
{/* PBS client-side encryption — mirrors the shell wizard:
|
||||
step 1 is a plain yes/no, step 2 only appears when
|
||||
no keyfile is installed yet. Once a keyfile exists,
|
||||
every future encrypted job silently reuses it. */}
|
||||
<div className="pt-2 border-t border-border space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label className="inline-flex items-center gap-1.5">
|
||||
<Lock className="h-3.5 w-3.5 text-emerald-400" />
|
||||
Encrypt backups (client-side keyfile)
|
||||
</Label>
|
||||
<Select
|
||||
value={pbsEncryptMode}
|
||||
onValueChange={(v) => setPbsEncryptMode(v as "none" | "new" | "existing")}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No encryption</SelectItem>
|
||||
<SelectItem value="new">Generate a new keyfile (per host — safest isolation)</SelectItem>
|
||||
<SelectItem value="existing">Import an existing keyfile (shared across hosts)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Adds <code className="font-mono">--keyfile</code> to <code className="font-mono">proxmox-backup-client backup</code>. Encryption happens before upload so chunks land on PBS already ciphered. The keyfile is installed at <code className="font-mono">/usr/local/share/proxmenux/pbs-key.conf</code> (chmod 0600) and reused by every encrypted PBS job on this host.
|
||||
</p>
|
||||
</div>
|
||||
{pbsEncryptMode === "existing" && (
|
||||
<div className="space-y-2 rounded-md border border-emerald-500/30 bg-emerald-500/5 p-3">
|
||||
<Label htmlFor="pbsImportFile" className="text-[11px] font-medium">
|
||||
Keyfile to import
|
||||
</Label>
|
||||
<Input
|
||||
id="pbsImportFile"
|
||||
type="file"
|
||||
accept=".conf,.key,application/json,text/plain"
|
||||
onChange={(e) => setPbsImportFile(e.target.files?.[0] ?? null)}
|
||||
disabled={pbsImportBusy}
|
||||
className="h-8 text-[11px]"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
The file is validated with <code className="font-mono">proxmox-backup-client key info</code> before being installed. If validation fails the job is not created and the existing keyfile (if any) stays in place.
|
||||
<label className="flex items-start gap-2 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={pbsEncrypt}
|
||||
onCheckedChange={(v) => {
|
||||
const checked = !!v
|
||||
if (!checked) {
|
||||
setPbsEncryptMode("none")
|
||||
} else {
|
||||
// Yes → if the host already has a keyfile, submit
|
||||
// "existing" (reuse it silently). If not, default
|
||||
// to "new" (Generate) — the operator can flip to
|
||||
// "existing" (Import) via the radio below.
|
||||
setPbsEncryptMode(pbsRecoveryStatus?.has_keyfile ? "existing" : "new")
|
||||
}
|
||||
}}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="inline-flex items-center gap-1.5 text-sm">
|
||||
<Lock className="h-3.5 w-3.5 text-emerald-400" />
|
||||
Encrypt backups (client-side keyfile)
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground mt-1">
|
||||
Adds <code className="font-mono">--keyfile</code> to <code className="font-mono">proxmox-backup-client backup</code>. Encryption happens before upload so chunks land on PBS already ciphered. The keyfile is installed at <code className="font-mono">/usr/local/share/proxmenux/pbs-key.conf</code> (chmod 0600) and reused by every encrypted PBS job on this host.
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{pbsEncrypt && pbsRecoveryStatus?.has_keyfile && (
|
||||
<div className="rounded-md border border-emerald-500/30 bg-emerald-500/5 p-3 text-[11px] text-muted-foreground flex items-start gap-2">
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-400 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
An encryption key is already installed on this host — it will be reused for this job. To replace it, first import a new one from Settings → Host backup or remove the existing keyfile.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pbsEncrypt && !pbsRecoveryStatus?.has_keyfile && (
|
||||
<div className="space-y-2 rounded-md border border-emerald-500/30 bg-emerald-500/5 p-3">
|
||||
<div className="text-[11px] font-medium">
|
||||
No encryption key is stored on this host. Choose how to set one up:
|
||||
</div>
|
||||
<label className="flex items-start gap-2 cursor-pointer text-[11px]">
|
||||
<input
|
||||
type="radio"
|
||||
name="pbsEncryptSetupCreate"
|
||||
checked={pbsEncryptMode === "new"}
|
||||
onChange={() => setPbsEncryptMode("new")}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">Generate a new keyfile (per host — safest isolation)</div>
|
||||
<div className="text-muted-foreground">Creates a fresh keyfile at <code className="font-mono">/usr/local/share/proxmenux/pbs-key.conf</code>. Backup up the recovery passphrase (below) to survive host loss.</div>
|
||||
</div>
|
||||
</label>
|
||||
<label className="flex items-start gap-2 cursor-pointer text-[11px]">
|
||||
<input
|
||||
type="radio"
|
||||
name="pbsEncryptSetupCreate"
|
||||
checked={pbsEncryptMode === "existing"}
|
||||
onChange={() => setPbsEncryptMode("existing")}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">Import an existing keyfile (shared across hosts)</div>
|
||||
<div className="text-muted-foreground">Use the same keyfile another host already has — enables cross-host restore of encrypted backups.</div>
|
||||
</div>
|
||||
</label>
|
||||
{pbsEncryptMode === "existing" && (
|
||||
<div className="space-y-1.5 pt-1">
|
||||
<Label htmlFor="pbsImportFile" className="text-[11px] font-medium">
|
||||
Keyfile to import
|
||||
</Label>
|
||||
<Input
|
||||
id="pbsImportFile"
|
||||
type="file"
|
||||
accept=".conf,.key,application/json,text/plain"
|
||||
onChange={(e) => setPbsImportFile(e.target.files?.[0] ?? null)}
|
||||
disabled={pbsImportBusy}
|
||||
className="h-8 text-[11px]"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
Validated with <code className="font-mono">proxmox-backup-client key info</code> before install. On validation failure the job is not created and no keyfile is written.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pbsEncrypt && (
|
||||
<div className="pl-7 space-y-2 rounded-md border border-blue-500/30 bg-blue-500/5 p-3">
|
||||
<div className="text-[11px] font-medium text-foreground flex items-center gap-1.5">
|
||||
@@ -3562,10 +3627,14 @@ function ManualBackupDialog({
|
||||
if (!canSubmit) return
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
// "Import existing" mode: same shape as CreateJobDialog — push the
|
||||
// keyfile to the canonical path before anything else, bail loudly
|
||||
// if validation fails so we don't silently fall back to "new".
|
||||
if (backend === "pbs" && pbsEncryptMode === "existing") {
|
||||
// Same three encryption submit paths as CreateJobDialog. Only
|
||||
// "Import + no keyfile installed yet" actually uploads; the on-disk
|
||||
// reuse case sends mode=existing without touching the file.
|
||||
const needsImport =
|
||||
backend === "pbs" &&
|
||||
pbsEncryptMode === "existing" &&
|
||||
!pbsRecoveryStatus?.has_keyfile
|
||||
if (needsImport) {
|
||||
if (!pbsImportFile) {
|
||||
setError("Pick a keyfile to import.")
|
||||
setSubmitting(false)
|
||||
@@ -3827,46 +3896,91 @@ function ManualBackupDialog({
|
||||
/>
|
||||
</div>
|
||||
<div className="pt-2 border-t border-border space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label className="inline-flex items-center gap-1.5">
|
||||
<Lock className="h-3.5 w-3.5 text-emerald-400" />
|
||||
Encrypt this backup (client-side keyfile)
|
||||
</Label>
|
||||
<Select
|
||||
value={pbsEncryptMode}
|
||||
onValueChange={(v) => setPbsEncryptMode(v as "none" | "new" | "existing")}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No encryption</SelectItem>
|
||||
<SelectItem value="new">Generate a new keyfile (per host — safest isolation)</SelectItem>
|
||||
<SelectItem value="existing">Import an existing keyfile (shared across hosts)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Uses the shared keyfile at <code className="font-mono">/usr/local/share/proxmenux/pbs-key.conf</code>.
|
||||
</p>
|
||||
</div>
|
||||
{pbsEncryptMode === "existing" && (
|
||||
<div className="space-y-2 rounded-md border border-emerald-500/30 bg-emerald-500/5 p-3">
|
||||
<Label htmlFor="manualPbsImportFile" className="text-[11px] font-medium">
|
||||
Keyfile to import
|
||||
</Label>
|
||||
<Input
|
||||
id="manualPbsImportFile"
|
||||
type="file"
|
||||
accept=".conf,.key,application/json,text/plain"
|
||||
onChange={(e) => setPbsImportFile(e.target.files?.[0] ?? null)}
|
||||
disabled={pbsImportBusy}
|
||||
className="h-8 text-[11px]"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
Validated with <code className="font-mono">proxmox-backup-client key info</code> before install.
|
||||
<label className="flex items-start gap-2 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={pbsEncrypt}
|
||||
onCheckedChange={(v) => {
|
||||
const checked = !!v
|
||||
if (!checked) {
|
||||
setPbsEncryptMode("none")
|
||||
} else {
|
||||
setPbsEncryptMode(pbsRecoveryStatus?.has_keyfile ? "existing" : "new")
|
||||
}
|
||||
}}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="inline-flex items-center gap-1.5 text-sm">
|
||||
<Lock className="h-3.5 w-3.5 text-emerald-400" />
|
||||
Encrypt this backup (client-side keyfile)
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground mt-1">
|
||||
Uses the shared keyfile at <code className="font-mono">/usr/local/share/proxmenux/pbs-key.conf</code>.
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{pbsEncrypt && pbsRecoveryStatus?.has_keyfile && (
|
||||
<div className="rounded-md border border-emerald-500/30 bg-emerald-500/5 p-3 text-[11px] text-muted-foreground flex items-start gap-2">
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-400 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
An encryption key is already installed on this host — it will be reused for this backup.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pbsEncrypt && !pbsRecoveryStatus?.has_keyfile && (
|
||||
<div className="space-y-2 rounded-md border border-emerald-500/30 bg-emerald-500/5 p-3">
|
||||
<div className="text-[11px] font-medium">
|
||||
No encryption key is stored on this host. Choose how to set one up:
|
||||
</div>
|
||||
<label className="flex items-start gap-2 cursor-pointer text-[11px]">
|
||||
<input
|
||||
type="radio"
|
||||
name="manualPbsEncryptSetup"
|
||||
checked={pbsEncryptMode === "new"}
|
||||
onChange={() => setPbsEncryptMode("new")}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">Generate a new keyfile (per host — safest isolation)</div>
|
||||
<div className="text-muted-foreground">Creates a fresh keyfile. Set a recovery passphrase (below) to survive host loss.</div>
|
||||
</div>
|
||||
</label>
|
||||
<label className="flex items-start gap-2 cursor-pointer text-[11px]">
|
||||
<input
|
||||
type="radio"
|
||||
name="manualPbsEncryptSetup"
|
||||
checked={pbsEncryptMode === "existing"}
|
||||
onChange={() => setPbsEncryptMode("existing")}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">Import an existing keyfile (shared across hosts)</div>
|
||||
<div className="text-muted-foreground">Reuse the same keyfile another host already has.</div>
|
||||
</div>
|
||||
</label>
|
||||
{pbsEncryptMode === "existing" && (
|
||||
<div className="space-y-1.5 pt-1">
|
||||
<Label htmlFor="manualPbsImportFile" className="text-[11px] font-medium">
|
||||
Keyfile to import
|
||||
</Label>
|
||||
<Input
|
||||
id="manualPbsImportFile"
|
||||
type="file"
|
||||
accept=".conf,.key,application/json,text/plain"
|
||||
onChange={(e) => setPbsImportFile(e.target.files?.[0] ?? null)}
|
||||
disabled={pbsImportBusy}
|
||||
className="h-8 text-[11px]"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
Validated with <code className="font-mono">proxmox-backup-client key info</code> before install.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pbsEncrypt && (
|
||||
<div className="pl-7 space-y-2 rounded-md border border-blue-500/30 bg-blue-500/5 p-3">
|
||||
<div className="text-[11px] font-medium text-foreground flex items-center gap-1.5">
|
||||
@@ -4436,7 +4550,7 @@ function DestinationsSection({
|
||||
PBS keyfile is missing — recover it from PBS
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground mt-0.5">
|
||||
An encrypted recovery copy of the keyfile is available ({pbsRecoveryAvailable!.snapshots.length} snapshot{pbsRecoveryAvailable!.snapshots.length === 1 ? "" : "s"}). Click to rebuild the keyfile using your recovery passphrase.
|
||||
An encrypted recovery copy of the keyfile is available ({pbsRecoveryAvailable!.snapshots.length} backup{pbsRecoveryAvailable!.snapshots.length === 1 ? "" : "s"}). Click to rebuild the keyfile using your recovery passphrase.
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-emerald-300 shrink-0 self-center">Recover →</span>
|
||||
@@ -7597,6 +7711,17 @@ function RestoreOptionsModal({
|
||||
|
||||
{step === "choose" && (
|
||||
<div className="space-y-3">
|
||||
{/* Post-restore Monitor hint — the Backups tab renders a live
|
||||
progress card driven by /var/lib/proxmenux/restore-state.json,
|
||||
so the operator has a place to watch component reinstalls,
|
||||
sanity warnings, and the rollback delta after the reboot. */}
|
||||
<div className="rounded-md border border-blue-500/40 bg-blue-500/5 p-3 text-[11px] text-muted-foreground flex items-start gap-2">
|
||||
<History className="h-3.5 w-3.5 text-blue-400 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
After the reboot, this Backups tab will show a live post-restore progress card with ETA, per-component status, log tail and rollback delta. If Telegram/Discord/ntfy notifications are configured, you'll also receive the "Host restore finished" event.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isBkOlder && (
|
||||
<div className="rounded-md border border-amber-500/50 bg-amber-500/5 p-3">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-wider text-amber-400 flex items-center gap-1.5">
|
||||
|
||||
@@ -271,7 +271,7 @@ export function Login({ onLogin }: LoginProps) {
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground">ProxMenux Monitor v1.2.2.2-beta</p>
|
||||
<p className="text-center text-sm text-muted-foreground">ProxMenux Monitor v1.2.2.3-beta</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -836,7 +836,7 @@ export function ProxmoxDashboard() {
|
||||
</Tabs>
|
||||
|
||||
<footer className="mt-8 md:mt-12 pt-4 md:pt-6 border-t border-border text-center text-xs md:text-sm text-muted-foreground">
|
||||
<p className="font-medium mb-2">ProxMenux Monitor v1.2.2.2-beta</p>
|
||||
<p className="font-medium mb-2">ProxMenux Monitor v1.2.2.3-beta</p>
|
||||
<p>
|
||||
<a
|
||||
href="https://ko-fi.com/macrimi"
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Dialog, DialogContent, DialogTitle } from "./ui/dialog"
|
||||
import { X, Sparkles, Thermometer, Activity, HardDrive, Shield, Globe, Cpu, Zap, Sliders, Wrench, RefreshCw, Server, BellOff, Bell, Calendar, DatabaseBackup } from "lucide-react"
|
||||
import { Checkbox } from "./ui/checkbox"
|
||||
|
||||
const APP_VERSION = "1.2.2.2-beta" // Sync with AppImage/package.json
|
||||
const APP_VERSION = "1.2.2.3-beta" // Sync with AppImage/package.json
|
||||
|
||||
interface ReleaseNote {
|
||||
date: string
|
||||
@@ -220,9 +220,17 @@ const CURRENT_VERSION_FEATURES = [
|
||||
icon: <DatabaseBackup className="h-5 w-5" />,
|
||||
text: "New Backups section integrated in the Monitor — create, schedule and restore host backups against Local, PBS or Borg destinations; run on a proper systemd timer or attach to an existing PVE vzdump job with live-inherited retention; PBS encryption with paired recovery blobs; and a direction-aware restore flow that safely handles cross-kernel jumps by reapplying IOMMU / VFIO / GRUB tunings, protecting critical packages from cascade-remove, and auto-remapping NICs after a motherboard swap.",
|
||||
},
|
||||
{
|
||||
icon: <RefreshCw className="h-5 w-5" />,
|
||||
text: "Live post-restore progress — after a reboot the Backups tab shows a real-time progress card with step-by-step milestones, ETA, per-component status (NVIDIA, Intel GPU tools, Coral, AMD tools), boot sanity warnings, a rollback delta widget listing VMs / LXCs / components that exist on the host but weren't in the backup, and a log tail with an Issues-only filter. Past restores are archived and browsable.",
|
||||
},
|
||||
{
|
||||
icon: <Shield className="h-5 w-5" />,
|
||||
text: "PBS encryption flow reworked — first a yes/no dialog; only when a keyfile is not yet installed does a second menu ask whether to generate a new one or import an existing one (shared across hosts). Subsequent encrypted backups reuse the installed key silently. Cancelling never leaves a phantom keyfile behind.",
|
||||
},
|
||||
{
|
||||
icon: <HardDrive className="h-5 w-5" />,
|
||||
text: "Storage & Hardware — per-disk cards in a responsive grid; USB-NVMe enclosures (ASMedia / JMicron / Realtek) now show the drive's real model, serial, temperature and health instead of the bridge's identity; USB-SATA SSDs (ASM105x) no longer flip to HDD; consistent blue / amber / red capacity palette across the app; multi-TB PBS datastores render in the right unit.",
|
||||
text: "Storage & Hardware — per-disk cards in a responsive grid; USB-NVMe enclosures (ASMedia / JMicron / Realtek) now show the drive's real model, serial, temperature and health instead of the bridge's identity; USB detection now walks the sysfs device path instead of trusting `/sys/block/*/removable`, so USB-NVMe and USB-HDD enclosures that report removable=0 finally reach the snt* pass-through and expose full SMART (temperature, power-on hours, power cycles, health); USB-SATA SSDs (ASM105x) no longer flip to HDD; consistent blue / amber / red capacity palette across the app; multi-TB PBS datastores render in the right unit.",
|
||||
},
|
||||
{
|
||||
icon: <Activity className="h-5 w-5" />,
|
||||
|
||||
593
AppImage/components/restore-progress-card.tsx
Normal file
593
AppImage/components/restore-progress-card.tsx
Normal file
@@ -0,0 +1,593 @@
|
||||
"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 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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// Rough ETA derived from steps_done + elapsed. Best-effort: skips at
|
||||
// step 0 (no data yet) and returns "—" once the run is terminal.
|
||||
const computeEta = (state: RestoreState): string => {
|
||||
if (state.status !== "running") return "—"
|
||||
if (!state.steps_done || state.steps_done <= 0) return "estimating…"
|
||||
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`
|
||||
if (eta < 3600) return `~${Math.round(eta / 60)}m`
|
||||
return `~${Math.round(eta / 3600)}h`
|
||||
}
|
||||
|
||||
// ── 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
|
||||
{state.status === "running" && ` · ETA ${computeEta(state)}`}
|
||||
</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>
|
||||
)}
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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
|
||||
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>
|
||||
)}
|
||||
</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
|
||||
{state.status === "running" && ` · ETA ${computeEta(state)}`}
|
||||
{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
|
||||
@@ -299,6 +299,11 @@ const initMessage = {
|
||||
setTimeout(() => {
|
||||
if (fitAddonRef.current && termRef.current) {
|
||||
fitAddonRef.current.fit()
|
||||
// Send the keyboard to the xterm instance so any --yesno /
|
||||
// --menu the script opens receives Enter / arrow keys
|
||||
// directly. Without this the modal's Close button (or any
|
||||
// other focusable descendant) intercepts the keystrokes.
|
||||
termRef.current.focus()
|
||||
}
|
||||
}, 100)
|
||||
|
||||
@@ -682,6 +687,13 @@ const initMessage = {
|
||||
}}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
// Radix defaults to focusing the first focusable descendant
|
||||
// when a Dialog opens — that used to land on the Close button
|
||||
// and every Enter/Space keystroke intended for the shell
|
||||
// dialogs INSIDE the terminal would close the modal instead.
|
||||
// Preempting the auto-focus lets the xterm focus() call in
|
||||
// initializeTerminal below own the keyboard from the start.
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
hideClose
|
||||
>
|
||||
<DialogTitle className="sr-only">{title}</DialogTitle>
|
||||
|
||||
@@ -3717,7 +3717,7 @@ ${observationsHtml}
|
||||
<!-- Footer -->
|
||||
<div class="rpt-footer">
|
||||
<div>Report generated by ProxMenux Monitor</div>
|
||||
<div>ProxMenux Monitor v1.2.2.2-beta</div>
|
||||
<div>ProxMenux Monitor v1.2.2.3-beta</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user