update 1.2.2.2 beta

This commit is contained in:
MacRimi
2026-06-21 00:35:22 +02:00
parent 57e936785d
commit 8e92df5bd7
5 changed files with 1058 additions and 99 deletions

View File

@@ -36,6 +36,13 @@ import {
Power,
RefreshCw,
Lock,
Eye,
Network,
Cpu,
Package,
ShieldCheck,
Info,
ListTree,
} from "lucide-react"
import { fetchApi, getApiUrl } from "../lib/api-config"
import { formatStorage } from "../lib/utils"
@@ -676,12 +683,14 @@ export function HostBackup() {
const localJobId = u.local?.job_id
const localHost = u.local?.source_hostname
const localPath = u.local?.path
// Same palette as the DestinationRow accents so each
// backend reads identically across the two cards.
const sourceBadgeCls =
u.source === "pbs"
? "text-purple-300 border-purple-400/40 bg-purple-500/5"
? "text-purple-400 border-purple-500/20 bg-purple-500/10"
: u.source === "borg"
? "text-cyan-300 border-cyan-400/40 bg-cyan-500/5"
: "text-emerald-300 border-emerald-400/40 bg-emerald-500/5"
? "text-fuchsia-400 border-fuchsia-500/20 bg-fuchsia-500/10"
: "text-blue-400 border-blue-500/20 bg-blue-500/10"
return (
<button
key={`${u.source}:${u.display_id}`}
@@ -712,7 +721,7 @@ export function HostBackup() {
</span>
<span className="inline-flex items-center gap-1">
<HardDrive className="h-3 w-3" />
{formatStorage(u.size_bytes)}
{formatBytes(u.size_bytes)}
</span>
<span title={u.source_label}>
at: <code className="font-mono">{u.source_label}</code>
@@ -954,6 +963,7 @@ function InspectModal({
)
const [showArchiveFullLog, setShowArchiveFullLog] = useState(false)
const [showDeleteArchiveConfirm, setShowDeleteArchiveConfirm] = useState(false)
const [viewingContents, setViewingContents] = useState(false)
const [deletingArchive, setDeletingArchive] = useState(false)
const [archiveDeleteResult, setArchiveDeleteResult] = useState<string[] | null>(null)
@@ -1199,7 +1209,7 @@ function InspectModal({
<code className="font-mono">{remoteArc.backend === "pbs" ? `${remoteArc.backup_type}/${remoteArc.backup_id}` : remoteArc.backup_id}</code>
</div>
<div><span className="text-muted-foreground">Snapshot time:</span> {formatMtime(remoteArc.backup_time)}</div>
{remoteArc.size_bytes > 0 && <div><span className="text-muted-foreground">Size:</span> {formatStorage(remoteArc.size_bytes)}</div>}
{remoteArc.size_bytes > 0 && <div><span className="text-muted-foreground">Size:</span> {formatBytes(remoteArc.size_bytes)}</div>}
{remoteArc.owner && <div><span className="text-muted-foreground">Owner:</span> <code className="font-mono">{remoteArc.owner}</code></div>}
{remoteArc.borg_id && <div className="sm:col-span-2"><span className="text-muted-foreground">Borg id:</span> <code className="font-mono text-[10px] break-all">{remoteArc.borg_id}</code></div>}
</div>
@@ -1226,14 +1236,24 @@ function InspectModal({
The server will {remoteArc.backend === "pbs" ? "restore the snapshot from PBS" : "borg-extract the archive"} and pack it as a <code className="font-mono">.tar.zst</code>, then stream it to your browser. The extraction only starts when you click Download.
</p>
</div>
<Button onClick={downloadArchive} disabled={downloading}>
{downloading ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Download className="h-4 w-4 mr-2" />
)}
Download
</Button>
<div className="flex items-center gap-2 flex-wrap">
<Button
variant="outline"
onClick={() => setViewingContents(true)}
title="Extract this snapshot to a temp dir and show manifest, restore plan, files and metadata as a HTML report"
>
<Eye className="h-4 w-4 mr-2" />
View contents
</Button>
<Button onClick={downloadArchive} disabled={downloading}>
{downloading ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Download className="h-4 w-4 mr-2" />
)}
Download
</Button>
</div>
</div>
{exportTask && (
@@ -1247,7 +1267,7 @@ function InspectModal({
<div className="text-red-500 mt-1">{exportTask.error}</div>
)}
{exportTask.state === "completed" && exportTask.size_bytes > 0 && (
<div className="text-emerald-400">Packed size: {formatStorage(exportTask.size_bytes)}</div>
<div className="text-emerald-400">Packed size: {formatBytes(exportTask.size_bytes)}</div>
)}
</div>
)}
@@ -1356,6 +1376,14 @@ function InspectModal({
)}
Delete
</Button>
<Button
variant="outline"
onClick={() => setViewingContents(true)}
title="Extract this snapshot to a temp dir and show manifest, restore plan, files and metadata as a HTML report"
>
<Eye className="h-4 w-4 mr-2" />
View contents
</Button>
<Button
variant="outline"
onClick={runPreflight}
@@ -1459,6 +1487,17 @@ function InspectModal({
</DialogContent>
</Dialog>
{/* ── View contents — extract + parse the snapshot, show HTML ──── */}
<ArchiveContentsModal
open={viewingContents}
onClose={() => setViewingContents(false)}
source={(archive?.source as "pbs" | "borg" | "local" | null) ?? null}
repo_name={remoteArc?.repo_name}
snapshot={remoteArc?.snapshot}
path={localArc?.path}
display_id={archive?.display_id}
/>
{/* ── Confirm archive deletion ───────────────────────────── */}
<Dialog open={showDeleteArchiveConfirm} onOpenChange={setShowDeleteArchiveConfirm}>
<DialogContent className="max-w-md bg-card border-border">
@@ -1893,6 +1932,11 @@ function CreateJobDialog({
// only the passphrase. Mirrors hb_pbs_setup_recovery from the shell.
const [pbsRecoveryPass, setPbsRecoveryPass] = useState<string>("")
const [pbsRecoveryPass2, setPbsRecoveryPass2] = useState<string>("")
// Operator opted to replace an already-configured recovery escrow.
// Default behavior matches the shell: don't prompt for the
// passphrase if one is already saved — only show the inputs when
// there's no escrow yet OR the operator explicitly asks to change.
const [pbsRecoveryChange, setPbsRecoveryChange] = useState<boolean>(false)
// Whether the host already has an escrow blob configured. When
// present, the passphrase input becomes optional ("leave blank to
// keep saved"). Refreshed after a successful setup call.
@@ -2070,6 +2114,7 @@ function CreateJobDialog({
setPbsEncrypt(false)
setPbsRecoveryPass("")
setPbsRecoveryPass2("")
setPbsRecoveryChange(false)
setLocalDestDir("")
setBorgRepoSelected("")
setBorgPassphrase("")
@@ -2710,8 +2755,24 @@ function CreateJobDialog({
{destResp?.pbs?.length ? (
<div className="mt-1 flex items-center gap-2">
<Select value={pbsRepository} onValueChange={setPbsRepository}>
<SelectTrigger className="flex-1">
<SelectValue />
<SelectTrigger className="flex-1 min-w-0">
{/* Manual render: SelectValue's default mode
duplicates the item children and cuts
them in the middle on mobile. We render
"name — repository" ourselves with a
proper trailing ellipsis. */}
{(() => {
const sel = destResp.pbs.find((r) => r.repository === pbsRepository)
if (!sel) {
return <span className="truncate text-muted-foreground">Pick a PBS repository</span>
}
return (
<span className="truncate font-mono text-left">
{sel.name}
<span className="text-muted-foreground"> {sel.repository}</span>
</span>
)
})()}
</SelectTrigger>
<SelectContent>
{destResp.pbs.map((r) => (
@@ -2787,35 +2848,64 @@ function CreateJobDialog({
<AlertTriangle className="h-3.5 w-3.5 text-blue-400" />
Recovery passphrase (strongly recommended)
</div>
<p className="text-[11px] text-muted-foreground">
With a recovery passphrase, an encrypted copy of the keyfile is uploaded to PBS with every backup. If you lose this host, you can recover the keyfile on a fresh install with just the passphrase. Without it, losing the local keyfile means the encrypted backups become unrecoverable forever.
{pbsRecoveryStatus?.has_recovery && " A recovery blob is already configured — leave the fields blank to keep it."}
</p>
<div>
<Label htmlFor="pbsRecPass" className="text-[11px]">Passphrase</Label>
<Input
id="pbsRecPass"
type="password"
value={pbsRecoveryPass}
onChange={(e) => setPbsRecoveryPass(e.target.value)}
placeholder={pbsRecoveryStatus?.has_recovery ? "(unchanged — type to replace)" : "Long random string — write it down somewhere safe"}
className="font-mono mt-1 h-8 text-xs"
/>
</div>
<div>
<Label htmlFor="pbsRecPass2" className="text-[11px]">Confirm passphrase</Label>
<Input
id="pbsRecPass2"
type="password"
value={pbsRecoveryPass2}
onChange={(e) => setPbsRecoveryPass2(e.target.value)}
placeholder="Type it again"
className="font-mono mt-1 h-8 text-xs"
/>
{pbsRecoveryPass && pbsRecoveryPass2 && pbsRecoveryPass !== pbsRecoveryPass2 && (
<p className="text-[11px] text-red-400 mt-1">Passphrases don't match.</p>
)}
</div>
{pbsRecoveryStatus?.has_recovery && !pbsRecoveryChange ? (
/* Escrow already saved — mirror the shell: skip
the prompt unless the operator opts to change. */
<div className="flex items-start gap-2 text-[11px]">
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-400 mt-0.5 shrink-0" />
<div className="flex-1">
<div className="text-foreground">Recovery escrow already configured for this host.</div>
<div className="text-muted-foreground mt-0.5">Backups created by this job will reuse the existing passphrase. No need to type it again.</div>
</div>
<button
type="button"
onClick={() => setPbsRecoveryChange(true)}
className="text-blue-400 hover:text-blue-300 underline underline-offset-2 shrink-0"
>
Change
</button>
</div>
) : (
<>
<p className="text-[11px] text-muted-foreground">
With a recovery passphrase, an encrypted copy of the keyfile is uploaded to PBS with every backup. If you lose this host, you can recover the keyfile on a fresh install with just the passphrase. Without it, losing the local keyfile means the encrypted backups become unrecoverable forever.
</p>
<div>
<Label htmlFor="pbsRecPass" className="text-[11px]">Passphrase</Label>
<Input
id="pbsRecPass"
type="password"
value={pbsRecoveryPass}
onChange={(e) => setPbsRecoveryPass(e.target.value)}
placeholder="Long random string — write it down somewhere safe"
className="font-mono mt-1 h-8 text-xs"
/>
</div>
<div>
<Label htmlFor="pbsRecPass2" className="text-[11px]">Confirm passphrase</Label>
<Input
id="pbsRecPass2"
type="password"
value={pbsRecoveryPass2}
onChange={(e) => setPbsRecoveryPass2(e.target.value)}
placeholder="Type it again"
className="font-mono mt-1 h-8 text-xs"
/>
{pbsRecoveryPass && pbsRecoveryPass2 && pbsRecoveryPass !== pbsRecoveryPass2 && (
<p className="text-[11px] text-red-400 mt-1">Passphrases don't match.</p>
)}
</div>
{pbsRecoveryStatus?.has_recovery && pbsRecoveryChange && (
<button
type="button"
onClick={() => { setPbsRecoveryChange(false); setPbsRecoveryPass(""); setPbsRecoveryPass2("") }}
className="text-[11px] text-muted-foreground hover:text-foreground underline underline-offset-2"
>
Cancel change — keep the saved passphrase
</button>
)}
</>
)}
</div>
)}
</div>
@@ -2893,8 +2983,19 @@ function CreateJobDialog({
{destResp?.borg?.length ? (
<div className="mt-1 flex items-center gap-2">
<Select value={borgRepoSelected} onValueChange={setBorgRepoSelected}>
<SelectTrigger className="flex-1">
<SelectValue />
<SelectTrigger className="flex-1 min-w-0">
{(() => {
const sel = destResp.borg.find((r) => r.repository === borgRepoSelected)
if (!sel) {
return <span className="truncate text-muted-foreground">Pick a Borg repository</span>
}
return (
<span className="truncate font-mono text-left">
{sel.name}
<span className="text-muted-foreground"> — {sel.repository}</span>
</span>
)
})()}
</SelectTrigger>
<SelectContent>
{destResp.borg.map((r) => (
@@ -3138,6 +3239,11 @@ function ManualBackupDialog({
const [pbsEncrypt, setPbsEncrypt] = useState<boolean>(false)
const [pbsRecoveryPass, setPbsRecoveryPass] = useState<string>("")
const [pbsRecoveryPass2, setPbsRecoveryPass2] = useState<string>("")
// Operator opted to replace an already-configured recovery escrow.
// Default behavior matches the shell: don't prompt for the
// passphrase if one is already saved — only show the inputs when
// there's no escrow yet OR the operator explicitly asks to change.
const [pbsRecoveryChange, setPbsRecoveryChange] = useState<boolean>(false)
// Whether the host already has an escrow blob configured. When
// present, the passphrase input becomes optional ("leave blank to
// keep saved"). Refreshed after a successful setup call.
@@ -3196,6 +3302,7 @@ function ManualBackupDialog({
setPbsEncrypt(false)
setPbsRecoveryPass("")
setPbsRecoveryPass2("")
setPbsRecoveryChange(false)
setLocalDestDir("")
setBorgRepoSelected("")
setBorgPassphrase("")
@@ -3408,8 +3515,24 @@ function ManualBackupDialog({
{destResp?.pbs?.length ? (
<div className="mt-1 flex items-center gap-2">
<Select value={pbsRepository} onValueChange={setPbsRepository}>
<SelectTrigger className="flex-1">
<SelectValue />
<SelectTrigger className="flex-1 min-w-0">
{/* Manual render: SelectValue's default mode
duplicates the item children and cuts
them in the middle on mobile. We render
"name — repository" ourselves with a
proper trailing ellipsis. */}
{(() => {
const sel = destResp.pbs.find((r) => r.repository === pbsRepository)
if (!sel) {
return <span className="truncate text-muted-foreground">Pick a PBS repository</span>
}
return (
<span className="truncate font-mono text-left">
{sel.name}
<span className="text-muted-foreground"> {sel.repository}</span>
</span>
)
})()}
</SelectTrigger>
<SelectContent>
{destResp.pbs.map((r) => (
@@ -3478,35 +3601,62 @@ function ManualBackupDialog({
<AlertTriangle className="h-3.5 w-3.5 text-blue-400" />
Recovery passphrase (strongly recommended)
</div>
<p className="text-[11px] text-muted-foreground">
With a recovery passphrase an encrypted copy of the keyfile is uploaded to PBS on every backup. Lets you recover it on a fresh host with just the passphrase.
{pbsRecoveryStatus?.has_recovery && " A recovery blob is already configured — leave blank to keep it."}
</p>
<div>
<Label htmlFor="manualPbsRecPass" className="text-[11px]">Passphrase</Label>
<Input
id="manualPbsRecPass"
type="password"
value={pbsRecoveryPass}
onChange={(e) => setPbsRecoveryPass(e.target.value)}
placeholder={pbsRecoveryStatus?.has_recovery ? "(unchanged — type to replace)" : "Long random string — write it down somewhere safe"}
className="font-mono mt-1 h-8 text-xs"
/>
</div>
<div>
<Label htmlFor="manualPbsRecPass2" className="text-[11px]">Confirm passphrase</Label>
<Input
id="manualPbsRecPass2"
type="password"
value={pbsRecoveryPass2}
onChange={(e) => setPbsRecoveryPass2(e.target.value)}
placeholder="Type it again"
className="font-mono mt-1 h-8 text-xs"
/>
{pbsRecoveryPass && pbsRecoveryPass2 && pbsRecoveryPass !== pbsRecoveryPass2 && (
<p className="text-[11px] text-red-400 mt-1">Passphrases don't match.</p>
)}
</div>
{pbsRecoveryStatus?.has_recovery && !pbsRecoveryChange ? (
<div className="flex items-start gap-2 text-[11px]">
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-400 mt-0.5 shrink-0" />
<div className="flex-1">
<div className="text-foreground">Recovery escrow already configured for this host.</div>
<div className="text-muted-foreground mt-0.5">This backup will reuse the existing passphrase. No need to type it again.</div>
</div>
<button
type="button"
onClick={() => setPbsRecoveryChange(true)}
className="text-blue-400 hover:text-blue-300 underline underline-offset-2 shrink-0"
>
Change
</button>
</div>
) : (
<>
<p className="text-[11px] text-muted-foreground">
With a recovery passphrase an encrypted copy of the keyfile is uploaded to PBS on every backup. Lets you recover it on a fresh host with just the passphrase.
</p>
<div>
<Label htmlFor="manualPbsRecPass" className="text-[11px]">Passphrase</Label>
<Input
id="manualPbsRecPass"
type="password"
value={pbsRecoveryPass}
onChange={(e) => setPbsRecoveryPass(e.target.value)}
placeholder="Long random string — write it down somewhere safe"
className="font-mono mt-1 h-8 text-xs"
/>
</div>
<div>
<Label htmlFor="manualPbsRecPass2" className="text-[11px]">Confirm passphrase</Label>
<Input
id="manualPbsRecPass2"
type="password"
value={pbsRecoveryPass2}
onChange={(e) => setPbsRecoveryPass2(e.target.value)}
placeholder="Type it again"
className="font-mono mt-1 h-8 text-xs"
/>
{pbsRecoveryPass && pbsRecoveryPass2 && pbsRecoveryPass !== pbsRecoveryPass2 && (
<p className="text-[11px] text-red-400 mt-1">Passphrases don't match.</p>
)}
</div>
{pbsRecoveryStatus?.has_recovery && pbsRecoveryChange && (
<button
type="button"
onClick={() => { setPbsRecoveryChange(false); setPbsRecoveryPass(""); setPbsRecoveryPass2("") }}
className="text-[11px] text-muted-foreground hover:text-foreground underline underline-offset-2"
>
Cancel change — keep the saved passphrase
</button>
)}
</>
)}
</div>
)}
</div>
@@ -3567,8 +3717,19 @@ function ManualBackupDialog({
{destResp?.borg?.length ? (
<div className="mt-1 flex items-center gap-2">
<Select value={borgRepoSelected} onValueChange={setBorgRepoSelected}>
<SelectTrigger className="flex-1">
<SelectValue />
<SelectTrigger className="flex-1 min-w-0">
{(() => {
const sel = destResp.borg.find((r) => r.repository === borgRepoSelected)
if (!sel) {
return <span className="truncate text-muted-foreground">Pick a Borg repository</span>
}
return (
<span className="truncate font-mono text-left">
{sel.name}
<span className="text-muted-foreground"> — {sel.repository}</span>
</span>
)
})()}
</SelectTrigger>
<SelectContent>
{destResp.borg.map((r) => (
@@ -6246,10 +6407,12 @@ function ManualJobWatchModal({
) : (
<ScrollArea className="max-h-[60vh] pr-2">
<div className="space-y-4 text-sm">
{/* Last run + live log */}
{/* Status + live log — manual one-shot has no concept
of "last run" (the modal IS the run), so we use a
neutral "Status" header here. */}
<section className="space-y-2">
<h4 className="text-xs font-semibold uppercase tracking-wide flex items-center gap-1.5 text-green-500">
<Clock className="h-3.5 w-3.5" /> Last run
<Clock className="h-3.5 w-3.5" /> Status
</h4>
<div className="flex items-center gap-2 flex-wrap text-xs">
{resultBadge ? (
@@ -6258,7 +6421,7 @@ function ManualJobWatchModal({
{resultBadge.label}
</span>
) : (
<span className="text-muted-foreground">never run</span>
<span className="text-muted-foreground">starting</span>
)}
{lastRunWhen && <span className="text-muted-foreground">{lastRunWhen}</span>}
</div>
@@ -6501,3 +6664,414 @@ function PbsKeyfileRecoveryDialog({
</Dialog>
)
}
// ──────────────────────────────────────────────────────────────
// ArchiveContentsModal
// ──────────────────────────────────────────────────────────────
// "View contents" of any backup snapshot. Calls /inspect-archive
// (extract to staging + parse_manifest + run_restore dry-run +
// walk rootfs + cleanup) and renders the JSON as a HTML report.
// Same shape regardless of backend so the operator gets a
// consistent view across PBS, Borg and local.
// ──────────────────────────────────────────────────────────────
interface InspectResponse {
manifest?: any
manifest_error?: string
manifest_missing?: boolean
plan?: any
plan_error?: string
plan_raw_stderr?: string
files?: Array<{ path: string; size: number }>
files_truncated?: boolean
files_total_count?: number
metadata_files?: Record<string, string>
}
function ArchiveContentsModal({
open,
onClose,
source,
repo_name,
snapshot,
path,
display_id,
}: {
open: boolean
onClose: () => void
source: "pbs" | "borg" | "local" | null
repo_name?: string
snapshot?: string
path?: string
display_id?: string
}) {
const [data, setData] = useState<InspectResponse | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!open || !source) {
setData(null)
setError(null)
setLoading(false)
return
}
const body: Record<string, string> = { source }
if (source === "local") {
if (!path) { setError("Local archive path missing"); return }
body.path = path
} else {
if (!repo_name || !snapshot) { setError("repo_name and snapshot required"); return }
body.repo_name = repo_name
body.snapshot = snapshot
}
setLoading(true)
setError(null)
fetchApi("/api/host-backups/inspect-archive", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
.then((r: any) => setData(r))
.catch((e) => setError(e instanceof Error ? e.message : String(e)))
.finally(() => setLoading(false))
}, [open, source, repo_name, snapshot, path])
const manifest = data?.manifest
const plan = data?.plan
const files = data?.files
return (
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose() }}>
<DialogContent className="max-w-4xl bg-card border-border h-[85vh] flex flex-col p-0">
<DialogHeader className="px-6 pt-6 pb-3 shrink-0">
<DialogTitle className="flex items-center gap-2 text-base">
<Eye className="h-5 w-5 text-blue-400" />
Backup contents
</DialogTitle>
<DialogDescription className="text-xs font-mono break-all">
{display_id}
</DialogDescription>
</DialogHeader>
{loading && (
<div className="flex items-center gap-3 py-12 justify-center text-sm text-muted-foreground px-6">
<Loader2 className="h-5 w-5 animate-spin" />
Extracting snapshot and analyzing this may take a minute on large backups
</div>
)}
{error && !loading && (
<div className="text-sm text-red-400 px-3 py-3 mx-6 rounded-md border border-red-500/30 bg-red-500/10">
{error}
</div>
)}
{data && !loading && (
<ScrollArea className="flex-1 min-h-0 px-6">
<div className="space-y-4 pr-2">
<ContentsSection icon={Info} title="Manifest" iconColor="text-blue-400">
{manifest ? (
<ManifestGrid manifest={manifest} />
) : data.manifest_missing ? (
<div className="text-xs text-muted-foreground italic">
This backup doesn't include a manifest.json. Manifest is generated by the collectors pipeline (not yet wired into the backup runner). The Metadata files below carry the equivalent info: <code className="font-mono">run_info.env</code>, <code className="font-mono">pveversion.txt</code>, <code className="font-mono">selected_paths.txt</code>.
</div>
) : data.manifest_error ? (
<div className="text-xs text-amber-400">{data.manifest_error}</div>
) : (
<div className="text-xs text-muted-foreground italic">No manifest in this archive.</div>
)}
</ContentsSection>
{(() => {
// Only render plan/storage/network/drivers sections
// when they actually carry information — otherwise
// they read as "Unknown / No entries reported / No
// changes planned" walls of noise.
const blockers = plan?.preflight?.blockers || plan?.blockers || []
const warnings = plan?.preflight?.warnings || plan?.warnings || []
const planStatus = plan?.status || plan?.preflight?.status
const planHasInfo = blockers.length > 0 || warnings.length > 0
|| (planStatus && planStatus !== "unknown")
const storageEntries = (plan?.storage?.entries || plan?.storage?.storages || [])
const networkIfaces = (plan?.network?.interfaces || plan?.network?.remap || [])
const driverList = (plan?.drivers?.plan || plan?.drivers?.components || plan?.drivers?.entries || [])
return (
<>
{planHasInfo && (
<ContentsSection icon={ListTree} title="Restore plan" iconColor="text-emerald-400">
<PlanSummary plan={plan} />
</ContentsSection>
)}
{storageEntries.length > 0 && (
<ContentsSection icon={HardDrive} title="Storage" iconColor="text-amber-400">
<StorageSection storage={plan.storage} />
</ContentsSection>
)}
{networkIfaces.length > 0 && (
<ContentsSection icon={Network} title="Network" iconColor="text-purple-400">
<NetworkSection network={plan.network} />
</ContentsSection>
)}
{driverList.length > 0 && (
<ContentsSection icon={Cpu} title="Drivers to reinstall" iconColor="text-cyan-400">
<DriversSection drivers={plan.drivers} />
</ContentsSection>
)}
</>
)
})()}
{files && files.length > 0 && (
<ContentsSection
icon={Package}
title={`Files (${data.files_total_count ?? files.length}${data.files_truncated ? "+" : ""})`}
iconColor="text-fuchsia-400"
>
<FilesTree files={files} truncated={data.files_truncated} />
</ContentsSection>
)}
{data.metadata_files && Object.keys(data.metadata_files).length > 0 && (
<ContentsSection icon={FileText} title="Metadata files" iconColor="text-muted-foreground">
<div className="space-y-3">
{Object.entries(data.metadata_files).map(([fname, content]) => (
<div key={fname}>
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1 font-mono">{fname}</div>
<pre className="text-[11px] font-mono whitespace-pre-wrap break-all max-h-40 overflow-auto rounded border border-border bg-background/40 p-2 text-foreground/80">
{content}
</pre>
</div>
))}
</div>
</ContentsSection>
)}
{data.plan_error && !data.manifest_missing && (
<div className="text-[11px] text-amber-400 italic">
Restore plan unavailable: {data.plan_error}
</div>
)}
</div>
</ScrollArea>
)}
<div className="flex justify-end px-6 py-3 border-t border-border shrink-0">
<Button variant="ghost" onClick={onClose}>Close</Button>
</div>
</DialogContent>
</Dialog>
)
}
function ContentsSection({
icon: Icon,
iconColor,
title,
children,
}: {
icon: any
iconColor?: string
title: string
children: React.ReactNode
}) {
return (
<section className="rounded-md border border-border bg-background/40 p-3 space-y-2">
<h3 className="text-xs font-semibold uppercase tracking-wider flex items-center gap-1.5">
<Icon className={`h-3.5 w-3.5 ${iconColor || "text-muted-foreground"}`} />
{title}
</h3>
{children}
</section>
)
}
function ManifestGrid({ manifest }: { manifest: any }) {
// The manifest follows the proxmenux_backup_manifest schema:
// {source_host: {hostname, pve_version, kernel, boot_mode, cpu_model, memory_kb, roles},
// hardware_inventory: {gpu, tpu, nic, wireless},
// storage_inventory: {zfs_pools, lvm, physical_disks, pve_storage_cfg, mounts},
// proxmenux_installed_components: [...],
// kernel_params: {...},
// vms_lxcs_at_backup: {vms, lxcs},
// backup_metadata: {paths_archived, encrypted, compression, ...},
// created_at, created_by}
// So .source_host is an OBJECT — passing it raw to String() gave
// the dreaded "[object Object]".
const src = manifest.source_host || {}
const meta = manifest.backup_metadata || {}
const hw = manifest.hardware_inventory || {}
const guests = manifest.vms_lxcs_at_backup || {}
const rows: Array<[string, any]> = []
const push = (k: string, v: any) => {
if (v === undefined || v === null || v === "") return
rows.push([k, v])
}
push("Source host", src.hostname || manifest.hostname)
push("Created at", manifest.created_at || meta.created_at)
push("Kernel", src.kernel || manifest.kernel)
push("Proxmox version", src.pve_version || manifest.pve_version)
push("Boot mode", src.boot_mode)
push("CPU", src.cpu_model)
push("Memory", src.memory_kb ? `${(src.memory_kb / 1024 / 1024).toFixed(1)} GB` : undefined)
push("Roles", Array.isArray(src.roles) && src.roles.length ? src.roles.join(", ") : undefined)
push("GPUs", Array.isArray(hw.gpu) && hw.gpu.length ? `${hw.gpu.length} device(s)` : undefined)
push("NICs", Array.isArray(hw.nic) && hw.nic.length ? `${hw.nic.length} interface(s)` : undefined)
push("VMs at backup", Array.isArray(guests.vms) ? guests.vms.length : undefined)
push("LXCs at backup", Array.isArray(guests.lxcs) ? guests.lxcs.length : undefined)
push("Compression", meta.compression)
push("Encrypted", meta.encrypted === true ? "yes" : meta.encrypted === false ? "no" : undefined)
push("Paths archived", Array.isArray(meta.paths_archived) ? `${meta.paths_archived.length} paths` : undefined)
push("Built by", manifest.created_by)
return (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-1 text-xs">
{rows.map(([k, v]) => (
<div key={k}>
<span className="text-muted-foreground">{k}:</span>{" "}
<code className="font-mono text-foreground/90 break-all">{String(v)}</code>
</div>
))}
</div>
)
}
function PlanSummary({ plan }: { plan: any }) {
const status = plan.status || plan.preflight?.status || "unknown"
const blockers: any[] = plan.preflight?.blockers || plan.blockers || []
const warnings: any[] = plan.preflight?.warnings || plan.warnings || []
const StatusIcon = status === "ok" ? CheckCircle2 : status === "warn" ? AlertTriangle : XCircle
const statusColor = status === "ok" ? "text-emerald-400" : status === "warn" ? "text-amber-400" : "text-red-400"
return (
<div className="space-y-2 text-xs">
<div className="flex items-center gap-2">
<StatusIcon className={`h-4 w-4 ${statusColor}`} />
<span className="font-medium capitalize">{status}</span>
</div>
{blockers.length > 0 && (
<div>
<div className="text-[10px] uppercase tracking-wider text-red-400 mb-1">Blockers</div>
<ul className="space-y-0.5 ml-4 list-disc">
{blockers.map((b, i) => (
<li key={i} className="text-red-400">{typeof b === "string" ? b : (b.message || JSON.stringify(b))}</li>
))}
</ul>
</div>
)}
{warnings.length > 0 && (
<div>
<div className="text-[10px] uppercase tracking-wider text-amber-400 mb-1">Warnings</div>
<ul className="space-y-0.5 ml-4 list-disc">
{warnings.map((w, i) => (
<li key={i} className="text-amber-400">{typeof w === "string" ? w : (w.message || JSON.stringify(w))}</li>
))}
</ul>
</div>
)}
{blockers.length === 0 && warnings.length === 0 && (
<div className="text-muted-foreground italic">No blockers or warnings detected.</div>
)}
</div>
)
}
function StorageSection({ storage }: { storage: any }) {
const entries: any[] = storage.entries || storage.storages || []
if (entries.length === 0) {
return <div className="text-xs text-muted-foreground italic">No storage entries reported.</div>
}
return (
<ul className="space-y-1 text-xs">
{entries.map((e: any, i: number) => {
const ok = e.status === "ok" || e.matches
const Icon = ok ? CheckCircle2 : AlertTriangle
const color = ok ? "text-emerald-400" : "text-amber-400"
return (
<li key={i} className="flex items-start gap-2">
<Icon className={`h-3.5 w-3.5 mt-0.5 shrink-0 ${color}`} />
<div className="min-w-0 flex-1">
<code className="font-mono">{e.name || e.storage || `entry ${i}`}</code>
{e.type && <span className="text-muted-foreground ml-2">{e.type}</span>}
{e.message && <div className="text-muted-foreground text-[11px]">{e.message}</div>}
</div>
</li>
)
})}
</ul>
)
}
function NetworkSection({ network }: { network: any }) {
const ifaces: any[] = network.interfaces || network.remap || []
if (ifaces.length === 0) {
return <div className="text-xs text-muted-foreground italic">No interface changes planned.</div>
}
return (
<ul className="space-y-1 text-xs">
{ifaces.map((nic: any, i: number) => (
<li key={i} className="flex items-start gap-2">
<Network className="h-3.5 w-3.5 mt-0.5 shrink-0 text-purple-400" />
<div className="min-w-0 flex-1">
<code className="font-mono">{nic.source || nic.from || "?"}</code>
<span className="text-muted-foreground mx-2"></span>
<code className="font-mono">{nic.target || nic.to || "?"}</code>
{nic.action && <span className="text-[10px] text-muted-foreground ml-2">{nic.action}</span>}
</div>
</li>
))}
</ul>
)
}
function DriversSection({ drivers }: { drivers: any }) {
const list: any[] = drivers.plan || drivers.components || drivers.entries || []
if (list.length === 0) {
return <div className="text-xs text-muted-foreground italic">No drivers to reinstall.</div>
}
return (
<ul className="space-y-1 text-xs">
{list.map((d: any, i: number) => (
<li key={i} className="flex items-start gap-2">
<Cpu className="h-3.5 w-3.5 mt-0.5 shrink-0 text-cyan-400" />
<div className="min-w-0 flex-1">
<code className="font-mono">{d.name || d.id || `driver ${i}`}</code>
{d.action && <span className="text-muted-foreground ml-2">{d.action}</span>}
{d.detail && <div className="text-muted-foreground text-[11px]">{d.detail}</div>}
</div>
</li>
))}
</ul>
)
}
function FilesTree({ files, truncated }: { files: Array<{ path: string; size: number }>; truncated?: boolean }) {
const [query, setQuery] = useState("")
const filtered = query
? files.filter((f) => f.path.toLowerCase().includes(query.toLowerCase()))
: files
return (
<div className="space-y-2">
<Input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Filter paths…"
className="h-8 text-xs"
/>
<div className="max-h-72 overflow-auto rounded border border-border bg-background/40">
<ul className="text-[11px] font-mono divide-y divide-border/30">
{filtered.slice(0, 2000).map((f) => (
<li key={f.path} className="flex items-center justify-between gap-3 px-2 py-1 hover:bg-white/5">
<span className="truncate" title={f.path}>{f.path}</span>
<span className="text-muted-foreground shrink-0">{formatBytes(f.size)}</span>
</li>
))}
</ul>
</div>
<div className="text-[10px] text-muted-foreground flex items-center gap-2">
Showing {Math.min(filtered.length, 2000)} of {filtered.length}{query ? " filtered" : ""}{filtered.length !== files.length ? ` (total ${files.length})` : ""}
{truncated && <span className="text-amber-400">· list truncated at 5000 open the snapshot manually to see the rest</span>}
</div>
</div>
)
}

View File

@@ -13372,6 +13372,13 @@ def api_pbs_recovery_available():
if not bid.startswith('proxmenux-keyrecovery-'):
continue
t = it.get('backup-time', 0)
# ISO timestamp — proxmox-backup-client rejects the
# raw epoch with "unable to parse backup snapshot path".
from datetime import datetime, timezone
try:
iso = datetime.fromtimestamp(int(t), tz=timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
except (ValueError, OSError):
iso = str(t)
if bid not in latest or t > latest[bid]['backup_time']:
latest[bid] = {
'repo_name': repo['name'],
@@ -13379,7 +13386,7 @@ def api_pbs_recovery_available():
'backup_id': bid,
'source_host': bid[len('proxmenux-keyrecovery-'):],
'backup_time': t,
'snapshot': f"host/{bid}/{t}",
'snapshot': f"host/{bid}/{iso}",
}
out.extend(latest.values())
out.sort(key=lambda x: x['backup_time'], reverse=True)
@@ -15358,10 +15365,22 @@ def _list_pbs_snapshots_for_repo(repo: dict, timeout: int = 15) -> tuple[list, s
if backup_type != 'host':
continue
backup_id = it.get('backup-id', '')
# Hide internal keyrecovery escrow snapshots — they're a
# ProxMenux implementation detail, not user-facing backups.
# See _PBS_RECOVERY_ENC_PATH and the runner upload step.
if backup_id.startswith('proxmenux-keyrecovery-'):
continue
backup_time = it.get('backup-time', 0)
# The canonical snapshot identifier the client accepts back —
# exactly what we'd pass to `proxmox-backup-client restore`.
snapshot = f"{backup_type}/{backup_id}/{backup_time}"
# proxmox-backup-client expects the timestamp portion as an
# ISO-8601 UTC string ("2026-06-20T20:23:17Z"), NOT the raw
# epoch — passing the epoch back makes it reject the path
# with "unable to parse backup snapshot path".
from datetime import datetime, timezone
try:
iso = datetime.fromtimestamp(int(backup_time), tz=timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
except (ValueError, OSError):
iso = str(backup_time)
snapshot = f"{backup_type}/{backup_id}/{iso}"
# A snapshot is "encrypted" when ANY of its files reports a
# crypt-mode other than `none`. Used to render the lock badge.
files = it.get('files', []) or []
@@ -15474,8 +15493,53 @@ def _list_borg_archives_for_target(target: dict, timeout: int = 30) -> tuple[lis
except json.JSONDecodeError:
return [], 'borg list output was not valid JSON'
archives = data.get('archives', []) or []
# `borg list --json` only carries archive name + start time. Pull
# the per-archive size with a parallel batch of `borg info --json
# repo::name` calls so the UI can render a real number instead of
# "0 MB". 4-way concurrency keeps the wall-clock acceptable on
# repos with dozens of archives. Results are returned alongside
# the listing so callers don't have to re-issue per-row probes.
from concurrent.futures import ThreadPoolExecutor
def _info_size(name: str) -> int:
if not name:
return 0
try:
ri = subprocess.run(
['borg', 'info', '--json', f'{repo}::{name}'],
capture_output=True, text=True, timeout=15, env=env,
)
except (subprocess.TimeoutExpired, OSError):
return 0
if ri.returncode != 0:
return 0
try:
info = json.loads(ri.stdout)
except (json.JSONDecodeError, ValueError):
return 0
arcs = info.get('archives') or []
if not arcs:
return 0
stats = arcs[0].get('stats') or {}
# Prefer original (uncompressed, undeduplicated) — matches what
# users expect when comparing to PBS source-data size.
v = stats.get('original_size') or stats.get('compressed_size') or 0
try:
return int(v)
except (TypeError, ValueError):
return 0
names = [a.get('name') or '' for a in archives]
sizes: dict[str, int] = {}
if names:
with ThreadPoolExecutor(max_workers=4) as ex:
for nm, sz in zip(names, ex.map(_info_size, names)):
sizes[nm] = sz
out: list = []
for arc in data.get('archives', []):
for arc in archives:
name = arc.get('name') or ''
start_iso = arc.get('start') or arc.get('time') or ''
# Borg timestamps come as ISO-8601 with microseconds — convert
@@ -15485,7 +15549,6 @@ def _list_borg_archives_for_target(target: dict, timeout: int = 30) -> tuple[lis
if start_iso:
try:
from datetime import datetime
# Drop microseconds if present
cleaned = start_iso.split('.')[0]
dt = datetime.strptime(cleaned, '%Y-%m-%dT%H:%M:%S')
backup_time = int(dt.timestamp())
@@ -15495,21 +15558,17 @@ def _list_borg_archives_for_target(target: dict, timeout: int = 30) -> tuple[lis
'backend': 'borg',
'repo_name': target['name'],
'repo_repository': repo,
'snapshot': name, # canonical id used to extract
'snapshot': name,
'backup_type': 'archive',
'backup_id': name,
'backup_time': backup_time,
'size_bytes': 0, # borg list omits size; info per-archive is expensive
'size_bytes': sizes.get(name, 0),
'owner': arc.get('username'),
'protected': False,
'files': [],
'fingerprint': None,
'borg_id': arc.get('id'),
'borg_start_iso': start_iso,
# Encrypted iff the destination's repo mode is not "none".
# Borg archives inherit the encryption of the repo they
# live in (cannot mix per-archive), so the target's flag
# is authoritative.
'encrypted': (target.get('encrypt_mode') or 'repokey') != 'none',
})
return out, None
@@ -15858,6 +15917,286 @@ def api_host_backups_archives():
return jsonify({'archives': archives})
# ──────────────────────────────────────────────────────────────
# Snapshot inspection — extract + analyze backend
# ──────────────────────────────────────────────────────────────
# The "View contents" button in the Available Archives modal needs
# to read manifest + restore plan + file list out of ANY backup —
# PBS, Borg, or local. The trick is that PBS and Borg snapshots
# aren't files: they have to be extracted to a staging directory
# first. These helpers + endpoint do exactly that, reuse the four
# restore-side shell scripts (parse_manifest / run_restore), and
# clean the staging tree before returning.
# Staging lives under /tmp/pmnx-inspect-XXX and is removed in the
# `finally` block — there's nothing to leak even if the request
# fails mid-flight.
def _inspect_extract_to_staging(source: str, repo_dict: dict, snapshot: str, staging: str) -> tuple:
"""Pull a snapshot into <staging>. Returns (ok, error_message).
PBS: pulls the `hostcfg.pxar` archive (the canonical name the
ProxMenux backup uses) and lets the client write straight into
staging — the pxar already wraps the rootfs/+metadata/ layout.
Borg: `borg extract` into staging; if the archive used absolute
paths, the wrapped tree is normalized by _inspect_normalize_layout.
Local: tar/zst/gz extract into staging."""
if source == 'pbs':
pwd = _resolve_pbs_password(repo_dict['name'])
if not pwd:
return False, f"no password for PBS repo \"{repo_dict['name']}\""
env = {**os.environ, 'PBS_PASSWORD': pwd}
fp = _resolve_pbs_fingerprint(repo_dict['name'])
if fp:
env['PBS_FINGERPRINT'] = fp
cmd = ['proxmox-backup-client', 'restore',
'--repository', repo_dict['repository'],
snapshot, 'hostcfg.pxar', staging]
if os.path.isfile(_PBS_KEYFILE_PATH):
cmd.extend(['--keyfile', _PBS_KEYFILE_PATH])
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=900, env=env)
except (subprocess.TimeoutExpired, OSError) as e:
return False, str(e)
if r.returncode != 0:
return False, (r.stderr.strip() or r.stdout.strip() or 'pbs restore failed')[:500]
return True, None
if source == 'borg':
env = _borg_env_for(repo_dict)
cmd = ['borg', 'extract', f"{repo_dict['repository']}::{snapshot}"]
try:
r = subprocess.run(cmd, cwd=staging, capture_output=True, text=True, timeout=900, env=env)
except (subprocess.TimeoutExpired, OSError) as e:
return False, str(e)
if r.returncode != 0:
return False, (r.stderr.strip() or r.stdout.strip() or 'borg extract failed')[:500]
return True, None
if source == 'local':
archive_path = repo_dict.get('path') or ''
if not os.path.isfile(archive_path):
return False, f'local archive not found: {archive_path}'
if archive_path.endswith('.tar.zst'):
cmd = ['tar', '--zstd', '-xf', archive_path, '-C', staging]
elif archive_path.endswith(('.tar.gz', '.tgz')):
cmd = ['tar', '-xzf', archive_path, '-C', staging]
elif archive_path.endswith('.tar'):
cmd = ['tar', '-xf', archive_path, '-C', staging]
else:
return False, f'unknown archive type: {archive_path}'
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=900)
except (subprocess.TimeoutExpired, OSError) as e:
return False, str(e)
if r.returncode != 0:
return False, (r.stderr.strip() or 'tar extract failed')[:500]
return True, None
return False, f'unknown source: {source}'
def _inspect_normalize_layout(staging: str) -> bool:
"""Coerce arbitrary backup layouts into the canonical
rootfs/+metadata/ structure that parse_manifest.sh / run_restore.sh
expect. Mirrors backup_host.sh::_rs_check_layout without the dialogs."""
if os.path.isdir(os.path.join(staging, 'rootfs')):
return True
# Case 2: rootfs/ nested one level deep (Borg with absolute paths).
try:
for entry in os.listdir(staging):
sub = os.path.join(staging, entry)
if not os.path.isdir(sub):
continue
inner_rootfs = os.path.join(sub, 'rootfs')
if os.path.isdir(inner_rootfs):
shutil.move(inner_rootfs, os.path.join(staging, 'rootfs'))
inner_meta = os.path.join(sub, 'metadata')
if os.path.isdir(inner_meta):
shutil.move(inner_meta, os.path.join(staging, 'metadata'))
return True
except OSError:
return False
# Case 3: flat layout — etc/, var/, root/, usr/ extracted at top.
flat_indicators = ('etc', 'var', 'root', 'usr')
if any(os.path.isdir(os.path.join(staging, x)) for x in flat_indicators):
import tempfile
tmp = tempfile.mkdtemp(prefix='.rootfs_wrap.', dir=staging)
for entry in os.listdir(staging):
src = os.path.join(staging, entry)
if src == tmp:
continue
try:
shutil.move(src, os.path.join(tmp, entry))
except OSError:
pass
shutil.move(tmp, os.path.join(staging, 'rootfs'))
os.makedirs(os.path.join(staging, 'metadata'), exist_ok=True)
return True
return False
def _inspect_compose_json(staging: str) -> dict:
"""Run the restore-aware scripts against the normalized staging and
merge their JSON outputs into one dict the UI can render. Best-
effort: a failure in one section reports an error in that section
only, the rest of the report still comes back."""
scripts_dir = f'{_PROXMENUX_SCRIPTS_DIR}/backup_restore/restore'
out: dict = {}
# ── Manifest ──
# Many in-the-wild backups don't ship a manifest.json (the
# collectors that generate it are wired up but not invoked by
# the current backup_host.sh / run_scheduled_backup.sh). When
# that's the case, set `manifest_missing=True` so the UI shows
# a soft informational note instead of a red error — and skip
# run_restore.sh entirely since it would only repeat the
# "no manifest" message.
try:
r = subprocess.run(['bash', f'{scripts_dir}/parse_manifest.sh', staging],
capture_output=True, text=True, timeout=30)
if r.returncode == 0 and r.stdout.strip():
try:
out['manifest'] = json.loads(r.stdout)
except json.JSONDecodeError:
out['manifest_error'] = 'parse_manifest output was not valid JSON'
else:
err = (r.stderr.strip() or 'parse_manifest failed')[:500]
if 'no manifest.json' in err.lower():
out['manifest_missing'] = True
else:
out['manifest_error'] = err
except (subprocess.TimeoutExpired, OSError) as e:
out['manifest_error'] = str(e)
# ── Full preflight (storage + network + drivers + plan) ──
# Only run when a manifest is actually present — run_restore.sh
# uses it as input, and without one the report is just a repeat
# of the parse_manifest failure (already surfaced above).
if out.get('manifest'):
run_script = f'{scripts_dir}/run_restore.sh'
try:
r = subprocess.run(['bash', run_script, staging, '--mode', 'full', '--json'],
capture_output=True, text=True, timeout=180)
if r.stdout.strip():
try:
out['plan'] = json.loads(r.stdout)
except json.JSONDecodeError:
out['plan_raw_stderr'] = r.stderr[:1000]
out['plan_error'] = 'run_restore output was not valid JSON'
else:
out['plan_error'] = (r.stderr.strip() or 'no report from run_restore')[:500]
except (subprocess.TimeoutExpired, OSError) as e:
out['plan_error'] = str(e)
# ── File listing (capped, for the Files tab) ──
rootfs = os.path.join(staging, 'rootfs')
metadata = os.path.join(staging, 'metadata')
if os.path.isdir(rootfs):
files: list = []
limit = 5000
truncated = False
for root, dirs, fnames in os.walk(rootfs):
rel_dir = os.path.relpath(root, rootfs)
if rel_dir == '.':
rel_dir = ''
depth = rel_dir.count(os.sep) + (1 if rel_dir else 0)
if depth > 6:
dirs[:] = []
continue
for fn in fnames:
full = os.path.join(root, fn)
try:
sz = os.path.getsize(full)
except OSError:
sz = 0
files.append({
'path': ('/' + rel_dir + '/' + fn) if rel_dir else ('/' + fn),
'size': sz,
})
if len(files) >= limit:
truncated = True
break
if truncated:
break
out['files'] = files
out['files_truncated'] = truncated
out['files_total_count'] = len(files)
# ── Bundle the loose metadata sidecars for quick reference ──
if os.path.isdir(metadata):
meta_view: dict = {}
for fname in ('run_info.env', 'pveversion.txt', 'selected_paths.txt',
'missing_paths.txt', 'sha256sums.txt'):
fp = os.path.join(metadata, fname)
if os.path.isfile(fp):
try:
with open(fp, encoding='utf-8', errors='replace') as f:
meta_view[fname] = f.read()
except OSError:
pass
out['metadata_files'] = meta_view
return out
@app.route('/api/host-backups/inspect-archive', methods=['POST'])
@require_auth
def api_host_backups_inspect_archive():
"""One-shot 'View Contents' endpoint for any backend. Extracts the
snapshot to a temp staging dir, runs the restore-side tools, then
cleans up. Body:
{source: "pbs"|"borg"|"local", repo_name?, snapshot?, path?}
"""
payload = request.get_json(silent=True) or {}
source = (payload.get('source') or '').strip()
if source not in ('pbs', 'borg', 'local'):
return jsonify({'error': 'source must be pbs, borg, or local'}), 400
repo_dict: dict = {}
snapshot = ''
if source == 'pbs':
repo_name = (payload.get('repo_name') or '').strip()
snapshot = (payload.get('snapshot') or '').strip()
if not repo_name or not snapshot:
return jsonify({'error': 'repo_name and snapshot are required for pbs'}), 400
for r in _list_pbs_destinations():
if r.get('name') == repo_name:
repo_dict = r
break
if not repo_dict:
return jsonify({'error': f'PBS repo "{repo_name}" not configured'}), 404
elif source == 'borg':
repo_name = (payload.get('repo_name') or '').strip()
snapshot = (payload.get('snapshot') or '').strip()
if not repo_name or not snapshot:
return jsonify({'error': 'repo_name and snapshot are required for borg'}), 400
for r in _list_borg_destinations():
if r.get('name') == repo_name:
repo_dict = r
break
if not repo_dict:
return jsonify({'error': f'Borg repo "{repo_name}" not configured'}), 404
else: # local
path = (payload.get('path') or '').strip()
if not path or not os.path.isfile(path):
return jsonify({'error': f'local archive not found: {path}'}), 404
repo_dict = {'path': path}
import tempfile
staging = tempfile.mkdtemp(prefix='pmnx-inspect-')
try:
ok, err = _inspect_extract_to_staging(source, repo_dict, snapshot, staging)
if not ok:
return jsonify({'error': err}), 500
if not _inspect_normalize_layout(staging):
return jsonify({'error': 'archive layout not recognized — no rootfs/ found'}), 500
return jsonify(_inspect_compose_json(staging))
finally:
try:
shutil.rmtree(staging, ignore_errors=True)
except OSError:
pass
@app.route('/api/host-backups/archives/<path:archive_id>/manifest', methods=['GET'])
@require_auth
def api_host_backups_archive_manifest(archive_id):

View File

@@ -228,8 +228,12 @@ _bk_borg() {
t_start=$SECONDS
: > "$log_file"
# Include manifest.json (top-level) when present — without it,
# parse_manifest.sh can't read the schema'd manifest on restore.
local -a _bk_borg_paths=(rootfs metadata)
[[ -f "$staging_root/manifest.json" ]] && _bk_borg_paths+=(manifest.json)
if (cd "$staging_root" && "$borg_bin" create --stats --progress \
"$repo::$archive_name" rootfs metadata) 2>&1 | tee -a "$log_file"; then
"$repo::$archive_name" "${_bk_borg_paths[@]}") 2>&1 | tee -a "$log_file"; then
elapsed=$((SECONDS - t_start))
# Extract compressed size from borg stats if available

View File

@@ -627,6 +627,33 @@ hb_prepare_staging() {
find . -type f -print0 | sort -z | xargs -0 sha256sum 2>/dev/null \
> "$meta/checksums.sha256" || true
)
# ── Structured manifest.json (proxmenux_backup_manifest) ──
# build_manifest.sh composes the collectors output into the
# exact JSON shape parse_manifest.sh + run_restore.sh expect.
# Without it, every restore flow degrades to "no manifest found"
# and the Monitor's View Contents / restore preview shows nothing.
# The collectors live next to this library file.
local _hb_lib_dir _hb_collector_dir _hb_build_manifest
_hb_lib_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
_hb_collector_dir="$_hb_lib_dir/collectors"
_hb_build_manifest="$_hb_collector_dir/build_manifest.sh"
if [[ -x "$_hb_build_manifest" ]]; then
local -a _hb_archived=()
if [[ -s "$meta/selected_paths.txt" ]]; then
mapfile -t _hb_archived < "$meta/selected_paths.txt"
fi
if (( ${#_hb_archived[@]} > 0 )); then
bash "$_hb_build_manifest" --paths-archived "${_hb_archived[@]}" \
> "$staging_root/manifest.json" 2>/dev/null || true
else
bash "$_hb_build_manifest" \
> "$staging_root/manifest.json" 2>/dev/null || true
fi
# Drop the file if the collectors emitted nothing useful so
# parse_manifest doesn't read a 0-byte JSON downstream.
[[ -s "$staging_root/manifest.json" ]] || rm -f "$staging_root/manifest.json"
fi
}
hb_load_restore_paths() {

View File

@@ -193,8 +193,13 @@ _sb_run_borg() {
return 1
fi
# Include manifest.json (top-level) when present. Borg needs the
# paths spelled out explicitly — without this, parse_manifest can't
# find the schema'd manifest on a restored Borg archive.
local -a _borg_paths=(rootfs metadata)
[[ -f "$stage_root/manifest.json" ]] && _borg_paths+=(manifest.json)
(cd "$stage_root" && "$borg_bin" create --stats \
"${repo}::${archive_name}" rootfs metadata) 2>&1 || return 1
"${repo}::${archive_name}" "${_borg_paths[@]}") 2>&1 || return 1
"$borg_bin" prune -v --list "$repo" \
${KEEP_LAST:+--keep-last "$KEEP_LAST"} \
@@ -271,9 +276,14 @@ _sb_run_pbs() {
local stage_root="$1"
local backup_id="$2"
local epoch="$3"
# Stage the WHOLE root (rootfs/ + metadata/ + manifest.json), not
# just rootfs/. Mirrors backup_host.sh::_bk_pbs (the TUI flow) — and
# parse_manifest.sh / run_restore.sh need metadata/ + manifest.json
# to compose a meaningful restore plan. With only rootfs/ in the
# pxar, View Contents reports "no manifest.json" forever.
local -a cmd=(
proxmox-backup-client backup
"hostcfg.pxar:${stage_root}/rootfs"
"hostcfg.pxar:${stage_root}"
--repository "$PBS_REPOSITORY"
--backup-type host
--backup-id "$backup_id"
@@ -378,7 +388,12 @@ main() {
if [[ "${PROFILE_MODE:-default}" == "custom" && -f "${JOBS_DIR}/${job_id}.paths" ]]; then
mapfile -t paths < "${JOBS_DIR}/${job_id}.paths"
else
mapfile -t paths < <(hb_default_profile_paths)
# Default profile = base paths + operator-saved extras (the
# TUI flow does the same — see hb_resolve_paths_mode in
# lib_host_backup_common.sh). Without the extras line, the
# `backup-extra-paths.txt` set in Settings was silently
# ignored for scheduled / manual runs from the Monitor.
mapfile -t paths < <(hb_default_profile_paths; hb_load_extra_paths)
fi
if [[ ${#paths[@]} -eq 0 ]]; then