"use client" import { 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 } from "./ui/dialog" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select" import { DatabaseBackup, Clock, HardDrive, Server, CheckCircle2, AlertTriangle, XCircle, Loader2, PlayCircle, Archive, FileSearch, Calendar, } from "lucide-react" import { fetchApi } from "../lib/api-config" import { formatStorage } from "../lib/utils" // ── Shape contracts with the backend (flask_server.py: api_host_backups_*) ── interface BackupJob { id: string destination: string method: string // "local_tar" | "pbs" | "borg" | "unknown" on_calendar: string retention: string timer_enabled: boolean last_status: string | null next_run: string | null } interface BackupArchive { id: string // basename of the .tar file (also the URL slug) path: string // absolute path on host size_bytes: number mtime: number // unix seconds // From the backend identifier — see _identify_host_backup() in flask_server.py. // kind: "manual" / "scheduled" when we know; "legacy" when only the in-tar // marker confirmed it's a ProxMenux backup (no sidecar, no name match). job_id: string | null kind: "manual" | "scheduled" | "legacy" profile: string | null source_hostname: string | null // Which detection path identified this archive. Surfaced as a small tooltip // hint so the operator knows whether the metadata is authoritative // (sidecar) or inferred (filename / tar-peek). detected_via: "sidecar" | "job_id_match" | "hostcfg_prefix" | "tar_peek" } interface ManifestSourceHost { hostname: string pve_version: string | null roles: string[] kernel: string boot_mode: string cpu_model: string memory_kb: number } interface PreflightCheck { id: string severity: "pass" | "warn" | "fail" message: string details: Record | null } interface PreflightReport { source_host_at_backup: ManifestSourceHost selected_mode: { mode: string paths_include: string[] paths_exclude: string[] components_include: string[] storage_apply: boolean network_apply: boolean } preflight: { checks: PreflightCheck[] summary: { pass: number; warn: number; fail: number } } storage: { zfs: Array<{ name: string; action: string; present: string[]; missing: string[] }> lvm: Array<{ name: string; action: string }> pve_storage: Array<{ id: string; type: string; action: string; note: string | null }> in_selected_mode: boolean } network: { keep: Array<{ ifname: string; mac: string }> remap: Array<{ source_ifname: string; destination_ifname: string; mac: string }> orphan: Array<{ source_ifname: string; source_mac: string }> new: Array<{ ifname: string; mac: string }> in_selected_mode: boolean } driver_reinstall: { plan: Array<{ component_id: string type: string version: string installer: string | null action: string reason: string }> } abort_reason: string | null } const fetcher = async (url: string) => fetchApi(url) const formatMtime = (mtime: number) => new Date(mtime * 1000).toLocaleString(undefined, { year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", }) const formatNext = (iso: string | null) => { if (!iso) return "—" try { return new Date(iso).toLocaleString() } catch { return iso } } export function HostBackup() { const { data: jobsResp, error: jobsErr } = useSWR<{ jobs: BackupJob[] }>( "/api/host-backups/jobs", fetcher, { refreshInterval: 30000 }, ) const { data: archivesResp, error: archivesErr } = useSWR<{ archives: BackupArchive[] }>( "/api/host-backups/archives", fetcher, { refreshInterval: 30000 }, ) const [inspectingArchive, setInspectingArchive] = useState(null) return (
{/* ── Scheduled jobs ───────────────────────────────── */}
Scheduled Backup Jobs
{jobsResp?.jobs?.length ?? 0}
{jobsErr ? (
Failed to load jobs
) : !jobsResp ? (
Loading...
) : jobsResp.jobs.length === 0 ? (

No scheduled backup jobs configured yet.

For a one-shot manual backup{" "} or to create a scheduled job, run:

bash /usr/local/share/proxmenux/scripts/backup_restore/backup_host.sh

Menu options 1-6 are manual backups (default or custom paths, to PBS, Borg, or local tar). Option 7 opens the scheduler if you want a recurring job.

) : (
{jobsResp.jobs.map((j) => ( ))}
ID Destination Method Schedule Last status Next run
{j.id} {j.destination || "—"} {j.method} {j.on_calendar} {j.last_status ? ( {j.last_status} ) : ( never )} {formatNext(j.next_run)} {!j.timer_enabled && ( timer disabled )}
)}
{/* ── Available archives ─────────────────────────────── */}
Available Archives
{archivesResp?.archives?.length ?? 0}
{archivesErr ? (
Failed to load archives
) : !archivesResp ? (
Loading...
) : archivesResp.archives.length === 0 ? (
No backup archives found on this host. We scan /var/lib/vz/dump and any custom destination from a scheduled job, looking for files named hostcfg-<hostname>-*.tar.zst (manual backups) or <job_id>-*.tar.* (scheduled). PBS and Borg backups aren't surfaced in the UI yet.
) : (
{archivesResp.archives.map((a) => (
{a.id}
{formatMtime(a.mtime)} {formatStorage(a.size_bytes)} {a.kind === "scheduled" && a.job_id ? ( job: {a.job_id} ) : a.kind === "legacy" ? ( legacy ) : ( manual )} {a.source_hostname && a.source_hostname !== "" && ( host: {a.source_hostname} )}
))}
)}
{/* ── Inspect / preflight modal ──────────────────────── */} setInspectingArchive(null)} />
) } // ────────────────────────────────────────────────────────────── // Inspect modal — shows manifest summary + lets the operator pick // a restore mode and run the dry-run preflight + plan against this // host. No mutating actions; --apply stays on the CLI for 1.3.0. // ────────────────────────────────────────────────────────────── function InspectModal({ archive, onClose, }: { archive: BackupArchive | null onClose: () => void }) { const open = archive !== null const [mode, setMode] = useState("full") const [report, setReport] = useState(null) const [running, setRunning] = useState(false) const [error, setError] = useState(null) const { data: manifest, error: manifestErr } = useSWR<{ source_host: ManifestSourceHost proxmenux_installed_components: Array<{ id: string; version_at_backup: string | null }> vms_lxcs_at_backup: { vms: unknown[]; lxcs: unknown[] } storage_inventory?: { zfs_pools?: unknown[]; lvm?: { vgs?: unknown[] } } }>( archive ? `/api/host-backups/archives/${encodeURIComponent(archive.id)}/manifest` : null, fetcher, ) const runPreflight = async () => { if (!archive) return setRunning(true) setError(null) setReport(null) try { const res = await fetchApi( `/api/host-backups/archives/${encodeURIComponent(archive.id)}/preflight`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ mode }), }, ) setReport(res) } catch (e: any) { setError(e?.message || "Preflight failed") } finally { setRunning(false) } } // Reset state when archive changes const archiveId = archive?.id // Note: this useEffect-like cleanup happens via key={archiveId} on the // Dialog content so React unmounts and remounts; state resets naturally. return ( { if (!v) onClose() }}> {archive?.id} Inspect the manifest snapshot taken at backup time, then dry-run the restore plan for a chosen mode. Read-only; nothing on this host is changed. {/* Manifest summary */} {manifestErr ? (
Couldn't read the manifest from this archive — it may have been created before the manifest format was added.
) : !manifest ? (
Reading manifest...
) : ( )} {/* Preflight controls */}
{error && (
{error}
)} {report && }
) } // ── Manifest summary panel ─────────────────────────────────── function ManifestSummary({ manifest, }: { manifest: { source_host: ManifestSourceHost proxmenux_installed_components: Array<{ id: string; version_at_backup: string | null }> vms_lxcs_at_backup: { vms: unknown[]; lxcs: unknown[] } storage_inventory?: { zfs_pools?: unknown[]; lvm?: { vgs?: unknown[] } } } }) { const sh = manifest.source_host const zfsCount = manifest.storage_inventory?.zfs_pools?.length ?? 0 const lvmCount = manifest.storage_inventory?.lvm?.vgs?.length ?? 0 return (
} label="Source host" value={sh.hostname} />
{manifest.proxmenux_installed_components.length > 0 && (
ProxMenux components at backup time:
{manifest.proxmenux_installed_components.map((c) => ( {c.id}{c.version_at_backup ? ` @ ${c.version_at_backup}` : ""} ))}
)}
) } function Field({ icon, label, value, mono }: { icon?: React.ReactNode; label: string; value: string; mono?: boolean }) { return (
{icon} {label}
{value}
) } // ── Preflight report view ──────────────────────────────────── function PreflightReportView({ report }: { report: PreflightReport }) { const { summary, checks } = report.preflight const passColor = "text-emerald-500" const warnColor = "text-amber-500" const failColor = "text-red-500" return (
{/* Summary line */}
{summary.pass} pass {summary.warn} warn {summary.fail} fail {summary.fail > 0 && ( --apply would be refused )}
{/* Per-check list */}
{checks.map((c) => { const color = c.severity === "pass" ? passColor : c.severity === "warn" ? warnColor : failColor const Icon = c.severity === "pass" ? CheckCircle2 : c.severity === "warn" ? AlertTriangle : XCircle return (
{c.id} {c.message}
) })}
{/* Storage / network counts */}
Storage [in mode: {String(report.storage.in_selected_mode)}]
{report.storage.zfs.length} ZFS pool(s) · {" "}{report.storage.lvm.length} LVM VG(s) · {" "}{report.storage.pve_storage.length} PVE storage(s)
Network [in mode: {String(report.network.in_selected_mode)}]
{report.network.keep.length} keep · {" "}{report.network.remap.length} remap · {" "}{report.network.orphan.length} orphan · {" "}{report.network.new.length} new
{/* Driver plan */} {report.driver_reinstall.plan.length > 0 && (
Driver reinstall plan ({report.driver_reinstall.plan.length})
{report.driver_reinstall.plan.map((p) => (
{p.component_id} {p.action}
))}
)} {/* Abort reason (if --apply would have been refused) */} {report.abort_reason && (
{report.abort_reason}
)}
) }