"use client" import { useState, useEffect, useRef } from "react" import useSWR from "swr" import { Card, CardContent, CardHeader, CardTitle } from "./ui/card" import { Button } from "./ui/button" import { Badge } from "./ui/badge" import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "./ui/dialog" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select" import { Input } from "./ui/input" import { Label } from "./ui/label" import { Checkbox } from "./ui/checkbox" import { ScrollArea } from "./ui/scroll-area" import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs" import { DatabaseBackup, Clock, HardDrive, Server, CheckCircle2, AlertTriangle, XCircle, Loader2, PlayCircle, Archive, FileSearch, Calendar, Trash2, Plus, ChevronRight, ChevronLeft, Pencil, Save, Download, FileText, Power, RefreshCw, Lock, Eye, Network, Cpu, Package, ShieldCheck, Info, ListTree, History, } from "lucide-react" import { ScriptTerminalModal } from "./script-terminal-modal" import { RestoreProgressCard } from "./restore-progress-card" import { fetchApi, getApiUrl } from "../lib/api-config" import { fetchTerminalTicket } from "../lib/terminal-ws" import { formatStorage, formatBytes } from "../lib/utils" import { getStorageUsageColor } from "../lib/storage-usage-color" // ── Shape contracts with the backend (flask_server.py: api_host_backups_*) ── interface BackupJob { id: string destination: string method: string // "pbs" | "borg" | "local" | "unknown" on_calendar: string // "OnCalendar=..." for timer jobs, "attached → storage:X" for attached retention: string // "last=5, daily=7, ..." timer_enabled: boolean // legacy — only meaningful for non-attached jobs enabled: boolean // unified state (timer for non-attached, ENABLED= for attached) attached: boolean // PVE vzdump-attached: no own timer, trigger from hook pve_storage: string | null // storage id the attached job listens for profile_mode: string // "default" | "custom" manual: boolean // MANUAL_RUN=1 — one-shot, no schedule, won't re-fire last_status: string | null next_run: string | null // True when this job ships encrypted backups — PBS_KEYFILE set for // PBS, or BORG_ENCRYPT_MODE != "none" for Borg. Drives the lock // badge in the row + the toggle's initial state in Edit. encrypted?: boolean } // Sprint H — remote backups (PBS + Borg). Listing is cheap (metadata // only); the heavy lifting happens on demand when the operator clicks // Download. Same shape for both backends so the UI renders them // uniformly — `backend` is the only switch. interface RemoteSnapshot { backend: "pbs" | "borg" repo_name: string repo_repository: string snapshot: string // canonical id used to extract / restore backup_type: string backup_id: string backup_time: number // unix seconds size_bytes: number // 0 for Borg (omitted by `borg list`) owner: string | null protected: boolean files: Array<{ filename: string; size: number }> fingerprint: string | null // Whether THIS snapshot is encrypted. For PBS it's derived from // any file having crypt-mode != "none"; for Borg it follows the // target's encrypt_mode. encrypted?: boolean borg_id?: string // Borg-only fields when available borg_start_iso?: string } interface RemoteArchivesResp { snapshots: RemoteSnapshot[] errors: Array<{ backend: string; repo_name: string; error: string }> } interface ExportTask { task_id: string backend: string repo_name: string snapshot: string state: "queued" | "restoring" | "packing" | "completed" | "failed" message: string size_bytes: number output_path: string | null error: string | null } // Unified archive descriptor — local .tar.zst files and remote PBS / // Borg snapshots share the same row layout; `source` decides which // code paths apply (inline download vs export-then-download, manifest // available vs not, etc.). interface UnifiedArchive { source: "local" | "pbs" | "borg" display_id: string // what shows in mono in the row size_bytes: number created_at: number // unix seconds source_label: string // "/var/lib/vz/dump" or "PBS my-pbs" or "Borg my-borg" // One of the two is populated depending on `source`. local?: BackupArchive remote?: RemoteSnapshot // for both pbs and borg } interface BackupArchive { id: string // basename of the .tar file (also the URL slug) path: string // absolute path on host size_bytes: number mtime: number // unix seconds // From the backend identifier — see _identify_host_backup() in flask_server.py. // kind: "manual" / "scheduled" when we know; "legacy" when only the in-tar // marker confirmed it's a ProxMenux backup (no sidecar, no name match). job_id: string | null kind: "manual" | "scheduled" | "legacy" profile: string | null source_hostname: string | null // Which detection path identified this archive. Surfaced as a small tooltip // hint so the operator knows whether the metadata is authoritative // (sidecar) or inferred (filename / tar-peek). detected_via: "sidecar" | "job_id_match" | "hostcfg_prefix" | "tar_peek" } interface ManifestSourceHost { hostname: string pve_version: string | null roles: string[] kernel: string boot_mode: string cpu_model: string memory_kb: number } interface PreflightCheck { id: string severity: "pass" | "warn" | "fail" message: string details: Record | null } interface PreflightReport { source_host_at_backup: ManifestSourceHost selected_mode: { mode: string paths_include: string[] paths_exclude: string[] components_include: string[] storage_apply: boolean network_apply: boolean } preflight: { checks: PreflightCheck[] summary: { pass: number; warn: number; fail: number } } storage: { zfs: Array<{ name: string; action: string; present: string[]; missing: string[] }> lvm: Array<{ name: string; action: string }> pve_storage: Array<{ id: string; type: string; action: string; note: string | null }> in_selected_mode: boolean } network: { keep: Array<{ ifname: string; mac: string }> remap: Array<{ source_ifname: string; destination_ifname: string; mac: string }> orphan: Array<{ source_ifname: string; source_mac: string }> new: Array<{ ifname: string; mac: string }> in_selected_mode: boolean } driver_reinstall: { plan: Array<{ component_id: string type: string version: string installer: string | null action: string reason: string }> } abort_reason: string | null } const fetcher = async (url: string) => fetchApi(url) // Detect encrypted-backup key errors coming from proxmox-backup-client // and pull the manifest + provided fingerprints out of the message so // the UI can render them prominently instead of burying the info // inside a raw stack-trace-shaped string. Returns null when the error // is not encryption-related — the caller should fall back to plain // text rendering in that case. type KeyfileError = { manifestFp: string | null providedFp: string | null raw: string } const parseKeyfileError = (raw: string): KeyfileError | null => { if (!raw) return null const lower = raw.toLowerCase() if (!/missing key|wrong key|was created with key|manifest.?s key|does not match|no key found|unable to (load|read) key|failed to decrypt/.test(lower)) { return null } const manifest = raw.match(/(?:was created with key|manifest'?s key)\s+([0-9a-f:]+)/i) const provided = raw.match(/provided key\s+([0-9a-f:]+)/i) return { manifestFp: manifest?.[1] ?? null, providedFp: provided?.[1] ?? null, raw, } } const KeyfileErrorBlock: React.FC<{ err: KeyfileError; className?: string }> = ({ err, className }) => (
Encrypted backup — wrong keyfile on this host
The keyfile installed here does not match the one used to create this backup, so PBS refuses to open it.
{err.manifestFp && (
Required (manifest)
{err.manifestFp}
)} {err.providedFp && (
Currently installed
{err.providedFp}
)}
Import the correct keyfile from{" "} Backup configuration → Destinations → PBS row → Upload {" "}(you may need to Delete the current one first) and retry.
) const formatMtime = (mtime: number) => new Date(mtime * 1000).toLocaleString(undefined, { year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", }) const formatNext = (iso: string | null) => { if (!iso) return "—" try { return new Date(iso).toLocaleString() } catch { return iso } } // Best-effort human form of systemd OnCalendar expressions. Whatever // we don't recognise falls back to " (systemd OnCalendar)" so the // operator at least knows what kind of string they're looking at. // Handles the patterns the host-backup wizard can emit ("hourly", // "daily", "weekly", "monthly", "*-*-* HH:MM:SS", "Mon..Sun *-*-* …"). const humanizeOnCalendar = (raw: string | null | undefined): string => { if (!raw) return "—" const s = raw.trim() if (!s) return "—" const lower = s.toLowerCase() if (lower === "hourly") return "Every hour (at minute 0)" if (lower === "daily") return "Every day at 00:00" if (lower === "weekly") return "Every Monday at 00:00" if (lower === "monthly") return "On the 1st of every month at 00:00" if (lower === "yearly" || lower === "annually") return "On Jan 1st at 00:00" if (lower === "minutely") return "Every minute" // *-*-* HH:MM[:SS] → "Every day at HH:MM" let m = s.match(/^\*-\*-\*\s+(\d{1,2}):(\d{2})(?::(\d{2}))?$/) if (m) return `Every day at ${m[1].padStart(2, "0")}:${m[2]}` // *-*-* *:MM:SS → "Every hour at minute MM" m = s.match(/^\*-\*-\*\s+\*:(\d{2})(?::(\d{2}))?$/) if (m) return `Every hour at minute ${m[1]}` // Mon,Tue *-*-* HH:MM:SS → " at HH:MM" m = s.match(/^([A-Za-z,.\s]+)\s+\*-\*-\*\s+(\d{1,2}):(\d{2})(?::(\d{2}))?$/) if (m) { const expandWeekdays = (chunk: string): string => { const days: Record = { mon: "Monday", tue: "Tuesday", wed: "Wednesday", thu: "Thursday", fri: "Friday", sat: "Saturday", sun: "Sunday", } const rangeMatch = chunk.match(/^([A-Za-z]+)\.\.([A-Za-z]+)$/) if (rangeMatch) { const order = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] const start = order.indexOf(rangeMatch[1].slice(0, 3).toLowerCase()) const end = order.indexOf(rangeMatch[2].slice(0, 3).toLowerCase()) if (start >= 0 && end >= 0 && start <= end) { return order.slice(start, end + 1).map((d) => days[d]).join(", ") } } return chunk .split(",") .map((d) => days[d.trim().slice(0, 3).toLowerCase()] || d.trim()) .join(", ") } return `${expandWeekdays(m[1])} at ${m[2].padStart(2, "0")}:${m[3]}` } return `${s} (systemd OnCalendar)` } // A job is "running" when its .status file has RUN_AT (runner started) // but no RESULT yet (runner hasn't written the verdict). Lets the UI // flag in-progress runs both inline (badge in the row) and from the // Manual backups card so an operator who closes the dialog can re-open // the live stream. function isJobRunning(j: BackupJob | null | undefined): boolean { if (!j?.last_status) return false const s = parseJobStatus(j.last_status) return !!(s?.runAt && !s?.result) } // last_status is the raw RUN_AT=..., RESULT=..., LOG_FILE=... blob the // scheduler runner persists. We only care about RESULT + RUN_AT for the // UI; everything else is noise the operator doesn't need on a list view. function parseJobStatus(raw: string | null): { result: string | null; runAt: string | null; logFile: string | null } | null { if (!raw) return null const map: Record = {} for (const line of raw.split("\n")) { const m = line.match(/^([A-Z_]+)=(.*)$/) if (m) map[m[1]] = m[2] } return { result: map["RESULT"] || null, runAt: map["RUN_AT"] || null, logFile: map["LOG_FILE"] || null, } } // Backend-method color scheme, mirrored exactly from storage-overview.tsx // (line 414 et al.) so a PBS job badge here looks identical to a PBS // snapshot badge on the Storage page. const methodBadgeCls = (m: string | undefined): string => { switch ((m || "").toLowerCase()) { case "pbs": return "bg-purple-500/10 text-purple-400 border-purple-500/20" case "borg": return "bg-fuchsia-500/10 text-fuchsia-400 border-fuchsia-500/20" case "local": default: return "bg-blue-500/10 text-blue-400 border-blue-500/20" } } const formatRunAt = (iso: string | null) => { if (!iso) return null try { return new Date(iso).toLocaleString(undefined, { day: "2-digit", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit", }) } catch { return iso } } // ── PBS keyfile management (shared between CreateJob + ManualBackup) ── // // Rendered inside the encryption section when a keyfile is already // installed. Replaces the older static info box that pointed to a // non-existent "Settings → Host backup" panel: the operator now has // concrete actions (download keyfile, update stored passphrase, // change recovery model, remove keyfile) with destructive-confirmation // modals that call the /api/host-backups/pbs-encryption/* endpoints // and mutate pbsRecoveryStatus so the parent dialog re-renders on // success. There is no "Replace keyfile" action any more — replacing // the key would silently orphan every encrypted backup that used it. // The operator instead uses Remove + set up again via the normal flow // (Generate a new keyfile / Use an existing keyfile), which surfaces // the source picker properly. // ────────────────────────────────────────────────────────────── // PbsKeyfileActions — the trimmed action set the operator sees // inside every PBS destination row: Download, Upload (import a new // keyfile), Delete. No stored-passphrase rotation, no escrow toggle // — those live in the setup dialogs where the operator makes the // initial call. Placing these three next to the destination they // belong to matches "the key is a PBS thing" without dragging in // the fuller management surface. // ────────────────────────────────────────────────────────────── function PbsKeyfileActions({ pbsRepository }: { pbsRepository?: string }) { const { data: info, mutate: mutateInfo } = useSWR<{ installed: boolean fingerprint?: string path: string escrow_mode?: "none" | "local" | "full" }>("/api/host-backups/pbs-encryption/keyfile-info", fetcher) const [uploadOpen, setUploadOpen] = useState(false) const [deleteOpen, setDeleteOpen] = useState(false) const [importFile, setImportFile] = useState(null) const [importPath, setImportPath] = useState("") const [busy, setBusy] = useState(false) const [err, setErr] = useState(null) // Auto-discover a PVE-managed keyfile at /etc/pve/priv/storage/.enc // that matches this specific PBS repository. When present, the Upload // modal surfaces a one-click "Use PVE-managed key" shortcut at the // top so the operator doesn't have to hunt down the file path — the // exact convenience the shell TUI already provides during its import // wizard. Only fetched while the modal is open + a repository is known. const { data: pveDiscover } = useSWR<{ entries: Array<{ name: string; server: string; datastore: string; path: string; matches_repository: boolean }> }>( uploadOpen && pbsRepository ? `/api/host-backups/pbs-encryption/discover-pve-keyfiles?repository=${encodeURIComponent(pbsRepository)}` : null, fetcher, ) const pveMatch = pveDiscover?.entries?.find((e) => e.matches_repository) || null const closeUpload = () => { setUploadOpen(false) setImportFile(null) setImportPath("") setBusy(false) setErr(null) } const closeDelete = () => { setDeleteOpen(false) setBusy(false) setErr(null) } const download = () => { // Same plain--download trick used elsewhere: bypasses fetch so // an ad-blocker or a stale SWR cache can't intervene. const a = document.createElement("a") a.href = "/api/host-backups/pbs-encryption/download-keyfile" a.download = "pbs-key.conf" document.body.appendChild(a) a.click() document.body.removeChild(a) } const runUpload = async () => { if (!importFile && !importPath.trim()) { setErr("Pick a keyfile file or enter an absolute path on this host.") return } setBusy(true) setErr(null) try { const body: Record = { escrow_mode: "none" } if (importFile) { const buf = new Uint8Array(await importFile.arrayBuffer()) let bin = "" for (let i = 0; i < buf.length; i++) bin += String.fromCharCode(buf[i]) body.source = "content" body.content_b64 = btoa(bin) } else { body.source = "path" body.keyfile_path = importPath.trim() } await fetchApi("/api/host-backups/pbs-encryption/import", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }) await mutateInfo() closeUpload() } catch (e) { setErr(e instanceof Error ? e.message : String(e)) setBusy(false) } } const runDelete = async () => { setBusy(true) setErr(null) try { await fetchApi("/api/host-backups/pbs-encryption/remove", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}), }) await mutateInfo() closeDelete() } catch (e) { setErr(e instanceof Error ? e.message : String(e)) setBusy(false) } } const installed = !!info?.installed return (
Encryption keyfile:{" "} {installed ? ( installed ) : ( not installed on this host )}
{installed && ( <> )} {!installed && ( )}
!v && closeUpload()}> Upload PBS keyfile Import a keyfile you already have. It lands at /usr/local/share/proxmenux/pbs-key.conf and every subsequent encrypted backup reuses it. Recovery escrow stays off — use the setup wizard if you want to enable it.
{pveMatch && (
PVE-managed keyfile detected for this PBS
Proxmox already stores an encryption key for storage {pveMatch.name} at{" "} {pveMatch.path}. Import it in one click.
)} {pveMatch &&
— or upload your own —
}
setImportFile(e.target.files?.[0] ?? null)} disabled={busy || !!importPath.trim()} className="h-9 mt-1" />
— or —
setImportPath(e.target.value)} disabled={busy || !!importFile} placeholder="e.g. /etc/pve/priv/storage/.enc or /root/my-pbs-key" className="h-9 mt-1 font-mono text-xs" />
{err && (
{err}
)}
!v && closeDelete()}> Delete keyfile Backups already stored on PBS were encrypted with the current keyfile. After this action:
  • New backups will use no encryption on this host until a new keyfile is set up.
  • Downloading pre-existing encrypted backups from this host will fail unless you kept a copy of the current key.
  • Existing recovery blobs on PBS stay intact — they still recover the old key with its original passphrase.
{err && (
{err}
)}
) } function KeyfileActionsBar({ mutateStatus, escrowMode, }: { mutateStatus: () => Promise escrowMode?: "none" | "local" | "full" }) { const [busy, setBusy] = useState(false) const [err, setErr] = useState(null) const [pass1, setPass1] = useState("") const [pass2, setPass2] = useState("") // Pending radio state — reflects the operator's intended mode after // Apply. Seeded from the current mode so a No-op click on Apply is // rejected. Binary UX ('none' vs 'full') matches the setup wizard. const currentIsFull = escrowMode === "full" const [pendingMode, setPendingMode] = useState<"none" | "full">(currentIsFull ? "full" : "none") const runApply = async () => { // Yes → passphrase required + match. No → passphrase ignored. if (pendingMode === "full") { if (!pass1) { setErr("Recovery passphrase is required.") return } if (pass1 !== pass2) { setErr("Passphrases do not match.") return } } setBusy(true) setErr(null) try { const body: Record = { escrow_mode: pendingMode } if (pendingMode === "full") body.escrow_passphrase = pass1 await fetchApi("/api/host-backups/pbs-encryption/set-escrow-mode", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }) await mutateStatus() setPass1("") setPass2("") } catch (e) { setErr(e instanceof Error ? e.message : String(e)) } finally { setBusy(false) } } // Contextual Apply-button label. Three transitions: // No → Yes → "Start uploading" // Yes → No → "Stop uploading" // Yes → Yes (new pw) → "Update passphrase" (rewraps envelope) const applyLabel = pendingMode === "full" && !currentIsFull ? "Start uploading" : pendingMode === "none" && currentIsFull ? "Stop uploading" : pendingMode === "full" && currentIsFull ? "Update passphrase" : "Apply" // Apply is enabled when there is a real change to commit. const canApply = ( (pendingMode !== (currentIsFull ? "full" : "none")) || // mode change (pendingMode === "full" && !!pass1 && pass1 === pass2) // passphrase update ) const download = () => { const a = document.createElement("a") a.href = "/api/host-backups/pbs-encryption/download-keyfile" a.download = "pbs-key.conf" document.body.appendChild(a) a.click() document.body.removeChild(a) } return (
Manage installed keyfile
{/* Current status — icon + colour by state, no truncated fp. */} {escrowMode !== undefined && (
Upload to PBS: {currentIsFull ? ( Yes — envelope uploaded on every backup ) : ( No — kept only on this host )}
)} {/* Change mode + passphrase — one integrated form, no popover. Radio picks the intent; the passphrase pair unfolds when the intent is Yes (both for a first-time upload and for a passphrase rotation while already in Yes). */}
Upload key to PBS?
{pendingMode === "full" && (
setPass1(e.target.value)} placeholder={currentIsFull ? "Type a new passphrase to rotate" : "Long random string — write it down somewhere safe"} className="font-mono mt-1 h-8 text-xs" />
setPass2(e.target.value)} placeholder="Type it again" className="font-mono mt-1 h-8 text-xs" /> {pass1 && pass2 && pass1 !== pass2 && (

Passphrases don't match.

)}
)} {err && (
{err}
)}
{/* Deletion lives inside the PBS destination row (PbsKeyfileActions); setup menus of Create Job / Manual Backup never surface it. */}
) } export function HostBackup() { const { data: jobsResp, error: jobsErr, mutate: mutateJobs } = useSWR<{ jobs: BackupJob[] }>( "/api/host-backups/jobs", fetcher, { refreshInterval: 30000 }, ) const [busyJobId, setBusyJobId] = useState(null) const [actionError, setActionError] = useState(null) const [jobToDelete, setJobToDelete] = useState(null) const [creatingJob, setCreatingJob] = useState(false) const [editingJobId, setEditingJobId] = useState(null) const [viewingJobId, setViewingJobId] = useState(null) const [watchingManualId, setWatchingManualId] = useState(null) const [runningManual, setRunningManual] = useState(false) // Independent tracker for the most recent manual backup that's still // running. Survives the operator closing the ManualJobWatchModal — // the modal only drives auto-refresh while it's OPEN, so if the // operator dismisses it before the job finishes, the archive lists // would only refresh on the next SWR tick (30-60 s). Polling the // job status here at 3 s and calling mutate once we see the run // transition to completed closes that gap regardless of modal // state. Set = single slot per launch; a fresh manual replaces the // slot (the previous one falls back to the periodic SWR tick). const [pendingManualJobId, setPendingManualJobId] = useState(null) async function confirmDeleteJob() { if (!jobToDelete) return const id = jobToDelete.id setBusyJobId(id) setActionError(null) try { await fetchApi(`/api/host-backups/jobs/${encodeURIComponent(id)}`, { method: "DELETE" }) mutateJobs() setJobToDelete(null) } catch (e) { setActionError(`Failed to delete "${id}": ${e instanceof Error ? e.message : String(e)}`) } finally { setBusyJobId(null) } } const { data: destinationsResp, mutate: mutateDestinations } = useSWR( "/api/host-backups/destinations", fetcher, { refreshInterval: 60000 }, ) const { data: remoteArchivesResp, error: remoteArchivesErr, mutate: mutateRemoteArchives } = useSWR( "/api/host-backups/remote-archives", fetcher, { refreshInterval: 60000 }, ) const { data: archivesResp, error: archivesErr, mutate: mutateArchives } = useSWR<{ archives: BackupArchive[] }>( "/api/host-backups/archives", fetcher, { refreshInterval: 30000 }, ) // Background poll for the pending manual backup, independent of the // watch modal being open. Fires mutateArchives/mutateRemoteArchives // as soon as the run flips to done so the operator sees the new // entry in Available Archives without a manual refresh. const { data: pendingManualPoll } = useSWR( pendingManualJobId ? `/api/host-backups/jobs/${encodeURIComponent(pendingManualJobId)}` : null, fetcher, { refreshInterval: 3000 }, ) useEffect(() => { const d = pendingManualPoll as JobDetail | undefined if (pendingManualJobId && d?.last_run_at && d?.last_result) { mutateJobs() mutateArchives() mutateRemoteArchives() setPendingManualJobId(null) } }, [pendingManualPoll, pendingManualJobId, mutateJobs, mutateArchives, mutateRemoteArchives]) const [inspectingArchive, setInspectingArchive] = useState(null) // Merge local archives + remote snapshots (PBS + Borg) into a single // sorted list. Newest first, regardless of source — operators care // about "the most recent backup", not "the most recent local backup". const unifiedArchives: UnifiedArchive[] = (() => { const out: UnifiedArchive[] = [] if (archivesResp?.archives) { for (const a of archivesResp.archives) { out.push({ source: "local", display_id: a.id, size_bytes: a.size_bytes, created_at: a.mtime, source_label: a.path.replace(/\/[^/]+$/, "") || "/", local: a, }) } } if (remoteArchivesResp?.snapshots) { for (const s of remoteArchivesResp.snapshots) { out.push({ source: s.backend, display_id: s.snapshot, size_bytes: s.size_bytes, created_at: s.backup_time, source_label: `${s.backend.toUpperCase()} ${s.repo_name}`, remote: s, }) } } out.sort((a, b) => b.created_at - a.created_at) return out })() return (
{/* ── Post-restore progress card ──────────────────── Renders only while a restore is running or its summary hasn't been acknowledged. Once dismissed, it collapses to a "Past restores" ghost button. */} {/* ── Scheduled jobs ───────────────────────────────── */}
Scheduled Backup Jobs {jobsResp?.jobs?.filter((j) => !j.manual).length ?? 0}
{jobsErr ? (
Failed to load jobs
) : !jobsResp ? (
Loading...
) : jobsResp.jobs.filter((j) => !j.manual).length === 0 ? null : (
{actionError && (
{actionError}
)} {jobsResp.jobs.filter((j) => !j.manual).map((j) => { const status = parseJobStatus(j.last_status) const running = isJobRunning(j) // While running we override the ok/failed pill with a // running spinner — same way the modal does. Clicking // the row re-opens JobDetailModal which auto-detects // the in-progress state and resumes streaming. const statusBadge = running ? { label: "running", cls: "bg-blue-500/10 border-blue-500/40 text-blue-300" } : status?.result === "ok" ? { label: "ok", cls: "bg-emerald-500/10 border-emerald-500/40 text-emerald-400" } : status?.result ? { label: status.result, cls: "bg-red-500/10 border-red-500/40 text-red-400" } : null const lastRunWhen = formatRunAt(status?.runAt ?? null) return ( ) })}
)}
{/* ── Backup configuration (destinations + custom paths) ── */} mutateDestinations()} /> {/* ── Manual backups (entry point) ──────────────────── */}
Manual backups

Manual backups run once and stop — no schedule.

{/* In-progress manual jobs. If the operator closed the ManualBackupDialog before the runner finished, this banner is the way back into the live log — clicking opens the JobDetailModal which automatically resumes the streaming view for any in-progress job. */} {jobsResp?.jobs ?.filter((j) => j.manual && isJobRunning(j)) .map((j) => ( ))}
{/* ── Available archives ─────────────────────────────── */}
Available Archives
{unifiedArchives.length}

All backups visible from this host — local .tar.zst files (PVE default dump dir, configured local target, USB mountpoints, scheduled jobs' destinations) and PBS backups from every configured datastore. Click an entry to inspect, restore or download it — downloads of PBS backups are extracted on-demand only when you request them.

{remoteArchivesResp?.errors && remoteArchivesResp.errors.length > 0 && (
Some remote backends couldn't be queried:
{remoteArchivesResp.errors.map((e, i) => (
{e.backend}/{e.repo_name}: {e.error}
))}
)} {archivesErr && remoteArchivesErr ? (
Failed to load archives
) : !archivesResp && !remoteArchivesResp ? (
Loading...
) : unifiedArchives.length === 0 ? (
No backups found yet. Use Run manual backup above, configure a scheduled job, or check that the configured PBS / Borg destinations have backups.
) : (
{unifiedArchives.map((u) => { const localKind = u.local?.kind 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-400 border-purple-500/20 bg-purple-500/10" : u.source === "borg" ? "text-fuchsia-400 border-fuchsia-500/20 bg-fuchsia-500/10" : "text-blue-400 border-blue-500/20 bg-blue-500/10" return ( ) })}
)}
{/* ── Inspect / preflight modal ──────────────────────── */} setInspectingArchive(null)} onDeleted={() => { setInspectingArchive(null) // Refresh both lists — a deleted item might have been local // or remote (PBS/Borg). Cheap enough to revalidate both. mutateArchives() mutateRemoteArchives() }} /> {/* ── Create / Edit job wizard ─────────────────────── */} setCreatingJob(false)} onCreated={() => { setCreatingJob(false) mutateJobs() }} /> setEditingJobId(null)} onCreated={() => { setEditingJobId(null) mutateJobs() }} /> {/* ── One-shot manual backup ─────────────────────── */} setRunningManual(false)} onLaunched={(jobId) => { // Hand off straight to the live-progress modal so the // operator never has to chase the "View progress" banner // after pressing Run. Mutate jobs first so the new manual // entry is in cache when the watch modal opens. setRunningManual(false) mutateJobs() setWatchingManualId(jobId) // Register the job with the modal-independent background // poller so the Available Archives list refreshes the // instant the run finishes, even if the operator closes // the watch modal before then. Replaces the old fire-and- // forget 5 s mutate that used to miss slow backups. setPendingManualJobId(jobId) }} /> {/* ── Job detail / actions modal ─────────────────────────── */} setViewingJobId(null)} onEdit={(id) => { setViewingJobId(null) setEditingJobId(id) }} onRequestDelete={(id) => { const job = jobsResp?.jobs.find((j) => j.id === id) if (job) { setViewingJobId(null) setJobToDelete(job) } }} onChanged={() => { mutateJobs() mutateArchives() mutateRemoteArchives() }} /> {/* Manual jobs use their own modal — no Schedule/Retention/ Profile, no Edit/Run/Disable. Only the live log; the modal is closed with the dialog's own X (no extra footer). */} setWatchingManualId(null)} onChanged={() => { // The watch modal fires onChanged when a tracked run // finishes (last_result transitions from null → ok/failed). // Refresh jobs (status badge), local archives (new tar.zst) // and remote archives (Borg/PBS snapshots) so the operator // sees the new backup without waiting for the next tick. mutateJobs() mutateArchives() mutateRemoteArchives() }} /> {/* ── Delete confirmation ────────────────────────────── */} { if (!v) setJobToDelete(null) }}> Delete backup job? This action cannot be undone. {jobToDelete && (
Job ID
{jobToDelete.id}
{jobToDelete.attached && jobToDelete.pve_storage && ( <>
Type
attached to PVE storage {jobToDelete.pve_storage}
)}
{jobToDelete.attached ? (

Only the ProxMenux host backup hook is removed. PVE vzdump jobs targeting this storage stay intact and keep running.

) : (

The systemd timer and service for this job will be stopped, disabled and removed. Existing backup archives on disk are NOT deleted.

)}
)}
) } // ────────────────────────────────────────────────────────────── // Inspect modal — shows manifest summary + lets the operator pick // a restore mode and run the dry-run preflight + plan against this // host. No mutating actions; --apply stays on the CLI for 1.3.0. // ────────────────────────────────────────────────────────────── function InspectModal({ archive, onClose, onDeleted, }: { archive: UnifiedArchive | null onClose: () => void onDeleted?: () => void }) { const open = archive !== null // Aliases to the source-specific payloads — saves on `.local!` / // `.remote!` repetition later. PBS and Borg share the same shape. const localArc = archive?.source === "local" ? archive.local : undefined const remoteArc = archive && archive.source !== "local" ? archive.remote : undefined const isRemote = archive?.source === "pbs" || archive?.source === "borg" const backendLabel = archive?.source === "pbs" ? "PBS" : archive?.source === "borg" ? "Borg" : "Local" const [mode, setMode] = useState("full") const [report, setReport] = useState(null) const [running, setRunning] = useState(false) const [error, setError] = useState(null) const [instructions, setInstructions] = useState<{ archive_basename: string mode_label: string shell_command: string menu_path: string[] reboot_required: boolean notes: string[] } | null>(null) const [fetchingInstructions, setFetchingInstructions] = useState(false) const [downloading, setDownloading] = useState(false) // Per-archive runner log — only available for local archives, where // the runner writes `.log` next to the `.tar.zst` in // /var/log/proxmenux/backup-jobs/. PBS / Borg snapshots don't have // a co-located run log on this host. const { data: archiveLog } = useSWR<{ log_path: string | null size: number content: string tail: string[] }>( open && archive?.source === "local" && localArc?.id ? `/api/host-backups/archives/${encodeURIComponent(localArc.id)}/log` : null, fetcher, ) const [showArchiveFullLog, setShowArchiveFullLog] = useState(false) const [showDeleteArchiveConfirm, setShowDeleteArchiveConfirm] = useState(false) const [viewingContents, setViewingContents] = useState(false) // Restore flow state: // restorePreparing — extract running (blocking spinner overlay) // restoreOptions — modal open after prepare succeeded // restoreTerminal — ScriptTerminalModal open with monitor_apply.sh // The extract happens BEFORE the options modal so the custom // checklist can be filled from `components_available` (what's // actually inside this backup) instead of a hardcoded list. const [restorePreparing, setRestorePreparing] = useState(false) const [restoreError, setRestoreError] = useState(null) const [restoreOptions, setRestoreOptions] = useState<{ stagingPath: string pathsAvailable: string[] rollbackPlan?: RollbackPlan // Cross-kernel awareness passed through to RestoreOptionsModal: // when direction === "bk_older" the modal shows the safe-subset // banner and grays out any path prefixed by anything in // blockedPaths. "bk_newer" and "same" pass through as no-ops. crossKernel?: { direction: "same" | "bk_older" | "bk_newer" backupKernel: string targetKernel: string blockedPaths: string[] } // Kernel-agnostic hydration preview (bk_older only): what the // restore will re-apply to the target via merge instead of // whole-file copy — IOMMU cmdline tokens, VFIO modules, // operator's GRUB keys, whitelisted vfio/nvidia files. hydration?: { applies: boolean actions: string[] } } | null>(null) const [restoreTerminal, setRestoreTerminal] = useState<{ stagingPath: string mode: "full" | "custom" paths: string[] rollbackExecute?: boolean } | null>(null) // ── Keyfile-required inline import ──────────────────────────── // When this modal opens on an encrypted PBS backup and the local // keyfile is missing at /usr/local/share/proxmenux/pbs-key.conf, // Restore / Download / View contents would all fail with // "missing key". We block the buttons and offer an inline Import // panel that reuses /api/host-backups/pbs-encryption/import with // source=content + escrow_mode=none — same endpoint as the setup // flow, so the imported keyfile lands at the canonical path and is // reused by every subsequent backup + restore. const { data: keyfileInfo, mutate: mutateKeyfileInfo } = useSWR<{ installed: boolean fingerprint?: string path: string }>( open && remoteArc?.encrypted ? "/api/host-backups/pbs-encryption/keyfile-info" : null, fetcher, ) const needsKeyfile = !!(remoteArc?.encrypted && keyfileInfo && !keyfileInfo.installed) // Same PVE-managed keyfile auto-discovery the setup wizards use: when // the encrypted snapshot lives on a PBS whose PVE storage.cfg entry // has an `encryption-key` file at /etc/pve/priv/storage/.enc, // surface a one-click "Use this key" shortcut in the amber panel so // the operator doesn't have to type the full path. Only fetched when // the amber gate is actually rendered (encrypted + no keyfile). const { data: pveDiscoverInspect } = useSWR<{ entries: Array<{ name: string; server: string; datastore: string; path: string; matches_repository: boolean }> }>( needsKeyfile && remoteArc?.repo_repository ? `/api/host-backups/pbs-encryption/discover-pve-keyfiles?repository=${encodeURIComponent(remoteArc.repo_repository)}` : null, fetcher, ) const pveMatchInspect = pveDiscoverInspect?.entries?.find((e) => e.matches_repository) || null const [importFile, setImportFile] = useState(null) const [importPath, setImportPath] = useState("") const [importing, setImporting] = useState(false) const [importError, setImportError] = useState(null) const runImportKeyfile = async () => { // Either a file upload OR a typed absolute path on this host — // both land at /usr/local/share/proxmenux/pbs-key.conf with // escrow_mode='none'. When both are provided the file wins. if (!importFile && !importPath.trim()) { setImportError("Pick a keyfile file or enter an absolute path on this host.") return } setImporting(true) setImportError(null) try { const body: Record = { escrow_mode: "none" } if (importFile) { const buf = new Uint8Array(await importFile.arrayBuffer()) let bin = "" for (let i = 0; i < buf.length; i++) bin += String.fromCharCode(buf[i]) body.source = "content" body.content_b64 = btoa(bin) } else { body.source = "path" body.keyfile_path = importPath.trim() } await fetchApi("/api/host-backups/pbs-encryption/import", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }) await mutateKeyfileInfo() setImportFile(null) setImportPath("") } catch (e) { setImportError(e instanceof Error ? e.message : String(e)) } finally { setImporting(false) } } const beginRestore = async () => { if (!archive) return setRestoreError(null) setRestorePreparing(true) const body: Record = { source: archive.source } if (archive.source === "local") { if (!localArc?.path) { setRestoreError("Local archive path missing"); setRestorePreparing(false); return } body.path = localArc.path } else if (remoteArc) { body.repo_name = remoteArc.repo_name body.snapshot = remoteArc.snapshot } else { setRestoreError("Snapshot info missing") setRestorePreparing(false) return } try { const r: any = await fetchApi("/api/host-backups/restore/prepare", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }) if (!r?.staging_path) throw new Error("backend did not return a staging path") const ck = r.cross_kernel || {} const hyd = r.hydration || {} setRestoreOptions({ stagingPath: r.staging_path, pathsAvailable: Array.isArray(r.paths_available) ? r.paths_available : [], rollbackPlan: r.rollback_plan, crossKernel: ck.direction ? { direction: ck.direction, backupKernel: ck.backup_kernel || "", targetKernel: ck.target_kernel || "", blockedPaths: Array.isArray(ck.blocked_paths) ? ck.blocked_paths : [], } : undefined, hydration: hyd.applies ? { applies: true, actions: Array.isArray(hyd.actions) ? hyd.actions : [], } : undefined, }) } catch (e) { setRestoreError(e instanceof Error ? e.message : String(e)) } finally { setRestorePreparing(false) } } const [deletingArchive, setDeletingArchive] = useState(false) const [archiveDeleteResult, setArchiveDeleteResult] = useState(null) // Local download. Uses a single-use ticket appended to the URL + // `
` instead of fetch+blob — the previous fetch+blob // approach loaded the whole archive into the browser's RAM via // URL.createObjectURL(), which broke around 2 GB on most browsers. // With the ticket-on-URL approach the browser streams the response // straight to disk, so archive size is bounded only by free space. const downloadLocalArchive = async () => { if (!localArc) return setDownloading(true) setError(null) try { const ticket = await fetchTerminalTicket() let url = getApiUrl(`/api/host-backups/archives/${encodeURIComponent(localArc.id)}/download`) if (ticket) url += `?ticket=${encodeURIComponent(ticket)}` const a = document.createElement("a") a.href = url a.download = localArc.id document.body.appendChild(a) a.click() document.body.removeChild(a) } catch (e) { setError(`Download failed: ${e instanceof Error ? e.message : String(e)}`) } finally { // The download itself runs in the browser's network stack; // we just initiated it. Clear the spinner immediately. setDownloading(false) } } // Remote export-then-download (PBS or Borg) — kicks off the // server-side extract + pack, polls until the .tar.zst is ready, // then streams it like the local case. Stage feedback (queued / // restoring / packing) goes into `exportTask.state` so the modal // can show what's happening live. const [exportTask, setExportTask] = useState(null) const downloadRemoteArchive = async () => { if (!remoteArc) return setDownloading(true) setError(null) setExportTask({ task_id: "", backend: remoteArc.backend, repo_name: remoteArc.repo_name, snapshot: remoteArc.snapshot, state: "queued", message: "Starting export…", size_bytes: 0, output_path: null, error: null, }) try { const started = await fetchApi<{ task_id: string; state: string }>( "/api/host-backups/remote-archives/export", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ backend: remoteArc.backend, repo_name: remoteArc.repo_name, snapshot: remoteArc.snapshot, }), }, ) // Poll until done. The export can take from seconds (small host // config) to minutes (multi-GB pxar) — poll every 1.5s so the // UI feels responsive without DoS-ing Flask. let task: ExportTask | null = null while (true) { await new Promise((res) => setTimeout(res, 1500)) task = await fetchApi( `/api/host-backups/remote-archives/export/${encodeURIComponent(started.task_id)}`, ) setExportTask(task) if (task.state === "completed" || task.state === "failed") break } if (!task || task.state !== "completed") { throw new Error(task?.error || "export did not complete") } // Stream the resulting .tar.zst with a ticketed URL + . // Same rationale as downloadLocalArchive: bypass fetch+blob to // avoid the ~2 GB browser-RAM limit; the browser writes // straight to disk. const ticket = await fetchTerminalTicket() let url = getApiUrl(`/api/host-backups/remote-archives/export/${encodeURIComponent(started.task_id)}/download`) if (ticket) url += `?ticket=${encodeURIComponent(ticket)}` const safeSnap = remoteArc.snapshot.replace(/\//g, "_") const a = document.createElement("a") a.href = url a.download = `${remoteArc.backend}-${remoteArc.repo_name}-${safeSnap}.tar.zst` document.body.appendChild(a) a.click() document.body.removeChild(a) setExportTask(null) } catch (e) { setError(`Download failed: ${e instanceof Error ? e.message : String(e)}`) } finally { setDownloading(false) } } const downloadArchive = () => { if (isRemote) return downloadRemoteArchive() return downloadLocalArchive() } // Archive deletion — works for local, PBS and Borg backends. // • local: DELETE /archives/ → removes .tar.zst + sidecar + run log. // • pbs: DELETE /remote-archives → `proxmox-backup-client snapshot forget`. // • borg: DELETE /remote-archives → `borg delete ::`. // PBS-protected snapshots come back as 409 with the original message so // the operator can decide whether to lift the protection first. const deleteArchive = async () => { if (!archive) return setDeletingArchive(true) setError(null) try { let removed: string[] = [] if (archive.source === "local" && localArc) { const resp = await fetchApi<{ status: string; removed: string[] }>( `/api/host-backups/archives/${encodeURIComponent(localArc.id)}`, { method: "DELETE" }, ) removed = resp.removed || [] } else if (remoteArc) { const resp = await fetchApi<{ status: string; removed: string }>( `/api/host-backups/remote-archives`, { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ backend: remoteArc.backend, repo_name: remoteArc.repo_name, snapshot: remoteArc.snapshot, }), }, ) removed = resp.removed ? [resp.removed] : [] } setArchiveDeleteResult(removed) setShowDeleteArchiveConfirm(false) if (onDeleted) onDeleted(); else onClose() } catch (e) { setError(`Failed to delete: ${e instanceof Error ? e.message : String(e)}`) } finally { setDeletingArchive(false) } } // Reset instructions when archive or mode changes — the previous // command is no longer relevant. useEffect(() => { setInstructions(null) }, [archive, mode]) const fetchInstructions = async () => { if (!localArc) return setFetchingInstructions(true) setError(null) try { const res = await fetchApi( `/api/host-backups/archives/${encodeURIComponent(localArc.id)}/prepare-restore`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ mode }), } ) as NonNullable setInstructions(res) } catch (e) { setError(e instanceof Error ? e.message : String(e)) } finally { setFetchingInstructions(false) } } const { data: manifest, error: manifestErr } = useSWR<{ source_host: ManifestSourceHost proxmenux_installed_components: Array<{ id: string; version_at_backup: string | null }> vms_lxcs_at_backup: { vms: unknown[]; lxcs: unknown[] } storage_inventory?: { zfs_pools?: unknown[]; lvm?: { vgs?: unknown[] } } }>( localArc ? `/api/host-backups/archives/${encodeURIComponent(localArc.id)}/manifest` : null, fetcher, ) const runPreflight = async () => { if (!localArc) return setRunning(true) setError(null) setReport(null) try { const res = await fetchApi( `/api/host-backups/archives/${encodeURIComponent(localArc.id)}/preflight`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ mode }), }, ) setReport(res) } catch (e: any) { setError(e?.message || "Preflight failed") } finally { setRunning(false) } } // Reset state when archive changes — the key= prop on DialogContent // forces React to unmount + remount so all useState() values are // discarded automatically. Cleaner than manually tracking each. const archiveKey = archive ? `${archive.source}:${archive.display_id}` : "" return ( <> { if (!v) onClose() }}> {archive?.display_id} {/* ── Body: same shape for the 3 backends ─────────────── */}
{/* Backup info — uniform grid, with backend-specific rows mixed in only when they carry data. Backend + encryption badges live here (instead of the header, where they used to overlap the close button). */}
Backup
{archive && ( {archive.source} )} {remoteArc?.encrypted && ( )}
{/* Time + size — present for every backend. */} {archive && (archive.source === "pbs" || archive.source === "borg") && remoteArc ? ( <>
Backup time: {formatMtime(remoteArc.backup_time)}
{remoteArc.size_bytes > 0 &&
Size: {formatBytes(remoteArc.size_bytes)}
}
Repository: {remoteArc.repo_repository}
Repo name: {remoteArc.repo_name}
{remoteArc.backend === "pbs" ? "Backup group:" : "Archive name:"}{" "} {remoteArc.backend === "pbs" ? `${remoteArc.backup_type}/${remoteArc.backup_id}` : remoteArc.backup_id}
{remoteArc.owner &&
Owner: {remoteArc.owner}
} {remoteArc.borg_id &&
Borg id: {remoteArc.borg_id}
} ) : localArc ? ( <>
Created: {formatMtime(localArc.mtime)}
Size: {formatBytes(localArc.size_bytes)}
Path: {localArc.path}
{localArc.job_id &&
Job id: {localArc.job_id}
} {localArc.profile &&
Profile: {localArc.profile}
} {localArc.source_hostname &&
Source host: {localArc.source_hostname}
}
Detected via: {localArc.detected_via}
) : null}
{/* PBS pxar files list — only PBS exposes this. */} {remoteArc?.files && remoteArc.files.length > 0 && (
Files in this backup
    {remoteArc.files.map((f) => (
  • {f.filename} {formatBytes(f.size)}
  • ))}
)}
{/* Run log — only Local has a co-located .log. */} {archive?.source === "local" && archiveLog && archiveLog.log_path && archiveLog.tail.length > 0 && (

Run log

{archiveLog.tail.join("\n")}
                  
tail · {formatBytes(archiveLog.size)} {archiveLog.log_path}
)} {/* In-flight export task feedback (Download for PBS/Borg). */} {exportTask && (
{exportTask.state} — {exportTask.message}
{exportTask.state === "failed" && exportTask.error && (
{exportTask.error}
)} {exportTask.state === "completed" && exportTask.size_bytes > 0 && (
Packed size: {formatBytes(exportTask.size_bytes)}
)}
)} {error && (() => { const kf = parseKeyfileError(error) return kf ? ( ) : (
{error}
) })()} {/* Encrypted-backup gate: no local keyfile → block the three actions until the operator imports the key. The import lands at the canonical path with escrow_mode= 'none', so subsequent runs reuse it silently. */} {needsKeyfile && (
Encrypted backup — keyfile required
This snapshot is encrypted but no local keyfile is installed at {" "}/usr/local/share/proxmenux/pbs-key.conf. Import the keyfile that was used at backup time to continue.
{pveMatchInspect && (
PVE-managed keyfile detected for this PBS
Proxmox stores an encryption key for storage {pveMatchInspect.name} at{" "} {pveMatchInspect.path}. Import it in one click.
)}
setImportFile(e.target.files?.[0] ?? null)} disabled={importing || !!importPath.trim()} className="h-8 text-[11px]" />
— or —
setImportPath(e.target.value)} disabled={importing || !!importFile} placeholder="e.g. /etc/pve/priv/storage/.enc or /root/my-pbs-key" className="h-8 text-[11px] font-mono" />
{importError && (
{importError}
)}
)}
{/* ── Footer: same 4 buttons for the 3 backends ───────── Restore (green) · Download (blue) · View contents (blue) on the left · Delete (red) on the right. On mobile only Restore keeps its label — the others are icon-only to fit the narrower viewport without wrapping. */}
{/* ── View contents — extract + parse the snapshot, show HTML ──── */} 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} /> {/* ── Restore error toast (prepare failed) ─────────────────── */} {restoreError && (() => { const kf = parseKeyfileError(restoreError) return ( setRestoreError(null)}> {kf ? "Restore blocked — encrypted backup" : "Restore preparation failed"} {!kf && ( {restoreError} )} {kf && }
) })()} {/* ── Restore options modal: opens only AFTER the prepare step, so the Custom checklist already knows what's inside. ──── */} { const sp = restoreOptions?.stagingPath setRestoreOptions(null) if (sp) { fetchApi("/api/host-backups/restore/cleanup", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ staging_path: sp }), }).catch(() => undefined) } }} onLaunch={(mode, paths, rollbackExecute) => { if (!restoreOptions) return const sp = restoreOptions.stagingPath setRestoreOptions(null) setRestoreTerminal({ stagingPath: sp, mode, paths, rollbackExecute }) }} /> {/* ── Restore terminal — uses the canonical ScriptTerminalModal (the same component Hardware/Security/Settings use to run their scripts). Stable `key` per stagingPath so each restore session is a fresh terminal. */} {restoreTerminal && ( { const sp = restoreTerminal.stagingPath fetchApi("/api/host-backups/restore/cleanup", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ staging_path: sp }), }).catch(() => undefined) setRestoreTerminal(null) }} // Auto-dismiss when the script's PTY closes (i.e. the bash // wrapper exited cleanly — the operator pressed Enter at the // "Press Enter to close" prompt). Without this the operator // would have to click Close manually after each restore. onComplete={() => { const sp = restoreTerminal.stagingPath fetchApi("/api/host-backups/restore/cleanup", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ staging_path: sp }), }).catch(() => undefined) setRestoreTerminal(null) }} scriptPath="/usr/local/share/proxmenux/scripts/backup_restore/restore/monitor_apply.sh" scriptName="monitor_apply" title={`Restore — ${restoreTerminal.mode === "full" ? "Complete" : "Custom by paths"}`} description={ restoreTerminal.mode === "custom" ? `${restoreTerminal.paths.length} path(s) selected` : "Complete restore — applies the whole backup" } params={{ EXECUTION_MODE: "web", STAGING: restoreTerminal.stagingPath, MODE: restoreTerminal.mode, ...(restoreTerminal.mode === "custom" && restoreTerminal.paths.length > 0 ? { PATHS: restoreTerminal.paths.join(",") } : {}), ...(restoreTerminal.rollbackExecute ? { ROLLBACK_EXECUTE: "1" } : {}), }} /> )} {/* ── Confirm archive deletion ───────────────────────────── */} Delete {backendLabel} backup {archive?.source === "local" ? "Removes the archive, its sidecar JSON and the matching run log. The action is permanent — restore needs an off-host copy." : archive?.source === "pbs" ? `Forgets this snapshot from the PBS repository "${remoteArc?.repo_name ?? ""}". The action is permanent — PBS GC may reclaim the underlying chunks at the next garbage-collection run.` : `Deletes this archive from the Borg repository "${remoteArc?.repo_name ?? ""}". The action is permanent — Borg compacts the freed space at the next prune.`}
{archive?.source === "local" ? localArc?.id : remoteArc?.snapshot}
{/* ── Full run log viewer ─────────────────────────────────── */} Run log {archiveLog?.log_path}
{archiveLog?.content ?? ""}
        
) } // ── Manifest summary panel ─────────────────────────────────── function ManifestSummary({ manifest, }: { manifest: { source_host: ManifestSourceHost proxmenux_installed_components: Array<{ id: string; version_at_backup: string | null }> vms_lxcs_at_backup: { vms: unknown[]; lxcs: unknown[] } storage_inventory?: { zfs_pools?: unknown[]; lvm?: { vgs?: unknown[] } } } }) { const sh = manifest.source_host const zfsCount = manifest.storage_inventory?.zfs_pools?.length ?? 0 const lvmCount = manifest.storage_inventory?.lvm?.vgs?.length ?? 0 return (
} label="Source host" value={sh.hostname} />
{manifest.proxmenux_installed_components.length > 0 && (
ProxMenux components at backup time:
{manifest.proxmenux_installed_components.map((c) => ( {c.id}{c.version_at_backup ? ` @ ${c.version_at_backup}` : ""} ))}
)}
) } function Field({ icon, label, value, mono, labelClassName }: { icon?: React.ReactNode; label: string; value: string; mono?: boolean; labelClassName?: string }) { return (
{icon} {label}
{value}
) } // Renders the retention policy as a row of labeled badges. The runner's // .env stores six discrete keys (KEEP_LAST / HOURLY / DAILY / WEEKLY / // MONTHLY / YEARLY); the original UI dumped them as a comma-separated // `key=val, key=val…` string which read like a config file. This view // drops zero-valued entries and presents what survives as ordered chips. function RetentionDisplay({ retention }: { retention: Record }) { const order: Array<[string, string]> = [ ["keep_last", "last"], ["keep_hourly", "hourly"], ["keep_daily", "daily"], ["keep_weekly", "weekly"], ["keep_monthly", "monthly"], ["keep_yearly", "yearly"], ] const items = order .map(([k, lbl]) => { const v = (retention[k] || "").trim() const n = Number.parseInt(v, 10) if (!v || !Number.isFinite(n) || n <= 0) return null return { label: lbl, value: n } }) .filter((x): x is { label: string; value: number } => x !== null) return (
retention
{items.length === 0 ? (
No retention rules — backups will accumulate.
) : (
{items.map((it) => ( {it.label} {it.value} ))}
)}
) } // Renders the backup profile paths as a 2-column grid of monospaced // rows instead of a single ` · `-separated line. With ~30 paths the // old layout forced a horizontal scroll on the dialog because the // line couldn't be wrapped without breaking the path identifiers. function PathsDisplay({ paths }: { paths: string[] }) { return (
paths ({paths.length})
{paths.map((p) => ( {p} ))}
) } // ── Preflight report view ──────────────────────────────────── function PreflightReportView({ report }: { report: PreflightReport }) { const { summary, checks } = report.preflight const passColor = "text-emerald-500" const warnColor = "text-amber-500" const failColor = "text-red-500" return (
{/* Summary line */}
{summary.pass} pass {summary.warn} warn {summary.fail} fail {summary.fail > 0 && ( --apply would be refused )}
{/* Per-check list */}
{checks.map((c) => { const color = c.severity === "pass" ? passColor : c.severity === "warn" ? warnColor : failColor const Icon = c.severity === "pass" ? CheckCircle2 : c.severity === "warn" ? AlertTriangle : XCircle return (
{c.id} {c.message}
) })}
{/* Storage / network counts */}
Storage [in mode: {String(report.storage.in_selected_mode)}]
{report.storage.zfs.length} ZFS pool(s) · {" "}{report.storage.lvm.length} LVM VG(s) · {" "}{report.storage.pve_storage.length} PVE storage(s)
Network [in mode: {String(report.network.in_selected_mode)}]
{report.network.keep.length} keep · {" "}{report.network.remap.length} remap · {" "}{report.network.orphan.length} orphan · {" "}{report.network.new.length} new
{/* Driver plan */} {report.driver_reinstall.plan.length > 0 && (
Driver reinstall plan ({report.driver_reinstall.plan.length})
{report.driver_reinstall.plan.map((p) => (
{p.component_id} {p.action}
))}
)} {/* Abort reason (if --apply would have been refused) */} {report.abort_reason && (
{report.abort_reason}
)}
) } // ────────────────────────────────────────────────────────────── // Create job wizard (A3.1 — attached jobs only). // // Pre-loads three things in parallel so the form can render selects // the moment the operator opens it: // - PVE vzdump jobs (filtered by backend once they pick one) // - Pre-configured destinations (PBS repos, Borg targets, local) // - Default profile path list (used when profile_mode === "default" // and also as the pool the custom checklist is populated from) // ────────────────────────────────────────────────────────────── interface PveVzdumpJob { id: string storage: string | null storage_type: string | null schedule: string | null prune: string | null enabled: boolean } // All Destination shapes carry `jobs_using`: list of job_ids that // currently depend on the destination. Surfaced in the Delete- // destination confirm flow so the operator sees the cascade impact // before agreeing. interface PbsRepo { name: string repository: string fingerprint: string | null source: "proxmox" | "manual" jobs_using?: string[] } interface BorgRepo { name: string repository: string ssh_key_path?: string // Encryption + saved-passphrase metadata. Newer backends ship these; // older deployments without the fields default to "repokey" (the // shell installer's historical default) and unknown-passphrase. encrypt_mode?: "none" | "repokey" | "keyfile" | "authenticated" has_passphrase?: boolean jobs_using?: string[] } interface LocalTargetEntry { path: string source: "default" | "custom" removable: boolean jobs_using?: string[] } interface LocalTarget { configured: string | null default: string effective: string // Multi-path layout: the default plus any custom paths stacked on // top. Present on every recent backend; older deployments without // this field fall back to the single configured/effective accessors. entries?: LocalTargetEntry[] } interface DestinationsResp { pbs: PbsRepo[] borg: BorgRepo[] local: LocalTarget } interface JobDetail { id: string attached: boolean enabled: boolean manual: boolean method: string destination: string on_calendar: string // human-formatted ("attached → storage:pbs" or "*-*-* 02:00") next_run: string | null pve_storage: string | null pve_parent_job_id: string | null profile_mode: string paths: string[] on_calendar_raw: string | null retention: Record pbs_repository: string | null pbs_backup_id: string | null pbs_fingerprint: string | null has_pbs_password: boolean // Legacy — kept for backend responses that only track the on/off // toggle. Newer builds also send pbs_encrypt_mode ("none" | "new" // | "existing"); loadFromJobDetail prefers that when present. pbs_encrypt: boolean pbs_encrypt_mode?: "none" | "new" | "existing" local_dest_dir: string | null local_archive_ext: string | null borg_repo: string | null borg_encrypt_mode: string has_borg_passphrase: boolean // Log fields from Sprint Pulido P1 last_result: string | null // "ok" / "failed" / etc. last_run_at: string | null // ISO-8601 last_log_path: string | null last_log_size: number last_log_tail: string[] } function CreateJobDialog({ open, onClose, onCreated, editingJobId, }: { open: boolean onClose: () => void onCreated: () => void editingJobId?: string | null }) { const isEdit = !!editingJobId const [step, setStep] = useState<1 | 2 | 3 | 4 | 5>(1) const [jobId, setJobId] = useState("") const [backend, setBackend] = useState<"pbs" | "local" | "borg">("pbs") const [mode, setMode] = useState<"new" | "attach">("new") const [pveJobId, setPveJobId] = useState("") const [profileMode, setProfileMode] = useState<"default" | "custom">("default") const [customPaths, setCustomPaths] = useState>(new Set()) // New (timer-based) mode fields — onCalendar is computed from the // builder controls below; the raw expression is only directly edited // when scheduleType === "advanced". const [scheduleType, setScheduleType] = useState<"daily" | "hourly" | "weekly" | "monthly" | "advanced">("daily") const [scheduleTime, setScheduleTime] = useState("02:00") const [scheduleMinute, setScheduleMinute] = useState("0") const [scheduleWeekdays, setScheduleWeekdays] = useState>(new Set(["Sun"])) const [scheduleDayOfMonth, setScheduleDayOfMonth] = useState("1") const [scheduleAdvanced, setScheduleAdvanced] = useState("daily") const [keepLast, setKeepLast] = useState("7") const [keepHourly, setKeepHourly] = useState("0") const [keepDaily, setKeepDaily] = useState("7") const [keepWeekly, setKeepWeekly] = useState("4") const [keepMonthly, setKeepMonthly] = useState("3") const [keepYearly, setKeepYearly] = useState("0") // Backend-specific fields const [pbsRepository, setPbsRepository] = useState("") const [pbsBackupId, setPbsBackupId] = useState("") // Client-side PBS encryption mode. Three modes match the shell wizard: // - "none" — plain backup, no --keyfile // - "new" — backend generates a fresh per-host keyfile (default) // - "existing" — operator uploads their own keyfile (shared across // hosts). The upload happens via a dedicated endpoint // BEFORE the job create call — see handleCreate below. const [pbsEncryptMode, setPbsEncryptMode] = useState<"none" | "new" | "existing">("none") // File picked in the "existing" mode. Kept in a ref-like state so a // stale reference doesn't linger across modal opens. const [pbsImportFile, setPbsImportFile] = useState(null) // Absolute-path alternative to the file upload — mirrors the shell // wizard's `_hb_pbs_import_dialog` when there is no PVE .enc match. const [pbsImportPath, setPbsImportPath] = useState("") const [pbsImportBusy, setPbsImportBusy] = useState(false) // Legacy alias for the many recovery / gating checks below — a truthy // value means "the operator wants an encrypted backup" regardless of // whether that keyfile came from a fresh generate or from an import. const pbsEncrypt = pbsEncryptMode !== "none" // Optional recovery passphrase. When set, the backend encrypts the // keyfile with openssl and the runner uploads that blob to PBS with // every backup so the keyfile can be rebuilt on a fresh host with // only the passphrase. Mirrors hb_pbs_setup_recovery from the shell. const [pbsRecoveryPass, setPbsRecoveryPass] = useState("") const [pbsRecoveryPass2, setPbsRecoveryPass2] = useState("") // 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(false) // Binary "upload key to PBS?" toggle. `true` maps to escrow_mode='full' // (passphrase required, envelope uploaded on every backup); `false` // maps to escrow_mode='none' (keyfile stays local, operator handles // the offsite copy). Defaults to 'No' — the keyfile is always // downloadable from the Monitor, so the operator can grab it and // save it offsite themselves without leaving a passphrase-wrapped // copy on the PBS server. const [pbsUploadToPbs, setPbsUploadToPbs] = useState(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. const { data: pbsRecoveryStatus, mutate: mutatePbsRecovery } = useSWR<{ has_keyfile: boolean; has_recovery: boolean; has_keyfile_passphrase?: boolean; escrow_mode?: "none" | "local" | "full" }>( open && backend === "pbs" ? "/api/host-backups/pbs-recovery/status" : null, fetcher, ) // Auto-discover a PVE-managed keyfile for the currently selected // PBS repository. When one is found, the "Use an existing keyfile" // radio uses it silently (matches the shell wizard's PVE auto-detect // path — the operator doesn't have to upload the same file twice). const { data: pbsPveDiscover } = useSWR<{ entries: Array<{ name: string; server: string; datastore: string; path: string; matches_repository: boolean }> }>( open && backend === "pbs" && pbsRepository && !pbsRecoveryStatus?.has_keyfile ? `/api/host-backups/pbs-encryption/discover-pve-keyfiles?repository=${encodeURIComponent(pbsRepository)}` : null, fetcher, ) const pbsPveMatch = pbsPveDiscover?.entries?.find((e) => e.matches_repository) const [localDestDir, setLocalDestDir] = useState("") const [borgRepoSelected, setBorgRepoSelected] = useState("") const [borgPassphrase, setBorgPassphrase] = useState("") const [borgEncryptMode, setBorgEncryptMode] = useState<"none" | "repokey" | "keyfile" | "authenticated">("repokey") const [submitting, setSubmitting] = useState(false) const [error, setError] = useState(null) // Build the canonical OnCalendar string from the builder controls. // The dropdown plus the small sub-inputs are friendlier than asking // the operator to remember the systemd Calendar Events grammar. const padHH = (s: string) => { const [hh = "00", mm = "00"] = s.split(":") return `${hh.padStart(2, "0")}:${mm.padStart(2, "0")}:00` } let onCalendar = "daily" if (scheduleType === "daily") { onCalendar = `*-*-* ${padHH(scheduleTime)}` } else if (scheduleType === "hourly") { const m = Math.max(0, Math.min(59, Number(scheduleMinute) || 0)) onCalendar = `*-*-* *:${String(m).padStart(2, "0")}:00` } else if (scheduleType === "weekly") { const days = Array.from(scheduleWeekdays) onCalendar = days.length > 0 ? `${days.join(",")} *-*-* ${padHH(scheduleTime)}` : `*-*-* ${padHH(scheduleTime)}` } else if (scheduleType === "monthly") { const d = Math.max(1, Math.min(31, Number(scheduleDayOfMonth) || 1)) onCalendar = `*-*-${String(d).padStart(2, "0")} ${padHH(scheduleTime)}` } else if (scheduleType === "advanced") { onCalendar = scheduleAdvanced.trim() } // Edit mode: fetch the full job (paths, retention, backend-specific) once // the dialog opens, then pre-populate the form fields on arrival. const { data: jobDetail } = useSWR( open && isEdit ? `/api/host-backups/jobs/${encodeURIComponent(editingJobId || "")}` : null, fetcher, ) const [editPreloaded, setEditPreloaded] = useState(false) // Snapshot of the job's backend/mode at the moment we entered edit // mode, so Step 5 can call out a change explicitly ("you're now // configuring a Borg destination" etc.) instead of just dropping the // operator into an unfamiliar set of fields. const [originalBackend, setOriginalBackend] = useState<"pbs" | "local" | "borg" | null>(null) const [originalMode, setOriginalMode] = useState<"new" | "attach" | null>(null) useEffect(() => { if (!isEdit || !jobDetail || editPreloaded) return setJobId(jobDetail.id) setBackend((jobDetail.method as "pbs" | "local" | "borg") || "pbs") setMode(jobDetail.attached ? "attach" : "new") setOriginalBackend((jobDetail.method as "pbs" | "local" | "borg") || "pbs") setOriginalMode(jobDetail.attached ? "attach" : "new") setProfileMode((jobDetail.profile_mode as "default" | "custom") || "default") if (jobDetail.profile_mode === "custom") { setCustomPaths(new Set(jobDetail.paths)) } if (!jobDetail.attached && jobDetail.on_calendar_raw) { setScheduleType("advanced") setScheduleAdvanced(jobDetail.on_calendar_raw) } if (jobDetail.attached && jobDetail.pve_parent_job_id) { setPveJobId(jobDetail.pve_parent_job_id) } const r = jobDetail.retention || {} if (r.keep_last !== undefined) setKeepLast(String(r.keep_last)) if (r.keep_hourly !== undefined) setKeepHourly(String(r.keep_hourly)) if (r.keep_daily !== undefined) setKeepDaily(String(r.keep_daily)) if (r.keep_weekly !== undefined) setKeepWeekly(String(r.keep_weekly)) if (r.keep_monthly !== undefined) setKeepMonthly(String(r.keep_monthly)) if (r.keep_yearly !== undefined) setKeepYearly(String(r.keep_yearly)) if (jobDetail.pbs_repository) setPbsRepository(jobDetail.pbs_repository) if (jobDetail.pbs_backup_id) setPbsBackupId(jobDetail.pbs_backup_id) // Prefer the new pbs_encrypt_mode string when the backend sends it; // fall back to the legacy bool for older `.env` layouts that only // remember the on/off toggle. const jd = jobDetail as unknown as { pbs_encrypt_mode?: string; pbs_encrypt?: boolean } if (jd.pbs_encrypt_mode === "new" || jd.pbs_encrypt_mode === "existing" || jd.pbs_encrypt_mode === "none") { setPbsEncryptMode(jd.pbs_encrypt_mode) } else { setPbsEncryptMode(jd.pbs_encrypt ? "new" : "none") } if (jobDetail.local_dest_dir) setLocalDestDir(jobDetail.local_dest_dir) if (jobDetail.borg_repo) setBorgRepoSelected(jobDetail.borg_repo) if (jobDetail.borg_encrypt_mode) { setBorgEncryptMode(jobDetail.borg_encrypt_mode as "none" | "repokey" | "keyfile" | "authenticated") } setEditPreloaded(true) }, [isEdit, jobDetail, editPreloaded]) // Reset the preload flag whenever the dialog closes so the next open // (potentially for a different job) re-populates fresh. useEffect(() => { if (!open) { setEditPreloaded(false) setOriginalBackend(null) setOriginalMode(null) } }, [open]) const { data: pveJobsResp } = useSWR<{ jobs: PveVzdumpJob[] }>( open && mode === "attach" && backend !== "borg" ? `/api/host-backups/pve-vzdump-jobs?backend=${backend}` : null, fetcher, ) // Live calendar preview — only fetch when we're actually on Step 3 in // "new" mode, otherwise the request is noise. const calendarPreviewKey = open && mode === "new" && step === 3 && onCalendar.trim().length > 0 ? `calendar-preview::${onCalendar}` : null const { data: calendarPreview } = useSWR<{ valid: boolean error?: string normalized?: string next_elapse?: string from_now?: string }>( calendarPreviewKey, () => fetchApi("/api/host-backups/calendar-preview", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ expr: onCalendar }), }), { dedupingInterval: 400 }, ) const { data: destResp, mutate: mutateDest } = useSWR( open ? "/api/host-backups/destinations" : null, fetcher, ) const { data: pathsResp } = useSWR<{ paths: string[] }>( open ? "/api/host-backups/default-paths" : null, fetcher, ) // The wizard can spawn AddDestinationDialog inline so the operator // configures a PBS / Borg destination without leaving the flow. // When AddDestinationDialog saves, we capture the new repository // string and auto-select it on the next destResp refresh — saves the // operator from picking what they just configured from the dropdown. const [addingDestType, setAddingDestType] = useState<"pbs" | "borg" | "local" | null>(null) const [pendingAutoSelectDest, setPendingAutoSelectDest] = useState<{ kind: "pbs" | "borg"; repository: string } | null>(null) // When the destinations load, auto-pick the first PBS repo + fill in // the fingerprint reference so the operator doesn't have to navigate // away just to copy it. Same convenience for the backup-id. const selectedPbs: PbsRepo | undefined = destResp?.pbs?.find((r) => r.repository === pbsRepository) ?? destResp?.pbs?.[0] // Reset state on open / backend change useEffect(() => { if (!open) { setStep(1) setJobId("") setBackend("pbs") setMode("new") setPveJobId("") setProfileMode("default") setCustomPaths(new Set()) setScheduleType("daily") setScheduleTime("02:00") setScheduleMinute("0") setScheduleWeekdays(new Set(["Sun"])) setScheduleDayOfMonth("1") setScheduleAdvanced("daily") setKeepLast("7") setKeepHourly("0") setKeepDaily("7") setKeepWeekly("4") setKeepMonthly("3") setKeepYearly("0") setPbsRepository("") setPbsBackupId("") setPbsEncryptMode("none") setPbsImportFile(null) setPbsImportPath("") setPbsImportBusy(false) setPbsRecoveryPass("") setPbsRecoveryPass2("") setPbsRecoveryChange(false) setPbsUploadToPbs(false) setLocalDestDir("") setBorgRepoSelected("") setBorgPassphrase("") setBorgEncryptMode("repokey") setError(null) setSubmitting(false) } }, [open]) // Borg can only be timer-based; coerce mode back to "new" if the // operator picks borg after having selected attach. useEffect(() => { if (backend === "borg" && mode === "attach") { setMode("new") } }, [backend, mode]) useEffect(() => { setPveJobId("") }, [backend, mode]) useEffect(() => { if (backend === "pbs" && !pbsRepository && destResp?.pbs?.length) { setPbsRepository(destResp.pbs[0].repository) } if (backend === "borg" && !borgRepoSelected && destResp?.borg?.length) { setBorgRepoSelected(destResp.borg[0].repository) } }, [backend, destResp, pbsRepository, borgRepoSelected]) useEffect(() => { if (!pendingAutoSelectDest || !destResp) return if (pendingAutoSelectDest.kind === "pbs") { const hit = destResp.pbs?.find((r) => r.repository === pendingAutoSelectDest.repository) if (hit) { setPbsRepository(hit.repository) setPendingAutoSelectDest(null) } } else { const hit = destResp.borg?.find((r) => r.repository === pendingAutoSelectDest.repository) if (hit) { setBorgRepoSelected(hit.repository) setPendingAutoSelectDest(null) } } }, [destResp, pendingAutoSelectDest]) const togglePath = (path: string) => { setCustomPaths((prev) => { const next = new Set(prev) if (next.has(path)) next.delete(path) else next.add(path) return next }) } const selectedPveJob: PveVzdumpJob | undefined = pveJobsResp?.jobs?.find((j) => j.id === pveJobId) const selectedBorgRepo: BorgRepo | undefined = destResp?.borg?.find((r) => r.repository === borgRepoSelected) // ── Step validation gates ── const idValid = /^[a-zA-Z0-9_-]+$/.test(jobId) && jobId.length > 0 const canAdvanceFrom1 = idValid const canAdvanceFrom2 = true // selecting New/Attach is always possible // Step 3 depends on mode. For "new" we additionally block advancing // if the live calendar preview already came back invalid, or if // weekly was picked without any day selected. const canAdvanceFrom3 = mode === "attach" ? !!pveJobId : onCalendar.trim().length > 0 && (calendarPreview === undefined || calendarPreview.valid !== false) && (scheduleType !== "weekly" || scheduleWeekdays.size > 0) // Step 4: profile const canAdvanceFrom4 = profileMode === "default" || (profileMode === "custom" && customPaths.size > 0) // Step 5 (submit) — backend-specific requirements const backendValid = backend === "pbs" ? !!pbsRepository : backend === "local" ? true : /* borg */ !!borgRepoSelected // PBS encryption gate — choosing "Encrypt" makes the recovery // passphrase mandatory. The button stays disabled until a matching // passphrase pair is entered (or an already-saved escrow is reused). // There is no opt-out: encrypting without recovery would leave the // keyfile only on this host, and a reinstall would render every // encrypted backup unrecoverable. // Passphrase requirement only kicks in when: encryption is on AND // there's no keyfile installed yet (fresh setup) AND the operator // opted to upload the key to PBS. Every other case (reuse existing // keyfile, or "no upload" mode) doesn't need a passphrase. const pbsRecoveryOk = !(backend === "pbs" && pbsEncrypt) || pbsRecoveryStatus?.has_keyfile || !pbsUploadToPbs || (pbsRecoveryPass !== "" && pbsRecoveryPass === pbsRecoveryPass2) const canSubmit = canAdvanceFrom1 && canAdvanceFrom2 && canAdvanceFrom3 && canAdvanceFrom4 && backendValid && pbsRecoveryOk async function handleCreate() { if (!canSubmit) return if (mode === "attach" && !selectedPveJob) return setSubmitting(true) setError(null) // Encryption submit paths: // • enabled + keyfile already on disk → mode = "existing", no upload // (backend reuses the canonical file at _PBS_KEYFILE_PATH). // • enabled + no keyfile + Generate → mode = "new", backend creates it. // • enabled + no keyfile + Import → upload the file first, then // mode = "existing" so the backend uses the freshly-installed key. // // Only the third branch actually needs to POST to // /api/host-backups/pbs-encryption/import — the second is handled // server-side, and the first has nothing to upload. // Unified encryption setup: one atomic call to /pbs-encryption/import // covers keyfile install + escrow setup, whether the operator picked // "generate a new keyfile" or "import an existing one", and whether // they opted to upload the envelope to PBS or keep the key local. const needsInstall = backend === "pbs" && pbsEncrypt && !pbsRecoveryStatus?.has_keyfile if (needsInstall) { // Existing + no PVE auto-detect requires either a file upload // or a typed absolute path — mirrors the shell wizard. // Existing + PVE auto-detect uses source=pve-storage. if (pbsEncryptMode === "existing" && !pbsPveMatch && !pbsImportFile && !pbsImportPath.trim()) { setError("Pick a keyfile file or enter an absolute path on this host.") setSubmitting(false) return } if (pbsUploadToPbs && pbsRecoveryPass !== pbsRecoveryPass2) { setError("Recovery passphrases don't match.") setSubmitting(false) return } setPbsImportBusy(true) try { // Source picker mirrors the shell wizard's dispatch: // generate → new keyfile via proxmox-backup-client // pve-storage → reuse /etc/pve/priv/storage/.enc // content → upload a file the operator picked // path → read a file at an absolute path let source: "generate" | "pve-storage" | "content" | "path" if (pbsEncryptMode === "new") source = "generate" else if (pbsPveMatch) source = "pve-storage" else if (pbsImportFile) source = "content" else source = "path" const body: Record = { source, escrow_mode: pbsUploadToPbs ? "full" : "none", } if (pbsUploadToPbs) body.escrow_passphrase = pbsRecoveryPass if (source === "pve-storage") { body.pve_storage_name = pbsPveMatch!.name } else if (source === "content") { const buf = new Uint8Array(await pbsImportFile!.arrayBuffer()) let bin = "" for (let i = 0; i < buf.length; i++) bin += String.fromCharCode(buf[i]) body.content_b64 = btoa(bin) } else if (source === "path") { body.keyfile_path = pbsImportPath.trim() } await fetchApi("/api/host-backups/pbs-encryption/import", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }) mutatePbsRecovery() } catch (e) { const err = e as Error & { body?: { tool_output?: string; tool_exit_code?: number } } const detail = err.body?.tool_output ? `${err.message}\n\nproxmox-backup-client output:\n${err.body.tool_output}` : err.message setError(`Encryption setup failed: ${detail || String(e)}`) setPbsImportBusy(false) setSubmitting(false) return } setPbsImportBusy(false) } // NOTE: recovery escrow is now bundled into the /pbs-encryption/import // call above. No separate /pbs-recovery/setup pass is needed. try { const body: Record = { id: jobId, backend, attached: mode === "attach", profile_mode: profileMode, enabled: true, } if (mode === "attach" && selectedPveJob) { body.pve_storage = selectedPveJob.storage body.pve_parent_job_id = selectedPveJob.id } else { body.on_calendar = onCalendar.trim() body.retention = { keep_last: Number(keepLast) || 0, keep_hourly: Number(keepHourly) || 0, keep_daily: Number(keepDaily) || 0, keep_weekly: Number(keepWeekly) || 0, keep_monthly: Number(keepMonthly) || 0, keep_yearly: Number(keepYearly) || 0, } } if (profileMode === "custom") { body.paths = Array.from(customPaths) } if (backend === "pbs") { body.pbs_repository = pbsRepository body.pbs_password = "" if (pbsBackupId) body.pbs_backup_id = pbsBackupId if (selectedPbs?.fingerprint) body.pbs_fingerprint = selectedPbs.fingerprint body.pbs_encrypt_mode = pbsEncryptMode } else if (backend === "local") { if (localDestDir.trim()) body.local_dest_dir = localDestDir.trim() } else if (backend === "borg") { // Passphrase + encrypt_mode are inherited from the destination // (the runner reads borg-pass-.txt + the 4th field of // borg-targets.txt). Only send overrides when the operator // really typed a new one in an edit flow. body.borg_repo = borgRepoSelected if (isEdit && borgPassphrase) body.borg_passphrase = borgPassphrase } // fetchApi returns the parsed JSON and throws (with the backend's // error message in `error.message` when the body was JSON) on any // non-2xx status — no need to inspect .ok or call .json() ourselves. const url = isEdit ? `/api/host-backups/jobs/${encodeURIComponent(editingJobId || "")}` : "/api/host-backups/jobs" await fetchApi(url, { method: isEdit ? "PUT" : "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }) onCreated() } catch (e) { setError(e instanceof Error ? e.message : String(e)) } finally { setSubmitting(false) } } const compatibleJobs = pveJobsResp?.jobs ?? [] const defaultPaths = pathsResp?.paths ?? [] return ( { if (!v) onClose() }}> {isEdit ? ( ) : ( )} {isEdit ? "Edit scheduled backup job" : "Create scheduled backup job"} Step {step} of 5 · {mode === "attach" ? "Attached to PVE vzdump" : "Standalone scheduled job"} {/* Step progress bar */}
{[1, 2, 3, 4, 5].map((n) => (
))}
{/* ── Step 1: ID + Backend ─────────────────────── */} {step === 1 && (
setJobId(e.target.value)} disabled={isEdit} className="font-mono mt-1" placeholder="my-host-backup" />

{isEdit ? "The job name can't be changed. Delete and recreate the job if you want to rename it." : <>A short name to identify this job in the list, logs, and shell menu. Letters, digits, _ and - only (no spaces or accents).}

{!idValid && jobId.length > 0 && !isEdit && (

Invalid characters. Use letters, digits, _ or -.

)}
{isEdit && (

You can change where the backup is sent. The destination of the new option is set on Step 5.

)}
{(["pbs", "local", "borg"] as const).map((b) => { const Icon = b === "pbs" ? Server : b === "local" ? HardDrive : Archive const desc = b === "pbs" ? "Proxmox Backup Server. Incremental, encrypted, dedup." : b === "local" ? "tar.zst archive into a local directory or mounted disk." : "Borg repo over SSH or on a local/USB disk (timer only)." return ( ) })}
)} {/* ── Step 2: How to schedule ──────────────────── */} {step === 2 && (

{backend === "borg" ? "Borg backups only run on their own timer — they're not produced by PVE vzdump." : "Either run on a schedule you define here, or hook into an existing PVE vzdump job and inherit its schedule + retention."}

)} {/* ── Step 3: branches by mode ─────────────────── */} {step === 3 && mode === "attach" && (

The host config backup will fire on every job-end of this job.

{compatibleJobs.length === 0 ? (
No compatible PVE vzdump job

No PVE vzdump job currently uses a {backend} storage. Create one in Datacenter → Backup first, then come back here to attach.

) : (
{compatibleJobs.map((j) => ( ))}
)}
)} {step === 3 && mode === "new" && (

Pick how often this backup runs. The expression is built and validated for you.

{scheduleType === "daily" && (
setScheduleTime(e.target.value)} className="font-mono mt-1 max-w-[140px]" />
)} {scheduleType === "hourly" && (
setScheduleMinute(e.target.value)} className="font-mono mt-1 max-w-[120px]" />

The job fires every hour at this minute. 0 = on the hour, 30 = half past, etc.

)} {scheduleType === "weekly" && (
{["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"].map((d) => { const active = scheduleWeekdays.has(d) return ( ) })}
{scheduleWeekdays.size === 0 && (

Pick at least one day.

)}
setScheduleTime(e.target.value)} className="font-mono mt-1 max-w-[140px]" />
)} {scheduleType === "monthly" && (
setScheduleDayOfMonth(e.target.value)} className="font-mono mt-1 max-w-[120px]" />

If the chosen day doesn't exist in a given month (e.g. 31 in February), systemd skips that month.

setScheduleTime(e.target.value)} className="font-mono mt-1 max-w-[140px]" />
)} {scheduleType === "advanced" && (
setScheduleAdvanced(e.target.value)} className="font-mono mt-1" placeholder="*-*-* 02:00, Mon..Fri *-*-* 04:00, daily, ..." />

Any expression accepted by systemd-analyze calendar. See man systemd.time for the full grammar.

)} {/* Live preview from the backend */}
Preview
Expression: {onCalendar}
{calendarPreview ? ( calendarPreview.valid ? ( <> {calendarPreview.normalized && calendarPreview.normalized !== onCalendar && (
Normalized: {calendarPreview.normalized}
)} {calendarPreview.next_elapse && (
Next run: {calendarPreview.next_elapse} {calendarPreview.from_now && ( ({calendarPreview.from_now}) )}
)} ) : (
Invalid: {calendarPreview.error}
) ) : (
checking…
)}

Zero disables that bucket.

{[ { id: "keep-last", lbl: "keep-last", v: keepLast, set: setKeepLast }, { id: "keep-hourly", lbl: "keep-hourly", v: keepHourly, set: setKeepHourly }, { id: "keep-daily", lbl: "keep-daily", v: keepDaily, set: setKeepDaily }, { id: "keep-weekly", lbl: "keep-weekly", v: keepWeekly, set: setKeepWeekly }, { id: "keep-monthly", lbl: "keep-monthly", v: keepMonthly, set: setKeepMonthly }, { id: "keep-yearly", lbl: "keep-yearly", v: keepYearly, set: setKeepYearly }, ].map((row) => (
row.set(e.target.value)} className="font-mono mt-1" />
))}
)} {/* ── Step 4: Profile + paths ──────────────────── */} {step === 4 && (
{profileMode === "custom" && (
{defaultPaths.map((p) => ( ))}
)}
)} {/* ── Step 5: Backend-specific + summary ──────── */} {step === 5 && (
{backend === "pbs" && ( <>
{destResp?.pbs?.length ? (
) : (
No PBS repository configured yet. You can add one without leaving this wizard.
)}
setPbsBackupId(e.target.value)} placeholder="Leave blank for the default" className="font-mono mt-1" />

PBS organises backups into named groups, each with its own retention. Leave blank to use the automatic default for this host (recommended).

{/* PBS client-side encryption — mirrors the shell wizard: step 1 is a plain yes/no, step 2 only appears when no keyfile is installed yet. Once a keyfile exists, every future encrypted job silently reuses it. */}
{pbsEncrypt && !pbsRecoveryStatus?.has_keyfile && (
No encryption key is stored on this host. Choose how to set one up:
{pbsEncryptMode === "existing" && pbsPveMatch && (
Auto-detected from PVE storage '{pbsPveMatch.name}'
Existing key at {pbsPveMatch.path}
Will be copied to the ProxMenux state directory. No file upload needed.
)} {pbsEncryptMode === "existing" && !pbsPveMatch && (
setPbsImportFile(e.target.files?.[0] ?? null)} disabled={pbsImportBusy || !!pbsImportPath.trim()} className="h-8 text-[11px]" />
— or —
setPbsImportPath(e.target.value)} disabled={pbsImportBusy || !!pbsImportFile} placeholder="e.g. /etc/pve/priv/storage/.enc or /root/my-pbs-key" className="h-8 text-[11px] font-mono" />

Either option works. The file lands at /usr/local/share/proxmenux/pbs-key.conf and every subsequent encrypted backup reuses it.

)}
)} {pbsEncrypt && (
{pbsRecoveryStatus?.has_keyfile && !pbsRecoveryChange && (
Keyfile installed. Backups reuse it silently.
The current PBS-upload setting stays in effect.
)} {pbsRecoveryStatus?.has_keyfile && pbsRecoveryChange && ( <> )} {!pbsRecoveryStatus?.has_keyfile && ( <>
Upload key to PBS?
{pbsUploadToPbs ? (
setPbsRecoveryPass(e.target.value)} placeholder="Long random string — write it down somewhere safe" className="font-mono mt-1 h-8 text-xs" />
setPbsRecoveryPass2(e.target.value)} placeholder="Type it again" className="font-mono mt-1 h-8 text-xs" /> {pbsRecoveryPass && pbsRecoveryPass2 && pbsRecoveryPass !== pbsRecoveryPass2 && (

Passphrases don't match.

)}
) : (
Keep an offsite copy of the keyfile.
After the job is created, copy /usr/local/share/proxmenux/pbs-key.conf to an external medium (USB, password manager, another host).
Without that copy, losing this host makes every encrypted backup on PBS unreadable.
)} )}
)}
)} {backend === "local" && (
{mode === "attach" ? ( <> setLocalDestDir(e.target.value)} placeholder="Auto (derived from the PVE storage path)" className="font-mono" />

Leave blank to derive automatically from the parent PVE job's storage at backup time. Default is the storage's /dump subdir, falling back to /var/lib/vz/dump.

) : ( <> {(destResp?.local?.entries?.length ?? 0) > 0 && (
)} setLocalDestDir(e.target.value)} placeholder={destResp?.local?.effective || "/var/lib/vz/dump"} className="font-mono" />

Pick a configured destination above, or type a custom directory path. Empty input falls back to {destResp?.local?.effective || "/var/lib/vz/dump"}.

)}
)} {backend === "borg" && ( <>
{destResp?.borg?.length ? (
) : (
No Borg repository configured yet. You can add one without leaving this wizard (SSH or local/USB path supported).
)}
{/* Encryption + passphrase aren't asked here — they live on the borg destination. The runner reads the saved sidecar at run time. */} {(() => { const dest = destResp?.borg?.find((r) => r.repository === borgRepoSelected) if (!dest) return null const mode = dest.encrypt_mode || "repokey" return (

Encryption + passphrase are taken from the destination ({mode === "none" ? "no encryption" : mode}). Edit the destination if you need to change them.

) })()} )} {/* Summary preview — mirrors the JobDetailModal styling so what the operator sees at Create time is what they'll see when they open the job later. */} {(() => { const retentionPairs: Array<[string, string]> = [ ["last", String(keepLast || "")], ["hourly", String(keepHourly || "")], ["daily", String(keepDaily || "")], ["weekly", String(keepWeekly || "")], ["monthly", String(keepMonthly || "")], ["yearly", String(keepYearly || "")], ].filter(([, v]) => Number(v) > 0) as Array<[string, string]> return (
Summary
Name: {jobId}
Backend: {backend} {mode === "attach" ? "attached" : "scheduled"}
{mode === "attach" && selectedPveJob && ( <>
PVE job: {selectedPveJob.id}
inherited schedule: {humanizeOnCalendar(selectedPveJob.schedule)}
inherited retention: {selectedPveJob.prune}
)} {mode === "new" && ( <>
when: {humanizeOnCalendar(onCalendar)}
retention: {retentionPairs.length === 0 ? ( No retention rules ) : (
{retentionPairs.map(([k, v]) => ( {k} {v} ))}
)}
)}
profile: {profileMode} ({profileMode === "default" ? defaultPaths.length : customPaths.size} paths)
) })()} {error && (
{error}
)}
)}
{/* Footer navigation */}
{step > 1 && ( )} {step < 5 ? ( ) : ( )}
{/* AddDestinationDialog spawned from inside the wizard. When the operator saves a new PBS or Borg destination we refresh the dest list and queue the freshly-saved repository so the next destResp render auto-selects it in the dropdown. */} setAddingDestType(null)} onSaved={(repo) => { if (repo && (addingDestType === "pbs" || addingDestType === "borg")) { setPendingAutoSelectDest({ kind: addingDestType, repository: repo }) } setAddingDestType(null) mutateDest() }} />
) } // ────────────────────────────────────────────────────────────── // Manual backup dialog (Sprint B). // // One-shot version of CreateJobDialog: same backends + profile + paths // + backend-specific config, but NO schedule, NO retention, NO timer. // Writes a `manual-` .env with ENABLED=0 + MANUAL_RUN=1 and fires // the runner in the background. The resulting job appears in the // scheduler list with a "manual" badge and can be deleted when the // operator wants to clean up. // ────────────────────────────────────────────────────────────── function ManualBackupDialog({ open, onClose, onLaunched, }: { open: boolean onClose: () => void // The job_id of the just-launched manual run. The caller uses it to // transition straight into the live-progress modal so the operator // never has to chase a banner after pressing Run. onLaunched: (jobId: string) => void }) { const [step, setStep] = useState<1 | 2>(1) const [backend, setBackend] = useState<"pbs" | "local" | "borg">("local") const [profileMode, setProfileMode] = useState<"default" | "custom">("default") const [customPaths, setCustomPaths] = useState>(new Set()) const [pbsRepository, setPbsRepository] = useState("") const [pbsBackupId, setPbsBackupId] = useState("") // Encryption mode. See CreateJobDialog above for the full comment // block — the three modes and the import flow are identical here. const [pbsEncryptMode, setPbsEncryptMode] = useState<"none" | "new" | "existing">("none") const [pbsImportFile, setPbsImportFile] = useState(null) // Absolute-path alternative to the file upload — mirrors the shell // wizard's `_hb_pbs_import_dialog` when there is no PVE .enc match. const [pbsImportPath, setPbsImportPath] = useState("") const [pbsImportBusy, setPbsImportBusy] = useState(false) const pbsEncrypt = pbsEncryptMode !== "none" const [pbsRecoveryPass, setPbsRecoveryPass] = useState("") const [pbsRecoveryPass2, setPbsRecoveryPass2] = useState("") // 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(false) // Binary "upload key to PBS?" toggle. `true` maps to escrow_mode='full' // (passphrase required, envelope uploaded on every backup); `false` // maps to escrow_mode='none' (keyfile stays local, operator handles // the offsite copy). Defaults to 'No' — the keyfile is always // downloadable from the Monitor, so the operator can grab it and // save it offsite themselves without leaving a passphrase-wrapped // copy on the PBS server. const [pbsUploadToPbs, setPbsUploadToPbs] = useState(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. const { data: pbsRecoveryStatus, mutate: mutatePbsRecovery } = useSWR<{ has_keyfile: boolean; has_recovery: boolean; has_keyfile_passphrase?: boolean; escrow_mode?: "none" | "local" | "full" }>( open && backend === "pbs" ? "/api/host-backups/pbs-recovery/status" : null, fetcher, ) // Auto-discover a PVE-managed keyfile for the currently selected // PBS repository. When one is found, the "Use an existing keyfile" // radio uses it silently (matches the shell wizard's PVE auto-detect // path — the operator doesn't have to upload the same file twice). const { data: pbsPveDiscover } = useSWR<{ entries: Array<{ name: string; server: string; datastore: string; path: string; matches_repository: boolean }> }>( open && backend === "pbs" && pbsRepository && !pbsRecoveryStatus?.has_keyfile ? `/api/host-backups/pbs-encryption/discover-pve-keyfiles?repository=${encodeURIComponent(pbsRepository)}` : null, fetcher, ) const pbsPveMatch = pbsPveDiscover?.entries?.find((e) => e.matches_repository) const [localDestDir, setLocalDestDir] = useState("") const [borgRepoSelected, setBorgRepoSelected] = useState("") const [borgPassphrase, setBorgPassphrase] = useState("") const [borgEncryptMode, setBorgEncryptMode] = useState<"none" | "repokey" | "keyfile" | "authenticated">("repokey") const [submitting, setSubmitting] = useState(false) const [error, setError] = useState(null) const { data: destResp, mutate: mutateDest } = useSWR( open ? "/api/host-backups/destinations" : null, fetcher, ) const { data: pathsResp } = useSWR<{ paths: string[] }>( open ? "/api/host-backups/default-paths" : null, fetcher, ) // Same inline AddDestinationDialog flow as the scheduled CreateJobDialog. const [addingDestType, setAddingDestType] = useState<"pbs" | "borg" | "local" | null>(null) const [pendingAutoSelectDest, setPendingAutoSelectDest] = useState<{ kind: "pbs" | "borg"; repository: string } | null>(null) useEffect(() => { if (!pendingAutoSelectDest || !destResp) return if (pendingAutoSelectDest.kind === "pbs") { const hit = destResp.pbs?.find((r) => r.repository === pendingAutoSelectDest.repository) if (hit) { setPbsRepository(hit.repository) setPendingAutoSelectDest(null) } } else { const hit = destResp.borg?.find((r) => r.repository === pendingAutoSelectDest.repository) if (hit) { setBorgRepoSelected(hit.repository) setPendingAutoSelectDest(null) } } }, [destResp, pendingAutoSelectDest]) const selectedPbs = destResp?.pbs?.find((r) => r.repository === pbsRepository) ?? destResp?.pbs?.[0] useEffect(() => { if (!open) { setStep(1) setBackend("local") setProfileMode("default") setCustomPaths(new Set()) setPbsRepository("") setPbsBackupId("") setPbsEncryptMode("none") setPbsImportFile(null) setPbsImportPath("") setPbsImportBusy(false) setPbsRecoveryPass("") setPbsRecoveryPass2("") setPbsRecoveryChange(false) setPbsUploadToPbs(false) setLocalDestDir("") setBorgRepoSelected("") setBorgPassphrase("") setBorgEncryptMode("repokey") setError(null) setSubmitting(false) } }, [open]) useEffect(() => { if (backend === "pbs" && !pbsRepository && destResp?.pbs?.length) { setPbsRepository(destResp.pbs[0].repository) } if (backend === "borg" && !borgRepoSelected && destResp?.borg?.length) { setBorgRepoSelected(destResp.borg[0].repository) } }, [backend, destResp, pbsRepository, borgRepoSelected]) const togglePath = (path: string) => { setCustomPaths((prev) => { const next = new Set(prev) if (next.has(path)) next.delete(path) else next.add(path) return next }) } const defaultPaths = pathsResp?.paths ?? [] const canAdvanceFrom1 = profileMode === "default" || (profileMode === "custom" && customPaths.size > 0) const backendValid = backend === "pbs" ? !!pbsRepository : backend === "local" ? true // Borg destination carries its own passphrase + encryption. // No need to gate on local state — the wizard no longer asks. : !!borgRepoSelected // Recovery gate for PBS encryption. Choosing "Encrypt" makes the // recovery passphrase mandatory — same as the CLI. The "Run backup" // button stays disabled until a matching passphrase pair is entered // (or an already-saved escrow is reused). No accept-risk escape: // encrypting without recovery would leave the keyfile only on this // host, and a reinstall would render every encrypted backup // unrecoverable. // Passphrase requirement only kicks in when: encryption is on AND // there's no keyfile installed yet (fresh setup) AND the operator // opted to upload the key to PBS. Every other case (reuse existing // keyfile, or "no upload" mode) doesn't need a passphrase. const pbsRecoveryOk = !(backend === "pbs" && pbsEncrypt) || pbsRecoveryStatus?.has_keyfile || !pbsUploadToPbs || (pbsRecoveryPass !== "" && pbsRecoveryPass === pbsRecoveryPass2) const canSubmit = canAdvanceFrom1 && backendValid && pbsRecoveryOk async function handleRun() { if (!canSubmit) return setSubmitting(true) setError(null) // Unified encryption setup — mirrors CreateJobDialog. One atomic // /pbs-encryption/import call covers keyfile install + escrow // setup with source picked from what the operator provided. const needsInstall = backend === "pbs" && pbsEncrypt && !pbsRecoveryStatus?.has_keyfile if (needsInstall) { if (pbsEncryptMode === "existing" && !pbsPveMatch && !pbsImportFile && !pbsImportPath.trim()) { setError("Pick a keyfile file or enter an absolute path on this host.") setSubmitting(false) return } if (pbsUploadToPbs && pbsRecoveryPass !== pbsRecoveryPass2) { setError("Recovery passphrases don't match.") setSubmitting(false) return } setPbsImportBusy(true) try { let source: "generate" | "pve-storage" | "content" | "path" if (pbsEncryptMode === "new") source = "generate" else if (pbsPveMatch) source = "pve-storage" else if (pbsImportFile) source = "content" else source = "path" const body: Record = { source, escrow_mode: pbsUploadToPbs ? "full" : "none", } if (pbsUploadToPbs) body.escrow_passphrase = pbsRecoveryPass if (source === "pve-storage") { body.pve_storage_name = pbsPveMatch!.name } else if (source === "content") { const buf = new Uint8Array(await pbsImportFile!.arrayBuffer()) let bin = "" for (let i = 0; i < buf.length; i++) bin += String.fromCharCode(buf[i]) body.content_b64 = btoa(bin) } else if (source === "path") { body.keyfile_path = pbsImportPath.trim() } await fetchApi("/api/host-backups/pbs-encryption/import", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }) mutatePbsRecovery() } catch (e) { const err = e as Error & { body?: { tool_output?: string; tool_exit_code?: number } } const detail = err.body?.tool_output ? `${err.message}\n\nproxmox-backup-client output:\n${err.body.tool_output}` : err.message setError(`Encryption setup failed: ${detail || String(e)}`) setPbsImportBusy(false) setSubmitting(false) return } setPbsImportBusy(false) } // NOTE: recovery escrow is now bundled into the /pbs-encryption/import // call above. No separate /pbs-recovery/setup pass is needed. try { const body: Record = { backend, profile_mode: profileMode, } if (profileMode === "custom") { body.paths = Array.from(customPaths) } if (backend === "pbs") { body.pbs_repository = pbsRepository body.pbs_password = "" if (pbsBackupId) body.pbs_backup_id = pbsBackupId if (selectedPbs?.fingerprint) body.pbs_fingerprint = selectedPbs.fingerprint body.pbs_encrypt_mode = pbsEncryptMode } else if (backend === "local") { if (localDestDir.trim()) body.local_dest_dir = localDestDir.trim() } else if (backend === "borg") { // Passphrase + encrypt_mode are inherited from the destination // (borg-pass-.txt sidecar + 4th field of borg-targets.txt). body.borg_repo = borgRepoSelected } const resp = await fetchApi<{ status: string; job_id: string }>( "/api/host-backups/manual-run", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }, ) onLaunched(resp.job_id) } catch (e) { setError(e instanceof Error ? e.message : String(e)) } finally { setSubmitting(false) } } return ( { if (!v) onClose() }}> Run a one-shot backup Step {step} of 2 · The backup runs once. It appears in the list with a "manual" badge and won't fire again.
{[1, 2].map((n) => (
))}
{step === 1 && (
{(["pbs", "local", "borg"] as const).map((b) => { const Icon = b === "pbs" ? Server : b === "local" ? HardDrive : Archive const desc = b === "pbs" ? "Proxmox Backup Server." : b === "local" ? "tar.zst archive into a local directory." : "Borg repo (SSH or local/USB disk)." return ( ) })}
{profileMode === "custom" && (
{defaultPaths.map((p) => ( ))}
)}
)} {step === 2 && (
{backend === "pbs" && ( <>
{destResp?.pbs?.length ? (
) : (
No PBS repository configured yet. You can add one without leaving this dialog.
)}
setPbsBackupId(e.target.value)} placeholder="Leave blank for the default" className="font-mono mt-1" />
{pbsEncrypt && !pbsRecoveryStatus?.has_keyfile && (
No encryption key is stored on this host. Choose how to set one up:
{pbsEncryptMode === "existing" && pbsPveMatch && (
Auto-detected from PVE storage '{pbsPveMatch.name}'
Existing key at {pbsPveMatch.path}
Will be copied to the ProxMenux state directory. No file upload needed.
)} {pbsEncryptMode === "existing" && !pbsPveMatch && (
setPbsImportFile(e.target.files?.[0] ?? null)} disabled={pbsImportBusy || !!pbsImportPath.trim()} className="h-8 text-[11px]" />
— or —
setPbsImportPath(e.target.value)} disabled={pbsImportBusy || !!pbsImportFile} placeholder="e.g. /etc/pve/priv/storage/.enc or /root/my-pbs-key" className="h-8 text-[11px] font-mono" />

Either option works. The file lands at /usr/local/share/proxmenux/pbs-key.conf and every subsequent encrypted backup reuses it.

)}
)} {pbsEncrypt && (
{pbsRecoveryStatus?.has_keyfile && !pbsRecoveryChange && (
Keyfile installed. This backup reuses it silently.
The current PBS-upload setting stays in effect.
)} {pbsRecoveryStatus?.has_keyfile && pbsRecoveryChange && ( <> )} {!pbsRecoveryStatus?.has_keyfile && ( <>
Upload key to PBS?
{pbsUploadToPbs ? (
setPbsRecoveryPass(e.target.value)} placeholder="Long random string — write it down somewhere safe" className="font-mono mt-1 h-8 text-xs" />
setPbsRecoveryPass2(e.target.value)} placeholder="Type it again" className="font-mono mt-1 h-8 text-xs" /> {pbsRecoveryPass && pbsRecoveryPass2 && pbsRecoveryPass !== pbsRecoveryPass2 && (

Passphrases don't match.

)}
) : (
Keep an offsite copy of the keyfile.
After this backup, copy /usr/local/share/proxmenux/pbs-key.conf to an external medium (USB, password manager, another host).
Without that copy, losing this host makes every encrypted backup on PBS unreadable.
)} )}
)}
)} {backend === "local" && (
{(destResp?.local?.entries?.length ?? 0) > 0 && (
)}

Pick one of the configured local destinations, or type a custom directory path below. Empty input falls back to the selected destination above.

setLocalDestDir(e.target.value)} placeholder={destResp?.local?.effective || "/var/lib/vz/dump"} className="font-mono" />
)} {backend === "borg" && ( <>
{destResp?.borg?.length ? (
) : (
No Borg repository configured yet. You can add one without leaving this dialog (SSH or local/USB path supported).
)}
{(() => { const dest = destResp?.borg?.find((r) => r.repository === borgRepoSelected) if (!dest) return null const mode = dest.encrypt_mode || "repokey" return (

Encryption + passphrase are taken from the destination ({mode === "none" ? "no encryption" : mode}). Edit the destination if you need to change them.

) })()} )} {/* Summary — mirrors the styling of the JobDetailModal. */}
Summary
Backend: {backend} manual / one-shot
profile: {profileMode} ({profileMode === "default" ? defaultPaths.length : customPaths.size} paths)
{error && (
{error}
)}
)}
{step > 1 && ( )} {step < 2 ? ( ) : ( )}
{/* AddDestinationDialog spawned from inside the manual-backup dialog. Same auto-select-on-save behavior as the wizard. */} setAddingDestType(null)} onSaved={(repo) => { if (repo && (addingDestType === "pbs" || addingDestType === "borg")) { setPendingAutoSelectDest({ kind: addingDestType, repository: repo }) } setAddingDestType(null) mutateDest() }} />
) } // ────────────────────────────────────────────────────────────── // Backup destinations CRUD (Sprint D). // // Three persistence files live in $HB_STATE_DIR (= /usr/local/share/proxmenux/): // - pbs-manual-configs.txt (manual PBS configs the shell also reads) // - borg-targets.txt (Borg repos saved by the shell) // - local-target.conf (single local target — default or override) // // Auto-discovered PBS storages from /etc/pve/storage.cfg show with a // "proxmox" badge and are NOT deletable here (PVE owns those). // ────────────────────────────────────────────────────────────── // One entry in the unified destinations list. PBS / Borg / Local all // flatten into this shape so the row component doesn't need to know // which backend a given destination targets — only the color, the // label and the action set. type UnifiedDest = | { id: string; kind: "local"; path: string; source: "default" | "custom"; removable: boolean; jobsUsing: string[] } | { id: string; kind: "pbs"; name: string; repository: string; source: "proxmox" | "manual"; fingerprint?: string; removable: boolean jobsUsing: string[] } | { id: string; kind: "borg"; name: string; repository: string; isSsh: boolean; ssh?: { user: string; host: string; remotePath: string }; sshKeyPath?: string; // The destination's encryption mode + whether a passphrase is // saved server-side; used to render a Lock / Unlock badge so the // operator sees at a glance which Borg repos are encrypted. encryptMode?: "none" | "repokey" | "keyfile" | "authenticated" hasPassphrase?: boolean removable: boolean jobsUsing: string[] } interface CapacityInfo { id: string total?: number available?: number used?: number is_usb?: boolean remote?: boolean error?: string } // Parse a Borg repository string into {user, host, remotePath} when // it's an SSH URL — used for the capacity probe over ssh. function parseBorgSsh(repo: string): { user: string; host: string; remotePath: string } | null { const m = repo.match(/^ssh:\/\/([^@]+)@([^/]+)\/(.+)$/) if (!m) return null return { user: m[1], host: m[2], remotePath: `/${m[3]}` } } function DestinationsSection({ destinations, onChanged, }: { destinations?: DestinationsResp onChanged: () => void }) { const [configuring, setConfiguring] = useState(false) const [editingDest, setEditingDest] = useState(null) const [confirmingDest, setConfirmingDest] = useState(null) const [busyKey, setBusyKey] = useState(null) const [error, setError] = useState(null) // Used by the Delete-destination confirm to tell the operator how // many backups already live on this target (they're kept after the // destination + jobs cascade, so the warning is informational). const { data: localArchivesResp } = useSWR<{ archives: BackupArchive[] }>( "/api/host-backups/archives", fetcher, { refreshInterval: 60_000, revalidateOnFocus: false }, ) const { data: remoteArchivesResp } = useSWR( "/api/host-backups/remote-archives", fetcher, { refreshInterval: 60_000, revalidateOnFocus: false }, ) // Recovery state — only needed when at least one PBS destination // exists. Drives the "Recover keyfile" banner and the "PBS encryption // key" management block. const hasPbs = (destinations?.pbs?.length ?? 0) > 0 const { data: pbsRecoveryStatus, mutate: mutatePbsRecovery } = useSWR<{ has_keyfile: boolean; has_recovery: boolean; has_keyfile_passphrase?: boolean; escrow_mode?: "none" | "local" | "full"; keyfile_fingerprint?: string }>(hasPbs ? "/api/host-backups/pbs-recovery/status" : null, fetcher) const { data: pbsRecoveryAvailable } = useSWR<{ snapshots: Array<{ repo_name: string; repo_repository: string; backup_id: string; source_host: string; backup_time: number; snapshot: string }> errors: Array<{ repo_name: string; error: string }> }>( hasPbs && pbsRecoveryStatus && !pbsRecoveryStatus.has_keyfile ? "/api/host-backups/pbs-recovery/available" : null, fetcher, ) const [recoveringKeyfile, setRecoveringKeyfile] = useState(false) const backupsCountFor = (it: UnifiedDest): number => { if (it.kind === "local") { const root = it.path.replace(/\/+$/, "") + "/" return (localArchivesResp?.archives || []).filter( (a) => (a.path || "").startsWith(root), ).length } const name = it.kind === "pbs" ? it.name : it.kind === "borg" ? it.name : "" return (remoteArchivesResp?.snapshots || []).filter( (s) => s.backend === it.kind && s.repo_name === name, ).length } // Flatten PBS + Borg + Local into one list. Order: locals first // (default always on top), then PBS, then Borg — matches the // mental model of "what's already on this machine" before any // remote target. const items: UnifiedDest[] = (() => { const out: UnifiedDest[] = [] for (const e of destinations?.local?.entries || []) { out.push({ id: `local:${e.path}`, kind: "local", path: e.path, source: e.source as "default" | "custom", removable: e.removable, jobsUsing: e.jobs_using || [], }) } for (const r of destinations?.pbs || []) { out.push({ id: `pbs:${r.name}:${r.repository}`, kind: "pbs", name: r.name, repository: r.repository, source: r.source as "proxmox" | "manual", fingerprint: r.fingerprint || undefined, removable: r.source === "manual", jobsUsing: r.jobs_using || [], }) } for (const r of destinations?.borg || []) { const ssh = parseBorgSsh(r.repository) out.push({ id: `borg:${r.name}`, kind: "borg", name: r.name, repository: r.repository, isSsh: !!ssh, ssh: ssh ?? undefined, sshKeyPath: (r as { ssh_key_path?: string }).ssh_key_path, encryptMode: r.encrypt_mode, hasPassphrase: r.has_passphrase, removable: true, jobsUsing: r.jobs_using || [], }) } return out })() // Build the capacity-probe payload + key for SWR. The key includes // every destination's id so SWR re-fetches when the list changes // (add / remove). 30 s refresh keeps the bars roughly live without // hammering ssh / pbs every render. const capacityTargets = items.map((it) => { if (it.kind === "local") return { id: it.id, kind: "local", path: it.path } if (it.kind === "pbs") return { id: it.id, kind: "pbs", name: it.name, repository: it.repository } if (it.isSsh && it.ssh) { return { id: it.id, kind: "borg-ssh", host: it.ssh.host, user: it.ssh.user, remote_path: it.ssh.remotePath, key_path: it.sshKeyPath || "", } } return { id: it.id, kind: "borg-local", path: it.repository } }) const capacityKey = items.length ? `/api/host-backups/destinations/capacity?keys=${items.map((i) => i.id).join(",")}` : null const { data: capacityResp } = useSWR<{ results: CapacityInfo[] }>( capacityKey, () => fetchApi("/api/host-backups/destinations/capacity", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ targets: capacityTargets }), }), { refreshInterval: 30_000, revalidateOnFocus: false }, ) const capByEdid = new Map( (capacityResp?.results || []).map((r) => [r.id, r] as const), ) async function removePbs(name: string, force = false) { setBusyKey(`pbs:${name}`) setError(null) try { await fetchApi(`/api/host-backups/destinations/pbs/${encodeURIComponent(name)}${force ? "?force=true" : ""}`, { method: "DELETE" }) onChanged() } catch (e) { setError(e instanceof Error ? e.message : String(e)) } finally { setBusyKey(null) } } async function removeBorg(name: string, force = false) { setBusyKey(`borg:${name}`) setError(null) try { await fetchApi(`/api/host-backups/destinations/borg/${encodeURIComponent(name)}${force ? "?force=true" : ""}`, { method: "DELETE" }) onChanged() } catch (e) { setError(e instanceof Error ? e.message : String(e)) } finally { setBusyKey(null) } } async function removeLocal(path: string, force = false) { setBusyKey(`local:${path}`) setError(null) try { const params = new URLSearchParams({ path }) if (force) params.set("force", "true") await fetchApi( `/api/host-backups/destinations/local?${params.toString()}`, { method: "DELETE" }, ) onChanged() } catch (e) { setError(e instanceof Error ? e.message : String(e)) } finally { setBusyKey(null) } } async function confirmDeleteDest() { const it = confirmingDest if (!it) return const force = it.jobsUsing.length > 0 setConfirmingDest(null) if (it.kind === "pbs") return removePbs(it.name, force) if (it.kind === "borg") return removeBorg(it.name, force) if (it.kind === "local") return removeLocal(it.path, force) } // Detach a USB drive — only the filesystem is unmounted; the path // stays in local-target.conf so re-plugging the same disk picks it // up again without re-adding the destination. async function unmountUsb(mountpoint: string) { setBusyKey(`umount:${mountpoint}`) setError(null) try { await fetchApi("/api/host-backups/usb-drives/unmount", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ mountpoint }), }) onChanged() } catch (e) { setError(e instanceof Error ? e.message : String(e)) } finally { setBusyKey(null) } } return (
{error && (
{error}
)} {/* Recovery banner: only when there's no local keyfile AND at least one escrow blob was found on a configured PBS. Click opens a passphrase dialog and the keyfile is rebuilt from the blob. Equivalent to hb_pbs_try_keyfile_recovery in the shell — what runs after a fresh PVE install when the operator points the new host at the same PBS. */} {hasPbs && pbsRecoveryStatus && !pbsRecoveryStatus.has_keyfile && (pbsRecoveryAvailable?.snapshots?.length ?? 0) > 0 && ( )}
{items.map((it) => { const cap = capByEdid.get(it.id) const busy = busyKey === it.id || (it.kind === "pbs" && busyKey === `pbs:${it.name}`) || (it.kind === "borg" && busyKey === `borg:${it.name}`) || (it.kind === "local" && busyKey === `local:${it.path}`) // Borg local-mode repos sit on top of a path — the same // /mnt/proxmenux-backup-disk-* USB mountpoint a Local target // would use. Expose Unmount for them too so the operator // doesn't have to remember which destination type is on the // disk they want to detach. const borgLocalUsbPath = it.kind === "borg" && !it.isSsh && cap?.is_usb ? it.repository : null const umountTarget = it.kind === "local" ? it.path : borgLocalUsbPath const unmountBusy = !!umountTarget && busyKey === `umount:${umountTarget}` return ( setConfirmingDest(it)} onUnmount={ umountTarget && cap?.is_usb ? () => unmountUsb(umountTarget) : undefined } onEdit={ // Only PBS-manual and Borg are editable from the UI. // PBS-proxmox is managed by Datacenter → Storage and // local destinations have no fields beyond the path. it.kind === "pbs" && it.source === "manual" ? () => { const [user, rest] = it.repository.includes("@") ? [it.repository.split("@").slice(0, -1).join("@"), it.repository.split("@").pop() || ""] : ["root@pam", it.repository] const [server, datastore] = rest.includes(":") ? [rest.split(":").slice(0, -1).join(":"), rest.split(":").pop() || ""] : [rest, ""] setEditingDest({ kind: "pbs", name: it.name, username: user, server, datastore, fingerprint: it.fingerprint, has_password: true, }) } : it.kind === "borg" ? () => setEditingDest({ kind: "borg", name: it.name, repository: it.repository, ssh_key_path: it.sshKeyPath, encrypt_mode: (destinations?.borg?.find((b) => b.name === it.name)?.encrypt_mode) || "repokey", has_passphrase: !!destinations?.borg?.find((b) => b.name === it.name)?.has_passphrase, }) : undefined } /> ) })}
setConfiguring(false)} onSaved={() => { setConfiguring(false) onChanged() }} /> {/* Edit-an-existing-destination dialog. Uses the same form as Add, just hydrated with the saved values and with `name` locked. The POST endpoint is upsert-by-name on the backend so no separate route is needed. */} setEditingDest(null)} onSaved={() => { setEditingDest(null) onChanged() }} /> setRecoveringKeyfile(false)} onRecovered={() => { setRecoveringKeyfile(false) mutatePbsRecovery() }} /> {/* Delete-destination confirm. Always opens (uniform UX) but the body adapts: a plain "Remove?" when no jobs/backups depend on the destination, or a warning summarizing the cascade (jobs go, backups stay) when there's something at stake. The destination + its sidecars are removed; backups on disk / on the remote stay untouched so the operator can decide what to do with them later. */} {confirmingDest && (() => { const it = confirmingDest const jobs = it.jobsUsing const backups = backupsCountFor(it) const headline = it.kind === "local" ? it.path : it.kind === "pbs" ? `${it.name} — ${it.repository}` : `${it.name} — ${it.repository}` return ( { if (!v) setConfirmingDest(null) }}> 0 ? "text-amber-500" : "text-red-500"}`} /> Remove destination {jobs.length === 0 && backups === 0 ? "Removes the destination from the list. Nothing else depends on it." : "The destination + its saved credentials will be removed. Backups already stored stay where they are — you decide what to do with them later."}
{headline}
{(jobs.length > 0 || backups > 0) && (
{jobs.length > 0 && (
{jobs.length} job{jobs.length === 1 ? "" : "s"} will be deleted (cascade):
{jobs.map((j) => ( {j} ))}
)} {backups > 0 && (
{backups} backup{backups === 1 ? "" : "s"} already at this destination — they are kept.
)}
)}
) })()}
) } // Single row in the unified destinations list. Layout mirrors the // disk-card chrome from storage-overview.tsx: icon + headline + all // badges + actions on ONE top row, capacity bar in storage-page blue, // stats grid below. No hover effect — these rows are inert (no modal // behind them), so the contrast change would only confuse the eye. function DestinationRow({ item, capacity, busy, unmountBusy, onDelete, onUnmount, onEdit, }: { item: UnifiedDest capacity?: CapacityInfo busy: boolean unmountBusy: boolean onDelete: () => void onUnmount?: () => void onEdit?: () => void }) { const accent = item.kind === "pbs" ? "bg-purple-500/10 text-purple-400 border-purple-500/20" : item.kind === "borg" ? "bg-fuchsia-500/10 text-fuchsia-400 border-fuchsia-500/20" : "bg-blue-500/10 text-blue-400 border-blue-500/20" const iconColor = item.kind === "pbs" ? "text-purple-400" : item.kind === "borg" ? "text-fuchsia-400" : "text-blue-400" const Icon = item.kind === "pbs" ? Server : item.kind === "borg" ? Archive : HardDrive const headline = item.kind === "pbs" ? item.name : item.kind === "borg" ? item.name : item.path const subline = item.kind === "pbs" ? item.repository : item.kind === "borg" ? item.repository : null const isUsb = !!capacity?.is_usb || (item.kind === "borg" && !item.isSsh && /\/(?:mnt|media)\//.test(item.repository)) const sourceLabel = item.kind === "local" ? (item.source === "default" ? "built-in default" : "manually added") : item.kind === "pbs" ? (item.source === "proxmox" ? "Datacenter → Storage" : "manually added") // Borg targets are always operator-configured (no Datacenter // auto-discovery path), so they all share the "manually added" // wording for visual consistency with the Local custom row. : "manually added" const pct = capacity?.total && capacity.available !== undefined ? Math.min(100, Math.round(((capacity.total - capacity.available) / capacity.total) * 100)) : null return (
{/* Top row split in two: a wrappable left side (icon + headline + badges) and a fixed right side (Unmount + Remove). The right side never falls to a new line even if `headline` is long, because the outer container is a non-wrapping flex. */}

{headline}

{item.kind} {item.kind === "local" && item.source === "default" && ( default )} {item.kind === "borg" ? ( // For Borg the sub-type is one of three (mutually exclusive): // ssh → remote repo // usb → local repo whose path lives on a USB mount // local → local repo on an internal disk / regular dir // Previously we showed BORG + LOCAL + USB which read like // three orthogonal facets and confused the badge with the // Local destination kind. Collapsing to a single sub-type // badge avoids the overlap. <> {item.isSsh ? ( ssh ) : isUsb ? ( USB ) : ( local )} {/* Encryption indicator — icon-only chip for encrypted repos. Plaintext repos get no chip (visually quiet for the common case). The tooltip carries the mode + saved-passphrase state for hover detail. */} {item.encryptMode && item.encryptMode !== "none" && ( {/* Icon sized to roughly the text-[10px] line-box of the neighbouring text badges (BORG, USB, …) so the pill itself ends up the same height — matching visual weight without adding a label. */} )} ) : isUsb && ( // PBS / Local destinations: USB badge is additive (the kind // badge alone doesn't say where the path lives). USB )}
{/* Action chips — always pinned to the right, never wrap. The outer flex container has no flex-wrap so even a very long headline keeps these two buttons in place. Chip style (square border + tinted bg) makes them read as actionable rather than decorative icons. */}
{onEdit && ( )} {onUnmount && ( )} {item.removable ? ( ) : ( {item.kind === "local" ? "built-in" : "managed by PVE"} )}
{subline && (

{subline}

)}
{/* Capacity bar — matches the height of the component used on the Storage page (h-2). Bar colour comes from the shared storage palette so a 100% PBS datastore flags red here too, not just on the Storage page. */} {capacity?.total && capacity.available !== undefined && (
)} {/* Stats grid */}
{capacity?.total !== undefined && (

Size

{formatBytes(capacity.total)}

)} {capacity?.used !== undefined && capacity.total !== undefined && (

Used

{formatBytes(capacity.used)} {pct !== null && ({pct}%)}

)} {capacity?.available !== undefined && (

Free

{formatBytes(capacity.available)}

)}

Source

{sourceLabel}

{capacity?.error && (

Capacity unavailable: {capacity.error}

)} {!capacity && (

Probing capacity…

)} {/* Keyfile actions live inside each PBS row — the encryption key is host-wide but conceptually belongs to PBS usage, so the Download / Upload / Delete controls sit next to the destination(s) they support. */} {item.kind === "pbs" && }
) } // Single-button wizard that replaces the old per-backend Add buttons. // Step 1 lets the operator pick the backend; Step 2 hands off to the // existing AddDestinationDialog with that backend pre-selected, so we // don't duplicate the per-backend forms. function ConfigureDestinationWizard({ open, onClose, onSaved, }: { open: boolean onClose: () => void onSaved: () => void }) { const [picked, setPicked] = useState<"pbs" | "local" | "borg" | null>(null) useEffect(() => { if (!open) setPicked(null) }, [open]) if (picked) { // Hand off to the existing form. Closing or saving collapses the // whole wizard so the operator lands back in the destinations list. return ( { setPicked(null); onClose() }} onSaved={() => { setPicked(null); onSaved() }} /> ) } return ( { if (!v) onClose() }}> Configure destination Pick the backend you want to add. The next step has the credentials and path fields for that backend only.
{([ { type: "pbs", label: "PBS", accent: "border-purple-500/40 hover:border-purple-400 text-purple-400", blurb: "Proxmox Backup Server. Incremental, encrypted, dedup." }, { type: "local", label: "Local", accent: "border-blue-500/40 hover:border-blue-400 text-blue-400", blurb: "tar.zst archive into a local directory or mounted disk." }, { type: "borg", label: "Borg", accent: "border-fuchsia-500/40 hover:border-fuchsia-400 text-fuchsia-400", blurb: "Borg repo over SSH or on a local / USB disk." }, ] as const).map((opt) => ( ))}
) } // One dialog handles all three Add flows — the form is small enough // that branching by type keeps it cleaner than three separate components. // Pre-fill payload for editing an existing destination. `name` stays // fixed (it's the key the sidecar / state files are scoped to); // everything else can be re-typed. PBS password and Borg passphrase // can be left blank to keep the saved one. interface EditingDest { kind: "pbs" | "borg" name: string // PBS fields server?: string datastore?: string username?: string fingerprint?: string has_password?: boolean // Borg fields repository?: string ssh_key_path?: string encrypt_mode?: "none" | "repokey" | "keyfile" | "authenticated" has_passphrase?: boolean } function AddDestinationDialog({ type, editing, onClose, onSaved, }: { type: "pbs" | "borg" | "local" | null editing?: EditingDest | null onClose: () => void // The repository string of the just-saved destination, so callers // can auto-select it in a dropdown (the wizard does this when an // operator adds a PBS / Borg repo inline). Undefined for `local` // and for the legacy callers that don't care. onSaved: (repository?: string) => void }) { const [name, setName] = useState("") // PBS fields const [server, setServer] = useState("") const [datastore, setDatastore] = useState("") const [username, setUsername] = useState("root@pam") const [password, setPassword] = useState("") const [fingerprint, setFingerprint] = useState("") // Borg fields (local mode) const [borgRepo, setBorgRepo] = useState("") // Borg SSH mode const [borgMode, setBorgMode] = useState<"local" | "ssh">("local") const [borgSshUser, setBorgSshUser] = useState("borg") const [borgSshHost, setBorgSshHost] = useState("") const [borgSshRemotePath, setBorgSshRemotePath] = useState("") const [borgSshKeyPath, setBorgSshKeyPath] = useState("/root/.ssh/proxmenux_borg") const [generatedKey, setGeneratedKey] = useState<{ public_key: string; authorized_keys_line: string } | null>(null) const [generatingKey, setGeneratingKey] = useState(false) // Borg encryption + passphrase live on the destination, not on each // job. Two-mode UI: encrypted (repokey, the default) or none. Other // borg modes (keyfile / authenticated) stay supported by the backend // for legacy shell-created configs. const [borgEncryptionEnabled, setBorgEncryptionEnabled] = useState(true) const [borgPassphrase, setBorgPassphraseLocal] = useState("") const [borgPassphrase2, setBorgPassphrase2] = useState("") // Local field const [localPath, setLocalPath] = useState("") const [submitting, setSubmitting] = useState(false) const [error, setError] = useState(null) // Read the current destinations so the UsbPicker can hide any USB // mountpoint already in use as a Local or Borg-local destination — // no point offering the same disk twice. const { data: existingDest } = useSWR( type !== null ? "/api/host-backups/destinations" : null, fetcher, ) const usedPaths: string[] = (() => { const out: string[] = [] for (const e of existingDest?.local?.entries || []) { if (e.source === "custom") out.push(e.path) } for (const r of existingDest?.borg || []) { // Borg local-mode: the `repository` IS the path. SSH repos // start with `ssh://` and are excluded automatically. if (!r.repository.startsWith("ssh://")) out.push(r.repository) } return out })() // Reset every time the dialog OPENS — not just on close. If we're // opening in edit mode, hydrate the form from the saved destination // so the operator only has to change what they want. Name stays // read-only in edit mode (it's the key for sidecars / state files). useEffect(() => { setError(null) setSubmitting(false) setGeneratedKey(null) setGeneratingKey(false) setPassword("") setBorgPassphraseLocal("") setBorgPassphrase2("") if (editing && editing.kind === "pbs") { setName(editing.name) setServer(editing.server || "") setDatastore(editing.datastore || "") setUsername(editing.username || "root@pam") setFingerprint(editing.fingerprint || "") setBorgRepo(""); setBorgMode("local"); setBorgSshUser("borg") setBorgSshHost(""); setBorgSshRemotePath("") setBorgSshKeyPath("/root/.ssh/proxmenux_borg") setBorgEncryptionEnabled(true); setLocalPath("") return } if (editing && editing.kind === "borg") { const repo = editing.repository || "" const ssh = repo.match(/^ssh:\/\/([^@]+)@([^/]+)\/(.+)$/) setName(editing.name) if (ssh) { setBorgMode("ssh") setBorgSshUser(ssh[1]) setBorgSshHost(ssh[2]) setBorgSshRemotePath(`/${ssh[3]}`) setBorgSshKeyPath(editing.ssh_key_path || "/root/.ssh/proxmenux_borg") setBorgRepo("") } else { setBorgMode("local") setBorgRepo(repo) setBorgSshUser("borg"); setBorgSshHost(""); setBorgSshRemotePath("") setBorgSshKeyPath("/root/.ssh/proxmenux_borg") } const mode = editing.encrypt_mode || "repokey" setBorgEncryptionEnabled(mode !== "none") setServer(""); setDatastore(""); setUsername("root@pam"); setFingerprint("") setLocalPath("") return } // Add-new path: full reset. setName("") setServer("") setDatastore("") setUsername("root@pam") setFingerprint("") setBorgRepo("") setBorgMode("local") setBorgSshUser("borg") setBorgSshHost("") setBorgSshRemotePath("") setBorgSshKeyPath("/root/.ssh/proxmenux_borg") setBorgEncryptionEnabled(true) setLocalPath("") }, [type, editing]) const isEditing = !!editing const nameValid = /^[a-zA-Z0-9_-]+$/.test(name) const borgPathValid = borgMode === "local" ? !!borgRepo.trim() : !!(borgSshUser.trim() && borgSshHost.trim() && borgSshRemotePath.trim()) // In edit mode the saved passphrase / password is reused when the // operator leaves the inputs blank, so validation relaxes. const borgPassphraseValid = !borgEncryptionEnabled || (isEditing && !borgPassphrase && editing?.has_passphrase) || (borgPassphrase.length > 0 && borgPassphrase === borgPassphrase2) const borgValid = borgPathValid && borgPassphraseValid const canSubmit = type === "pbs" ? nameValid && server.trim() && datastore.trim() && (!!password || (isEditing && !!editing?.has_password)) : type === "borg" ? nameValid && borgValid : type === "local" ? localPath.trim().startsWith("/") : false async function generateBorgKey() { setGeneratingKey(true) setError(null) try { const resp = await fetchApi<{ public_key: string; authorized_keys_line: string }>( "/api/host-backups/ssh-keys/generate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ key_path: borgSshKeyPath.trim() || "/root/.ssh/proxmenux_borg", remote_path: borgSshRemotePath.trim(), }), } ) setGeneratedKey(resp) } catch (e) { setError(e instanceof Error ? e.message : String(e)) } finally { setGeneratingKey(false) } } async function handleSave() { if (!canSubmit) return setSubmitting(true) setError(null) try { let savedRepo: string | undefined if (type === "pbs") { const resp = await fetchApi<{ repository?: string }>("/api/host-backups/destinations/pbs", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name, server: server.trim(), datastore: datastore.trim(), username: username.trim() || "root@pam", password, fingerprint: fingerprint.trim() || undefined, }), }) savedRepo = resp?.repository } else if (type === "borg") { const body: Record = { name, mode: borgMode, encrypt_mode: borgEncryptionEnabled ? "repokey" : "none", } if (borgEncryptionEnabled) { body.passphrase = borgPassphrase } if (borgMode === "local") { body.repo = borgRepo.trim() } else { body.ssh_user = borgSshUser.trim() body.ssh_host = borgSshHost.trim() body.ssh_remote_path = borgSshRemotePath.trim() if (borgSshKeyPath.trim()) body.ssh_key_path = borgSshKeyPath.trim() } const resp = await fetchApi<{ repo?: string }>("/api/host-backups/destinations/borg", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }) savedRepo = resp?.repo } else if (type === "local") { await fetchApi("/api/host-backups/destinations/local", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path: localPath.trim() }), }) } onSaved(savedRepo) } catch (e) { setError(e instanceof Error ? e.message : String(e)) } finally { setSubmitting(false) } } return ( { if (!v) onClose() }}> {isEditing ? ( ) : ( )} {isEditing ? "Edit" : "Add"} {type === "pbs" ? "PBS" : type === "borg" ? "Borg" : "local"} destination
{type === "pbs" && ( <>
setName(e.target.value)} className="font-mono mt-1" placeholder="my-pbs" disabled={isEditing} />

{isEditing ? "Name is the key for saved credentials and can't be changed." : "A short identifier. Letters, digits, _ or -."}

setServer(e.target.value)} className="font-mono mt-1" placeholder="192.168.1.10" />
setDatastore(e.target.value)} className="font-mono mt-1" placeholder="pbs" />
setUsername(e.target.value)} className="font-mono mt-1" />

User that runs proxmox-backup-client. Default root@pam.

setPassword(e.target.value)} className="font-mono mt-1" placeholder={isEditing && editing?.has_password ? "(unchanged — type to replace)" : ""} /> {isEditing && editing?.has_password && (

Leave blank to keep the saved password.

)}
setFingerprint(e.target.value)} className="font-mono mt-1" placeholder="aa:bb:cc:…" />

Required only if the PBS uses a self-signed certificate.

)} {type === "borg" && ( <>
setName(e.target.value)} className="font-mono mt-1" placeholder="usb-borg or remote-borg" disabled={isEditing} /> {isEditing && (

Name is the key for the saved passphrase and can't be changed.

)}
{borgMode === "local" ? (
setBorgRepo(e.target.value)} className="font-mono mt-1" placeholder="/mnt/usb-disk/borgbackup" />

Absolute path on this host. Pick a USB drive below to auto-fill, or type the path manually.

setBorgRepo(p)} excludePaths={usedPaths} />
) : ( <>
setBorgSshUser(e.target.value)} className="font-mono mt-1" placeholder="borg" />

User on the remote host that runs borg serve. Conventionally borg, not root.

setBorgSshHost(e.target.value)} className="font-mono mt-1" placeholder="backup.example.com" />
setBorgSshRemotePath(e.target.value)} className="font-mono mt-1" placeholder="/backup/borgbackup" />
setBorgSshKeyPath(e.target.value)} className="font-mono mt-1" />

If you already have an SSH key registered with the server, point to it here. Otherwise click Generate key below and paste the line shown into the server's authorized_keys.

Generate a new SSH key
{generatedKey ? ( <>

On the Borg server, append this single line to ~{borgSshUser}/.ssh/authorized_keys: