mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-07-26 18:38:30 +00:00
update 1.2.2.2 beta
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user