"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, } from "lucide-react" import { fetchApi, getApiUrl } from "../lib/api-config" import { formatStorage } from "../lib/utils" // ── Shape contracts with the backend (flask_server.py: api_host_backups_*) ── interface BackupJob { id: string destination: string method: string // "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 } // 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 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)` } // 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 [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 statusBadge = 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, no retention. Use this when you want a one-off copy without committing to a recurring job. The resulting archive shows up in Available Archives below (for local backends; PBS / Borg manual backups go to their respective storage).

{/* ── 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 const sourceBadgeCls = u.source === "pbs" ? "text-purple-300 border-purple-400/40 bg-purple-500/5" : u.source === "borg" ? "text-cyan-300 border-cyan-400/40 bg-cyan-500/5" : "text-emerald-300 border-emerald-400/40 bg-emerald-500/5" 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={() => { setRunningManual(false) mutateJobs() // Refresh archives a few seconds after launch so the new // tar.zst (if it's a local backend) appears in the list // without waiting for the next 30s SWR tick. setTimeout(() => mutateArchives(), 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()} /> {/* ── 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 [deletingArchive, setDeletingArchive] = useState(false) const [archiveDeleteResult, setArchiveDeleteResult] = useState(null) // Local download — direct file streamed straight from the server. const downloadLocalArchive = async () => { if (!localArc) return setDownloading(true) setError(null) try { const token = typeof window !== "undefined" ? localStorage.getItem("proxmenux-auth-token") || "" : "" const r = await fetch( getApiUrl(`/api/host-backups/archives/${encodeURIComponent(localArc.id)}/download`), { headers: token ? { Authorization: `Bearer ${token}` } : {}, }, ) if (!r.ok) throw new Error(`HTTP ${r.status} ${r.statusText}`) const blob = await r.blob() const url = URL.createObjectURL(blob) const a = document.createElement("a") a.href = url a.download = localArc.id document.body.appendChild(a) a.click() document.body.removeChild(a) setTimeout(() => URL.revokeObjectURL(url), 1000) } catch (e) { setError(`Download failed: ${e instanceof Error ? e.message : String(e)}`) } finally { 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. Server cleans the file up // automatically once the response finishes. const token = typeof window !== "undefined" ? localStorage.getItem("proxmenux-auth-token") || "" : "" const r = await fetch( getApiUrl(`/api/host-backups/remote-archives/export/${encodeURIComponent(started.task_id)}/download`), { headers: token ? { Authorization: `Bearer ${token}` } : {} }, ) if (!r.ok) throw new Error(`download HTTP ${r.status}`) const blob = await r.blob() const url = URL.createObjectURL(blob) const a = document.createElement("a") a.href = url const safeSnap = remoteArc.snapshot.replace(/\//g, "_") a.download = `${remoteArc.backend}-${remoteArc.repo_name}-${safeSnap}.tar.zst` document.body.appendChild(a) a.click() document.body.removeChild(a) setTimeout(() => URL.revokeObjectURL(url), 1000) 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} )} {isRemote ? `Inspect the ${backendLabel} snapshot metadata and download it as a .tar.zst — the server extracts it on demand only when you click Download.` : "Pick a restore mode, optionally run the preflight check, then get the exact shell command to apply the restore. Nothing on this host is changed from here — the apply step happens from a terminal."} {/* ── Remote snapshot view (PBS or Borg) — info card + on-demand export-then-download. */} {isRemote && remoteArc && (
{backendLabel} snapshot
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}
Snapshot time: {formatMtime(remoteArc.backup_time)}
{remoteArc.size_bytes > 0 &&
Size: {formatStorage(remoteArc.size_bytes)}
} {remoteArc.owner &&
Owner: {remoteArc.owner}
} {remoteArc.borg_id &&
Borg id: {remoteArc.borg_id}
}
{remoteArc.files.length > 0 && (
Files in this snapshot
    {remoteArc.files.map((f) => (
  • {f.filename} {formatStorage(f.size)}
  • ))}
)}

Download this snapshot

The server will {remoteArc.backend === "pbs" ? "restore the snapshot from PBS" : "borg-extract the archive"} and pack it as a .tar.zst, then stream it to your browser. The extraction only starts when you click Download.

{exportTask && (
{exportTask.state} — {exportTask.message}
{exportTask.state === "failed" && exportTask.error && (
{exportTask.error}
)} {exportTask.state === "completed" && exportTask.size_bytes > 0 && (
Packed size: {formatStorage(exportTask.size_bytes)}
)}
)} {error && (
{error}
)}

{remoteArc.backend === "pbs" ? "Restore-to-this-host for PBS snapshots is best done from the PBS side with proxmox-backup-client restore. Use Download to pull the snapshot to your computer for off-host inspection or cross-host transfer." : "Restore-to-this-host for Borg archives is best done with borg extract. Use Download to pull the archive to your computer for off-host inspection or cross-host transfer."}

)} {/* ── Local archive view — full restore wizard (manifest + preflight + apply instructions). */} {archive?.source === "local" && (<> {/* Manifest summary — optional. If the archive has no manifest (older backup format), we just skip it instead of blocking the operator from continuing with the restore. */} {manifestErr ? (
This archive doesn't carry a manifest snapshot — you can still pick a restore mode and get the instructions below.
) : !manifest ? (
Reading manifest...
) : ( )} {/* ── Run log — what the scheduler wrote for THIS archive ── */} {archiveLog && archiveLog.log_path && archiveLog.tail.length > 0 && (

Run log

{archiveLog.tail.join("\n")}
              
tail · {formatBytes(archiveLog.size)} {archiveLog.log_path}
)} {/* Mode selector + main "Restore" action */}

The archive downloads as a .tar.zst file. To extract: {" "}tar -I zstd -xf {localArc?.id || "<file>"} {" "}(Linux/macOS with zstd installed). On Windows, 7-Zip 21.0+ opens it natively. Double-click won't work — no OS opens .zst out of the box.

{error && (
{error}
)} {report && } {/* Restore instructions — appear whenever the operator clicks "Restore" (with or without a preflight beforehand). */} {instructions && (

Apply this restore

{instructions.archive_basename} {instructions.mode_label} {instructions.reboot_required && ( reboot required after )}
Run this from a terminal:
{ const sel = window.getSelection() if (sel) { sel.removeAllRanges() const range = document.createRange() range.selectNodeContents(e.currentTarget) sel.addRange(range) } }} > {instructions.shell_command}
{instructions.menu_path.length > 0 && (
Then:
    {instructions.menu_path.map((step, i) => (
  1. {step}
  2. ))}
)} {instructions.notes.length > 0 && (
{instructions.notes.map((note, i) => (

{note}

))}
)}
)}
)}
{/* ── 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 } interface PbsRepo { name: string repository: string fingerprint: string | null source: "proxmox" | "manual" } interface BorgRepo { name: string repository: string ssh_key_path?: string } interface LocalTargetEntry { path: string source: "default" | "custom" removable: boolean } 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 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("") 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) 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("") 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 && (borgEncryptMode === "none" || !!borgPassphrase) const canSubmit = canAdvanceFrom1 && canAdvanceFrom2 && canAdvanceFrom3 && canAdvanceFrom4 && backendValid async function handleCreate() { if (!canSubmit) return if (mode === "attach" && !selectedPveJob) return setSubmitting(true) setError(null) 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 } else if (backend === "local") { if (localDestDir.trim()) body.local_dest_dir = localDestDir.trim() } else if (backend === "borg") { body.borg_repo = borgRepoSelected body.borg_passphrase = borgPassphrase body.borg_encrypt_mode = borgEncryptMode } // 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).

)} {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.

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

Where the tar.zst archive is written. {" "} Leave blank to use the configured local target {" "} ({destResp?.local?.effective || "/var/lib/vz/dump"} {destResp?.local?.configured ? "" : " — default"}).

)}
)} {backend === "borg" && ( <>
{destResp?.borg?.length ? (
) : (
No Borg repository configured yet. You can add one without leaving this wizard (SSH or local/USB path supported).
)}
{borgEncryptMode !== "none" && (
setBorgPassphrase(e.target.value)} className="font-mono mt-1" placeholder={isEdit && jobDetail?.has_borg_passphrase ? "(unchanged — type to replace)" : "Passphrase used to decrypt repo at restore time"} />

{isEdit && jobDetail?.has_borg_passphrase ? "Leave blank to keep the current passphrase." : "Stored in the .env (mode 0600). If the repo is new it will be initialised with this passphrase."}

)} )} {/* 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 onLaunched: () => 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 [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("") 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 : !!borgRepoSelected && (borgEncryptMode === "none" || !!borgPassphrase) const canSubmit = canAdvanceFrom1 && backendValid async function handleRun() { if (!canSubmit) return setSubmitting(true) setError(null) 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 } else if (backend === "local") { if (localDestDir.trim()) body.local_dest_dir = localDestDir.trim() } else if (backend === "borg") { body.borg_repo = borgRepoSelected body.borg_passphrase = borgPassphrase body.borg_encrypt_mode = borgEncryptMode } await fetchApi("/api/host-backups/manual-run", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }) onLaunched() } 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" />
)} {backend === "local" && (
setLocalDestDir(e.target.value)} placeholder={destResp?.local?.effective || "/var/lib/vz/dump"} className="font-mono" />

Where the tar.zst archive is written. {" "}Leave blank to use the configured local target {" "}({destResp?.local?.effective || "/var/lib/vz/dump"}).

)} {backend === "borg" && ( <>
{destResp?.borg?.length ? (
) : (
No Borg repository configured yet. You can add one without leaving this dialog (SSH or local/USB path supported).
)}
{borgEncryptMode !== "none" && (
setBorgPassphrase(e.target.value)} className="font-mono mt-1" placeholder="Passphrase used to decrypt repo at restore time" />
)} )} {/* 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 } | { id: string; kind: "pbs"; name: string; repository: string; source: "proxmox" | "manual"; fingerprint?: string; removable: boolean } | { id: string; kind: "borg"; name: string; repository: string; isSsh: boolean; ssh?: { user: string; host: string; remotePath: string }; sshKeyPath?: string; removable: boolean } 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 [busyKey, setBusyKey] = useState(null) const [error, setError] = useState(null) // 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, }) } 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", }) } 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, removable: true, }) } 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) { setBusyKey(`pbs:${name}`) setError(null) try { await fetchApi(`/api/host-backups/destinations/pbs/${encodeURIComponent(name)}`, { method: "DELETE" }) onChanged() } catch (e) { setError(e instanceof Error ? e.message : String(e)) } finally { setBusyKey(null) } } async function removeBorg(name: string) { setBusyKey(`borg:${name}`) setError(null) try { await fetchApi(`/api/host-backups/destinations/borg/${encodeURIComponent(name)}`, { method: "DELETE" }) onChanged() } catch (e) { setError(e instanceof Error ? e.message : String(e)) } finally { setBusyKey(null) } } async function removeLocal(path: string) { setBusyKey(`local:${path}`) setError(null) try { await fetchApi( `/api/host-backups/destinations/local?path=${encodeURIComponent(path)}`, { method: "DELETE" }, ) onChanged() } catch (e) { setError(e instanceof Error ? e.message : String(e)) } finally { setBusyKey(null) } } // 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 (

Pre-configure backup destinations so wizards and manual backups can pick from them without re-typing credentials each time. Each entry is colored by backend: PBS purple, Local blue, Borg fuchsia.

{error && (
{error}
)}
{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}`) const unmountBusy = it.kind === "local" && busyKey === `umount:${it.path}` return ( { if (it.kind === "pbs") return removePbs(it.name) if (it.kind === "borg") return removeBorg(it.name) if (it.kind === "local") return removeLocal(it.path) }} onUnmount={ it.kind === "local" && cap?.is_usb ? () => unmountUsb(it.path) : undefined } /> ) })}
setConfiguring(false)} onSaved={() => { setConfiguring(false) onChanged() }} />
) } // 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, }: { item: UnifiedDest capacity?: CapacityInfo busy: boolean unmountBusy: boolean onDelete: () => void onUnmount?: () => 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") : item.isSsh ? "remote (SSH)" : "local path" const pct = capacity?.total && capacity.available !== undefined ? Math.min(100, Math.round(((capacity.total - capacity.available) / capacity.total) * 100)) : null return (
{/* Top row: icon + headline + all badges, then actions on the right */}

{headline}

{item.kind} {item.kind === "pbs" && item.source === "proxmox" && ( proxmox )} {item.kind === "local" && item.source === "default" && ( default )} {item.kind === "borg" && ( {item.isSsh ? "ssh" : "local"} )} {isUsb && ( USB )} {/* Spacer pushes actions to the right end of the row */}
{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. function AddDestinationDialog({ type, onClose, onSaved, }: { type: "pbs" | "borg" | "local" | 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) // Local field const [localPath, setLocalPath] = useState("") const [submitting, setSubmitting] = useState(false) const [error, setError] = useState(null) useEffect(() => { if (type === null) { setName("") setServer("") setDatastore("") setUsername("root@pam") setPassword("") setFingerprint("") setBorgRepo("") setBorgMode("local") setBorgSshUser("borg") setBorgSshHost("") setBorgSshRemotePath("") setBorgSshKeyPath("/root/.ssh/proxmenux_borg") setGeneratedKey(null) setGeneratingKey(false) setLocalPath("") setError(null) setSubmitting(false) } }, [type]) const nameValid = /^[a-zA-Z0-9_-]+$/.test(name) const borgValid = borgMode === "local" ? !!borgRepo.trim() : !!(borgSshUser.trim() && borgSshHost.trim() && borgSshRemotePath.trim()) const canSubmit = type === "pbs" ? nameValid && server.trim() && datastore.trim() && 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 } 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() }}> Add {type === "pbs" ? "PBS" : type === "borg" ? "Borg" : "local"} destination
{type === "pbs" && ( <>
setName(e.target.value)} className="font-mono mt-1" placeholder="my-pbs" />

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