"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 } 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, } from "lucide-react" import { ScriptTerminalModal } from "./script-terminal-modal" import { fetchApi, getApiUrl } from "../lib/api-config" import { fetchTerminalTicket } from "../lib/terminal-ws" import { formatStorage } from "../lib/utils" // ── 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) 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" } } // formatStorage() in lib/utils.ts expects GIGABYTES as input — it's // the storage layer's native unit. For raw log file sizes coming from // os.path.getsize() we need a byte-aware formatter; passing N bytes to // formatStorage() rendered "442 GB" for a 442-byte log. Tiny inline. const formatBytes = (n: number): string => { if (!Number.isFinite(n) || n < 0) return "—" if (n < 1024) return `${n} B` if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB` if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB` return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB` } 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 } } 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) 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 }, ) 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 (
{/* ── 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 snapshots from every configured datastore. Click an entry to inspect, restore or download it — downloads of PBS snapshots 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 snapshots.
) : (
{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) mutateArchives() }} /> {/* ── 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) // Refresh BOTH archive lists a few seconds after launch so // the new backup shows up regardless of backend: local // archives are on disk locally, PBS / Borg snapshots are // pulled from `remote-archives`. Without the second mutate // a Borg manual stayed invisible until the next 60s tick. setTimeout(() => { mutateArchives() mutateRemoteArchives() }, 5000) }} /> {/* ── 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 } | null>(null) const [restoreTerminal, setRestoreTerminal] = useState<{ stagingPath: string mode: "full" | "custom" paths: string[] } | null>(null) 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") setRestoreOptions({ stagingPath: r.staging_path, pathsAvailable: Array.isArray(r.paths_available) ? r.paths_available : [], rollbackPlan: r.rollback_plan, }) } 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() } // Local archive deletion. The backend purges the .tar.zst, the // .proxmenux.json sidecar and the matching .log so the // /var/log/proxmenux directory stays in sync with the archive list. // Remote archives (PBS / Borg) are not deletable from here — those // belong to their own datastore prune policy. const deleteLocalArchive = async () => { if (!localArc) return setDeletingArchive(true) setError(null) try { const resp = await fetchApi<{ status: string; removed: string[] }>( `/api/host-backups/archives/${encodeURIComponent(localArc.id)}`, { method: "DELETE" }, ) setArchiveDeleteResult(resp.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} {archive && ( {archive.source} )} {remoteArc?.encrypted && ( )} {/* ── Body: same shape for the 3 backends ─────────────── */}
{/* Snapshot info — uniform grid, with backend-specific rows mixed in only when they carry data. */}
Snapshot
{/* Time + size — present for every backend. */} {archive && (archive.source === "pbs" || archive.source === "borg") && remoteArc ? ( <>
Snapshot 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 snapshot
    {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 && (
{error}
)}
{/* ── 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. */}
{archive?.source === "local" && ( )}
{/* ── 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 && ( setRestoreError(null)}> Restore preparation failed {restoreError}
)} {/* ── 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) => { if (!restoreOptions) return const sp = restoreOptions.stagingPath setRestoreOptions(null) setRestoreTerminal({ stagingPath: sp, mode, paths }) }} /> {/* ── 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(",") } : {}), }} /> )} {/* ── Confirm archive deletion ───────────────────────────── */} Delete archive Removes the archive, its sidecar JSON and the matching run log. The action is permanent — restore needs an off-host copy.
{localArc?.id}
{/* ── 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 pbs_encrypt: boolean 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 — sent as `pbs_encrypt` in the payload. // Backend resolves the shared keyfile + injects PBS_KEYFILE into // the job .env so the runner adds `--keyfile` to the backup call. const [pbsEncrypt, setPbsEncrypt] = useState(false) // 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) // 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 }>( open && backend === "pbs" ? "/api/host-backups/pbs-recovery/status" : null, fetcher, ) 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) setPbsEncrypt(!!jobDetail.pbs_encrypt) 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("") setPbsEncrypt(false) setPbsRecoveryPass("") setPbsRecoveryPass2("") setPbsRecoveryChange(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 const canSubmit = canAdvanceFrom1 && canAdvanceFrom2 && canAdvanceFrom3 && canAdvanceFrom4 && backendValid async function handleCreate() { if (!canSubmit) return if (mode === "attach" && !selectedPveJob) return setSubmitting(true) setError(null) // Configure the PBS recovery escrow BEFORE creating the job so the // very first backup the runner triggers can already upload the // blob. Only fires when the operator typed a passphrase pair. if (backend === "pbs" && pbsEncrypt && pbsRecoveryPass) { if (pbsRecoveryPass !== pbsRecoveryPass2) { setError("Recovery passphrases don't match.") setSubmitting(false) return } try { await fetchApi("/api/host-backups/pbs-recovery/setup", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ passphrase: pbsRecoveryPass }), }) mutatePbsRecovery() } catch (e) { setError(`Recovery setup failed: ${e instanceof Error ? e.message : String(e)}`) setSubmitting(false) return } } 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 = pbsEncrypt } 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 — same flow as the shell wizard. A shared keyfile under /usr/local/ share/proxmenux/pbs-key.conf is generated on first use and reused by every encrypted job. */}

Adds --keyfile to proxmox-backup-client backup. Encryption happens before upload so chunks land on the PBS server already ciphered. The keyfile lives at /usr/local/share/proxmenux/pbs-key.conf (chmod 0600) and is shared across every encrypted PBS job on this host.

{pbsEncrypt && (
Recovery passphrase (strongly recommended)
{pbsRecoveryStatus?.has_recovery && !pbsRecoveryChange ? ( /* Escrow already saved — mirror the shell: skip the prompt unless the operator opts to change. */
Recovery escrow already configured for this host.
Backups created by this job will reuse the existing passphrase. No need to type it again.
) : ( <>

With a recovery passphrase, an encrypted copy of the keyfile is uploaded to PBS with every backup. If you lose this host, you can recover the keyfile on a fresh install with just the passphrase. Without it, losing the local keyfile means the encrypted backups become unrecoverable forever.

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.

)}
{pbsRecoveryStatus?.has_recovery && pbsRecoveryChange && ( )} )}
)}
)} {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("") const [pbsEncrypt, setPbsEncrypt] = useState(false) 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) // 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 }>( open && backend === "pbs" ? "/api/host-backups/pbs-recovery/status" : null, fetcher, ) 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("") setPbsEncrypt(false) setPbsRecoveryPass("") setPbsRecoveryPass2("") setPbsRecoveryChange(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 const canSubmit = canAdvanceFrom1 && backendValid async function handleRun() { if (!canSubmit) return setSubmitting(true) setError(null) // Same pre-step as CreateJob: configure the PBS recovery escrow // before the run so the runner's post-backup upload of the blob // has something to push. if (backend === "pbs" && pbsEncrypt && pbsRecoveryPass) { if (pbsRecoveryPass !== pbsRecoveryPass2) { setError("Recovery passphrases don't match.") setSubmitting(false) return } try { await fetchApi("/api/host-backups/pbs-recovery/setup", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ passphrase: pbsRecoveryPass }), }) mutatePbsRecovery() } catch (e) { setError(`Recovery setup failed: ${e instanceof Error ? e.message : String(e)}`) setSubmitting(false) return } } 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 = pbsEncrypt } 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" />

Uses the shared keyfile at /usr/local/share/proxmenux/pbs-key.conf (auto-generated on first use).

{pbsEncrypt && (
Recovery passphrase (strongly recommended)
{pbsRecoveryStatus?.has_recovery && !pbsRecoveryChange ? (
Recovery escrow already configured for this host.
This backup will reuse the existing passphrase. No need to type it again.
) : ( <>

With a recovery passphrase an encrypted copy of the keyfile is uploaded to PBS on every backup. Lets you recover it on a fresh host with just the passphrase.

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.

)}
{pbsRecoveryStatus?.has_recovery && pbsRecoveryChange && ( )} )}
)}
)} {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. const hasPbs = (destinations?.pbs?.length ?? 0) > 0 const { data: pbsRecoveryStatus, mutate: mutatePbsRecovery } = useSWR<{ has_keyfile: boolean; has_recovery: boolean }>(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). bg-muted track + solid blue fill, no border. */} {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

90 ? "text-red-400" : pct !== null && pct > 75 ? "text-amber-400" : ""}`}> {formatBytes(capacity.available)}

)}

Source

{sourceLabel}

{capacity?.error && (

Capacity unavailable: {capacity.error}

)} {!capacity && (

Probing capacity…

)}
) } // 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: