diff --git a/AppImage/components/host-backup.tsx b/AppImage/components/host-backup.tsx index 17fdd6e3..e84b8c4b 100644 --- a/AppImage/components/host-backup.tsx +++ b/AppImage/components/host-backup.tsx @@ -1,12 +1,17 @@ "use client" -import { useState } from "react" +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, @@ -20,8 +25,18 @@ import { Archive, FileSearch, Calendar, + Trash2, + Plus, + ChevronRight, + ChevronLeft, + Pencil, + Save, + Download, + FileText, + Power, + RefreshCw, } from "lucide-react" -import { fetchApi } from "../lib/api-config" +import { fetchApi, getApiUrl } from "../lib/api-config" import { formatStorage } from "../lib/utils" // ── Shape contracts with the backend (flask_server.py: api_host_backups_*) ── @@ -37,10 +52,64 @@ interface BackupJob { 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 @@ -136,6 +205,114 @@ const formatNext = (iso: string | null) => { } } +// 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", @@ -144,41 +321,77 @@ export function HostBackup() { ) 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 runJob(id: string) { + async function confirmDeleteJob() { + if (!jobToDelete) return + const id = jobToDelete.id setBusyJobId(id) setActionError(null) try { - const r = await fetchApi(`/api/host-backups/jobs/${encodeURIComponent(id)}/run`, { method: "POST" }) - if (!r.ok) throw new Error(`HTTP ${r.status}`) + await fetchApi(`/api/host-backups/jobs/${encodeURIComponent(id)}`, { method: "DELETE" }) mutateJobs() + setJobToDelete(null) } catch (e) { - setActionError(`Failed to run "${id}": ${e instanceof Error ? e.message : String(e)}`) + setActionError(`Failed to delete "${id}": ${e instanceof Error ? e.message : String(e)}`) } finally { setBusyJobId(null) } } - - async function toggleJob(id: string) { - setBusyJobId(id) - setActionError(null) - try { - const r = await fetchApi(`/api/host-backups/jobs/${encodeURIComponent(id)}/toggle`, { method: "POST" }) - if (!r.ok) throw new Error(`HTTP ${r.status}`) - mutateJobs() - } catch (e) { - setActionError(`Failed to toggle "${id}": ${e instanceof Error ? e.message : String(e)}`) - } finally { - setBusyJobId(null) - } - } - const { data: archivesResp, error: archivesErr } = useSWR<{ archives: BackupArchive[] }>( + 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) + 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 (
@@ -188,8 +401,18 @@ export function HostBackup() {
Scheduled Backup Jobs + + {jobsResp?.jobs?.filter((j) => !j.manual).length ?? 0} +
- {jobsResp?.jobs?.length ?? 0} + {jobsErr ? ( @@ -199,117 +422,161 @@ export function HostBackup() { Loading...
- ) : jobsResp.jobs.length === 0 ? ( -
-

No scheduled backup jobs configured yet.

-

- For a one-shot manual backup{" "} - or to create a scheduled job, run: -

- - bash /usr/local/share/proxmenux/scripts/backup_restore/backup_host.sh - -

- Menu options 1-6 are manual backups (default or custom paths, to PBS, Borg, or local tar). Option 7 opens the scheduler if you want a recurring job. -

-
- ) : ( -
+ ) : jobsResp.jobs.filter((j) => !j.manual).length === 0 ? null : ( +
{actionError && ( -
+
{actionError}
)} - - - - - - - - - - - - - - {jobsResp.jobs.map((j) => { - const isBusy = busyJobId === j.id - return ( - - - - - - - - - - )})} - -
IDDestinationMethodScheduleLast statusNext runActions
{j.id} - {j.destination || "—"} - {j.method} - {j.on_calendar} + {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 ( + - {j.last_status ? ( - {j.last_status} - ) : ( - never - )} - - {j.attached ? "—" : formatNext(j.next_run)} - {!j.enabled && ( - + {!j.enabled && !j.manual && ( + disabled )} - -
-
+ + {/* Destination */} +
+ {j.destination || "—"} +
+ + {/* Stats row — icons in green, values in foreground + white to match the rest of the dashboard. */} +
+ + + {humanizeOnCalendar(j.on_calendar)} + + {j.retention && ( + + + {(() => { + // Backend returns retention as "last=7, daily=7, …". + // Parse and render as labelled chips so the row + // matches the visual style of the modal. + const pairs = j.retention + .split(",") + .map((s) => s.trim()) + .map((s) => { + const m = s.match(/^([a-z]+)=(\d+)$/i) + return m && Number(m[2]) > 0 ? { label: m[1], value: m[2] } : null + }) + .filter((x): x is { label: string; value: string } => x !== null) + if (pairs.length === 0) { + return {j.retention} + } + return pairs.map((p) => ( + + {p.label} + {p.value} + + )) + })()} + + )} + {!j.attached && j.next_run && ( + + + next: {formatNext(j.next_run)} + + )} + {(statusBadge || lastRunWhen) && ( + + + last: + {statusBadge && ( + + {statusBadge.label} + )} - Run - - -
-
+ {lastRunWhen && {lastRunWhen}} + + )} + {!status && ( + + + never run + + )} +
+
+ + + + ) + })}
)} + {/* ── 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 ─────────────────────────────── */} @@ -317,73 +584,96 @@ export function HostBackup() { Available Archives - {archivesResp?.archives?.length ?? 0} + {unifiedArchives.length} - {archivesErr ? ( +

+ 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 ? ( + ) : !archivesResp && !remoteArchivesResp ? (
Loading...
- ) : archivesResp.archives.length === 0 ? ( + ) : unifiedArchives.length === 0 ? (
- No backup archives found on this host. We scan /var/lib/vz/dump and any custom destination from a scheduled job, looking for files named hostcfg-<hostname>-*.tar.zst (manual backups) or <job_id>-*.tar.* (scheduled). PBS and Borg backups aren't surfaced in the UI yet. + No backups found yet. Use Run manual backup above, configure a scheduled job, or check that the configured PBS / Borg destinations have snapshots.
) : (
- {archivesResp.archives.map((a) => ( -
-
-
- {a.id} -
-
- - - {formatMtime(a.mtime)} - - - - {formatStorage(a.size_bytes)} - - {a.kind === "scheduled" && a.job_id ? ( - job: {a.job_id} - ) : a.kind === "legacy" ? ( - - legacy - - ) : ( - - manual - - )} - {a.source_hostname && a.source_hostname !== "" && ( - host: {a.source_hostname} - )} -
-
- -
- ))} +
+
+ {u.display_id} +
+
+ + {u.source} + + + + {formatMtime(u.created_at)} + + + + {formatStorage(u.size_bytes)} + + + at: {u.source_label} + + {u.source === "local" && localKind === "scheduled" && localJobId ? ( + job: {localJobId} + ) : u.source === "local" && localKind === "legacy" ? ( + + legacy + + ) : u.source === "local" && localKind === "manual" ? ( + + manual + + ) : null} + {(u.source === "pbs" || u.source === "borg") && u.remote?.backup_id && ( + {u.source === "pbs" ? "group" : "archive"}: {u.remote.backup_id} + )} + {u.source === "local" && localHost && ( + host: {localHost} + )} +
+
+ + + ) + })}
)}
@@ -393,7 +683,123 @@ export function HostBackup() { 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. +

+ )} +
+ )} +
+ + +
+
+
) } @@ -406,15 +812,217 @@ export function HostBackup() { function InspectModal({ archive, onClose, + onDeleted, }: { - archive: BackupArchive | null + 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 @@ -422,18 +1030,18 @@ function InspectModal({ vms_lxcs_at_backup: { vms: unknown[]; lxcs: unknown[] } storage_inventory?: { zfs_pools?: unknown[]; lvm?: { vgs?: unknown[] } } }>( - archive ? `/api/host-backups/archives/${encodeURIComponent(archive.id)}/manifest` : null, + localArc ? `/api/host-backups/archives/${encodeURIComponent(localArc.id)}/manifest` : null, fetcher, ) const runPreflight = async () => { - if (!archive) return + if (!localArc) return setRunning(true) setError(null) setReport(null) try { const res = await fetchApi( - `/api/host-backups/archives/${encodeURIComponent(archive.id)}/preflight`, + `/api/host-backups/archives/${encodeURIComponent(localArc.id)}/preflight`, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -448,28 +1056,127 @@ function InspectModal({ } } - // Reset state when archive changes - const archiveId = archive?.id - // Note: this useEffect-like cleanup happens via key={archiveId} on the - // Dialog content so React unmounts and remounts; state resets naturally. + // 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?.id} + {archive?.display_id} + {archive && ( + + {archive.source} + + )} - Inspect the manifest snapshot taken at backup time, then dry-run the restore plan for a chosen mode. Read-only; nothing on this host is changed. + {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."} - {/* Manifest summary */} + {/* ── 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 ? ( -
- Couldn't read the manifest from this archive — it may have been created before the manifest format was added. +
+ This archive doesn't carry a manifest snapshot — you can still pick a restore mode and get the instructions below.
) : !manifest ? (
@@ -480,10 +1187,34 @@ function InspectModal({ )} - {/* Preflight controls */} + {/* ── 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 */}
-
-
+
+
@@ -500,20 +1231,64 @@ function InspectModal({
- + + +
+

+ 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 && (
@@ -522,9 +1297,122 @@ function InspectModal({ )} {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 ?? ""}
+        
+
+ +
+
+
+ ) } @@ -571,20 +1459,93 @@ function ManifestSummary({ ) } -function Field({ icon, label, value, mono }: { icon?: React.ReactNode; label: string; value: string; mono?: boolean }) { +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 @@ -683,3 +1644,3657 @@ function PreflightReportView({ report }: { report: PreflightReport }) {
) } + +// ────────────────────────────────────────────────────────────── +// 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: +

+