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

{t("backup.errors.passphrasesDontMatch")}

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

{t("backup.manual.description")}

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

{t("backup.archives.descriptionBefore")} .tar.zst {t("backup.archives.descriptionAfter")}

{remoteArchivesResp?.errors && remoteArchivesResp.errors.length > 0 && (
{t("backup.archives.remoteQueryWarning")}
{remoteArchivesResp.errors.map((e, i) => (
{e.backend}/{e.repo_name}: {e.error}
))}
)} {archivesErr && remoteArchivesErr ? (
{t("backup.archives.loadFailed")}
) : !archivesResp && !remoteArchivesResp ? (
{t("backup.common.loading")}
) : unifiedArchives.length === 0 ? (
{t("backup.archives.emptyBefore")} {t("backup.manual.run")} {t("backup.archives.emptyAfter")}
) : (
{unifiedArchives.map((u) => { const localKind = u.local?.kind const localJobId = u.local?.job_id const localHost = u.local?.source_hostname const localPath = u.local?.path // Same palette as the DestinationRow accents so each // backend reads identically across the two cards. const sourceBadgeCls = u.source === "pbs" ? "text-purple-400 border-purple-500/20 bg-purple-500/10" : u.source === "borg" ? "text-fuchsia-400 border-fuchsia-500/20 bg-fuchsia-500/10" : "text-blue-400 border-blue-500/20 bg-blue-500/10" return ( ) })}
)}
{/* ── Inspect / preflight modal ──────────────────────── */} setInspectingArchive(null)} onDeleted={() => { setInspectingArchive(null) // Refresh both lists — a deleted item might have been local // or remote (PBS/Borg). Cheap enough to revalidate both. mutateArchives() mutateRemoteArchives() }} /> {/* ── Create / Edit job wizard ─────────────────────── */} setCreatingJob(false)} onCreated={() => { setCreatingJob(false) mutateJobs() }} /> setEditingJobId(null)} onCreated={() => { setEditingJobId(null) mutateJobs() }} /> {/* ── One-shot manual backup ─────────────────────── */} setRunningManual(false)} onLaunched={(jobId) => { // Hand off straight to the live-progress modal so the // operator never has to chase the "View progress" banner // after pressing Run. Mutate jobs first so the new manual // entry is in cache when the watch modal opens. setRunningManual(false) mutateJobs() setWatchingManualId(jobId) // Register the job with the modal-independent background // poller so the Available Archives list refreshes the // instant the run finishes, even if the operator closes // the watch modal before then. Replaces the old fire-and- // forget 5 s mutate that used to miss slow backups. setPendingManualJobId(jobId) }} /> {/* ── Job detail / actions modal ─────────────────────────── */} setViewingJobId(null)} onEdit={(id) => { setViewingJobId(null) setEditingJobId(id) }} onRequestDelete={(id) => { const job = jobsResp?.jobs.find((j) => j.id === id) if (job) { setViewingJobId(null) setJobToDelete(job) } }} onChanged={() => { mutateJobs() mutateArchives() mutateRemoteArchives() }} /> {/* Manual jobs use their own modal — no Schedule/Retention/ Profile, no Edit/Run/Disable. Only the live log; the modal is closed with the dialog's own X (no extra footer). */} setWatchingManualId(null)} onChanged={() => { // The watch modal fires onChanged when a tracked run // finishes (last_result transitions from null → ok/failed). // Refresh jobs (status badge), local archives (new tar.zst) // and remote archives (Borg/PBS snapshots) so the operator // sees the new backup without waiting for the next tick. mutateJobs() mutateArchives() mutateRemoteArchives() }} /> {/* ── Delete confirmation ────────────────────────────── */} { if (!v) setJobToDelete(null) }}> {t("backup.deleteJob.title")} {t("backup.deleteJob.description")} {jobToDelete && (
{t("backup.fields.jobId")}
{jobToDelete.id}
{jobToDelete.attached && jobToDelete.pve_storage && ( <>
{t("backup.fields.type")}
{t("backup.deleteJob.attachedToStorage")} {jobToDelete.pve_storage}
)}
{jobToDelete.attached ? (

{t("backup.deleteJob.attachedWarning")}

) : (

{t("backup.deleteJob.timerWarning")}

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

{t("backup.logs.runLog")}

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

{isEdit ? t("backup.jobs.jobNameLocked") : <>{t("backup.jobs.jobNameHelpBefore")} _ {t("backup.jobs.jobNameHelpAnd")} -. {t("backup.jobs.jobNameHelpAfter")}}

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

{t("backup.jobs.invalidJobName")}

)}
{isEdit && (

{t("backup.jobs.backendEditHelp")}

)}
{(["pbs", "local", "borg"] as const).map((b) => { const Icon = b === "pbs" ? Server : b === "local" ? HardDrive : Archive const desc = b === "pbs" ? t("backup.backends.pbsDescription") : b === "local" ? t("backup.backends.localDescription") : t("backup.backends.borgDescriptionTimerOnly") return ( ) })}
)} {/* ── Step 2: How to schedule ──────────────────── */} {step === 2 && (

{backend === "borg" ? t("backup.schedule.borgTimerOnly") : t("backup.schedule.modeHelp")}

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

{t("backup.jobs.parentPveJobHelpBefore")} job-end {t("backup.jobs.parentPveJobHelpAfter")}

{compatibleJobs.length === 0 ? (
{t("backup.jobs.noCompatiblePveJob")}

{t("backup.jobs.noCompatiblePveJobDescriptionBefore")} {backend === "pbs" ? "PBS" : backend === "borg" ? "Borg" : t("backup.backends.local")} {t("backup.jobs.noCompatiblePveJobDescriptionMiddle")} Datacenter → Backup {t("backup.jobs.noCompatiblePveJobDescriptionAfter")}

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

{t("backup.schedule.pickFrequency")}

{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]" />

{t("backup.schedule.hourlyHelpBefore")} 0 {t("backup.schedule.hourlyHelpMiddle")} 30 {t("backup.schedule.hourlyHelpAfter")}

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

{t("backup.schedule.pickAtLeastOneDay")}

)}
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]" />

{t("backup.schedule.monthlySkipHelp")}

setScheduleTime(e.target.value)} className="font-mono mt-1 max-w-[140px]" />
)} {scheduleType === "advanced" && (
setScheduleAdvanced(e.target.value)} className="font-mono mt-1" placeholder={t("backup.placeholders.onCalendar")} />

{t("backup.schedule.advancedHelpBefore")} systemd-analyze calendar. {t("backup.schedule.advancedHelpMiddle")} man systemd.time {t("backup.schedule.advancedHelpAfter")}

)} {/* Live preview from the backend */}
{t("backup.schedule.preview")}
{t("backup.fields.expressionLabel")} {onCalendar}
{calendarPreview ? ( calendarPreview.valid ? ( <> {calendarPreview.normalized && calendarPreview.normalized !== onCalendar && (
{t("backup.fields.normalizedLabel")} {calendarPreview.normalized}
)} {calendarPreview.next_elapse && (
{t("backup.jobs.nextRunLabel")} {formatCalendarPreview(calendarPreview.next_elapse, language, t)} {calendarPreview.from_now && ( ({formatCalendarDistance(calendarPreview.from_now, language)}) )}
)} ) : (
{t("backup.validation.invalidLabel")} {calendarPreview.error}
) ) : (
{t("backup.common.checking")}
)}

{t("backup.retention.zeroDisables")}

{[ { id: "keep-last", lbl: t("backup.retention.keepLast"), v: keepLast, set: setKeepLast }, { id: "keep-hourly", lbl: t("backup.retention.keepHourly"), v: keepHourly, set: setKeepHourly }, { id: "keep-daily", lbl: t("backup.retention.keepDaily"), v: keepDaily, set: setKeepDaily }, { id: "keep-weekly", lbl: t("backup.retention.keepWeekly"), v: keepWeekly, set: setKeepWeekly }, { id: "keep-monthly", lbl: t("backup.retention.keepMonthly"), v: keepMonthly, set: setKeepMonthly }, { id: "keep-yearly", lbl: t("backup.retention.keepYearly"), 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 ? (
) : (
{t("backup.destinations.noPbsInWizard")}
)}
setPbsBackupId(e.target.value)} placeholder={t("backup.placeholders.leaveBlankForDefault")} className="font-mono mt-1" />

{t("backup.jobs.pbsGroupHelp")}

{/* PBS client-side encryption — mirrors the shell wizard: step 1 is a plain yes/no, step 2 only appears when no keyfile is installed yet. Once a keyfile exists, every future encrypted job silently reuses it. */}
{pbsEncrypt && !pbsRecoveryStatus?.has_keyfile && (
{t("backup.encryption.noKeyStored")}
{pbsEncryptMode === "existing" && pbsPveMatch && (
{t("backup.encryption.autoDetectedFromPveStorage", { storage: pbsPveMatch.name })}
{t("backup.encryption.existingKeyAt")} {pbsPveMatch.path}
{t("backup.encryption.pveKeyWillBeCopied")}
)} {pbsEncryptMode === "existing" && !pbsPveMatch && (
setPbsImportFile(e.target.files?.[0] ?? null)} disabled={pbsImportBusy || !!pbsImportPath.trim()} className="h-8 text-[11px]" />
{t("backup.common.or")}
setPbsImportPath(e.target.value)} disabled={pbsImportBusy || !!pbsImportFile} placeholder={t("backup.placeholders.keyfilePath")} className="h-8 text-[11px] font-mono" />

{t("backup.encryption.importKeyfileHelpBefore")} /usr/local/share/proxmenux/pbs-key.conf {t("backup.encryption.importKeyfileHelpAfter")}

)}
)} {pbsEncrypt && (
{pbsRecoveryStatus?.has_keyfile && !pbsRecoveryChange && (
{t("backup.keyfileManagement.keyfileInstalled")}
{t("backup.keyfileManagement.currentPbsUploadStays")}
)} {pbsRecoveryStatus?.has_keyfile && pbsRecoveryChange && ( <> )} {!pbsRecoveryStatus?.has_keyfile && ( <>
{t("backup.keyfileManagement.uploadToPbs")}
{pbsUploadToPbs ? (
setPbsRecoveryPass(e.target.value)} placeholder={t("backup.placeholders.recoveryPassphrase")} className="font-mono mt-1 h-8 text-xs" />
setPbsRecoveryPass2(e.target.value)} placeholder={t("backup.placeholders.typeItAgain")} className="font-mono mt-1 h-8 text-xs" /> {pbsRecoveryPass && pbsRecoveryPass2 && pbsRecoveryPass !== pbsRecoveryPass2 && (

{t("backup.errors.passphrasesDontMatch")}

)}
) : (
{t("backup.keyfileManagement.keepOffsiteCopy")}
{t("backup.keyfileManagement.copyAfterCreateBefore")} /usr/local/share/proxmenux/pbs-key.conf {t("backup.keyfileManagement.copyAfterCreateAfter")}
{t("backup.keyfileManagement.withoutCopyWarning")}
)} )}
)}
)} {backend === "local" && (
{mode === "attach" ? ( <> setLocalDestDir(e.target.value)} placeholder={t("backup.placeholders.autoFromPveStorage")} className="font-mono" />

{t("backup.destinations.localAttachHelpBefore")} /dump {t("backup.destinations.localAttachHelpMiddle")} /var/lib/vz/dump.

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

{t("backup.destinations.localPickHelpBefore")} {destResp?.local?.effective || "/var/lib/vz/dump"}.

)}
)} {backend === "borg" && ( <>
{destResp?.borg?.length ? (
) : (
{t("backup.destinations.noBorgInWizard")}
)}
{/* Encryption + passphrase aren't asked here — they live on the borg destination. The runner reads the saved sidecar at run time. */} {(() => { const dest = destResp?.borg?.find((r) => r.repository === borgRepoSelected) if (!dest) return null const mode = dest.encrypt_mode || "repokey" return (

{t("backup.destinations.borgEncryptionInherited", { mode: mode === "none" ? t("backup.encryption.none") : mode })}

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

{t("backup.encryption.importKeyfileHelpBefore")} /usr/local/share/proxmenux/pbs-key.conf {t("backup.encryption.importKeyfileHelpAfter")}

)}
)} {pbsEncrypt && (
{pbsRecoveryStatus?.has_keyfile && !pbsRecoveryChange && (
{t("backup.keyfileManagement.thisBackupReusesKeyfile")}
{t("backup.keyfileManagement.currentPbsUploadStays")}
)} {pbsRecoveryStatus?.has_keyfile && pbsRecoveryChange && ( <> )} {!pbsRecoveryStatus?.has_keyfile && ( <>
{t("backup.keyfileManagement.uploadToPbs")}
{pbsUploadToPbs ? (
setPbsRecoveryPass(e.target.value)} placeholder={t("backup.placeholders.recoveryPassphrase")} className="font-mono mt-1 h-8 text-xs" />
setPbsRecoveryPass2(e.target.value)} placeholder={t("backup.placeholders.typeItAgain")} className="font-mono mt-1 h-8 text-xs" /> {pbsRecoveryPass && pbsRecoveryPass2 && pbsRecoveryPass !== pbsRecoveryPass2 && (

{t("backup.errors.passphrasesDontMatch")}

)}
) : (
{t("backup.keyfileManagement.keepOffsiteCopy")}
{t("backup.keyfileManagement.copyAfterManualBefore")} /usr/local/share/proxmenux/pbs-key.conf {t("backup.keyfileManagement.copyAfterCreateAfter")}
{t("backup.keyfileManagement.withoutCopyWarning")}
)} )}
)}
)} {backend === "local" && (
{(destResp?.local?.entries?.length ?? 0) > 0 && (
)}

{t("backup.destinations.localManualPickHelp")}

setLocalDestDir(e.target.value)} placeholder={destResp?.local?.effective || "/var/lib/vz/dump"} className="font-mono" />
)} {backend === "borg" && ( <>
{destResp?.borg?.length ? (
) : (
{t("backup.destinations.noBorgInDialog")}
)}
{(() => { const dest = destResp?.borg?.find((r) => r.repository === borgRepoSelected) if (!dest) return null const mode = dest.encrypt_mode || "repokey" return (

{t("backup.destinations.borgEncryptionInherited", { mode: mode === "none" ? t("backup.encryption.none") : mode })}

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

{headline}

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

{subline}

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

{t("backup.fields.size")}

{formatBytes(capacity.total)}

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

{t("backup.fields.used")}

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

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

{t("backup.fields.free")}

{formatBytes(capacity.available)}

)}

{t("backup.fields.source")}

{sourceLabel}

{capacity?.error && (

{t("backup.destinations.capacityUnavailable", { error: capacity.error })}

)} {!capacity && (

{t("backup.destinations.probingCapacity")}

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

{isEditing ? t("backup.destinations.nameCredentialsLocked") : t("backup.destinations.shortIdentifierHelp")}

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" />

{t("backup.destinations.pbsUserHelpBefore")} proxmox-backup-client. {t("backup.destinations.defaultUserBefore")} root@pam.

setPassword(e.target.value)} className="font-mono mt-1" placeholder={isEditing && editing?.has_password ? t("backup.placeholders.unchangedTypeToReplace") : ""} /> {isEditing && editing?.has_password && (

{t("backup.destinations.leavePasswordBlank")}

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

{t("backup.destinations.fingerprintHelp")}

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

{t("backup.destinations.namePassphraseLocked")}

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

{t("backup.destinations.absolutePathHelp")}

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

{t("backup.destinations.sshUserHelpBefore")} borg serve. {t("backup.destinations.sshUserHelpAfter")} borg, {t("backup.destinations.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" />

{t("backup.destinations.sshKeyHelpBefore")} {" "}{t("backup.destinations.sshKeyHelpMiddle")} {t("backup.actions.generateKey")} {t("backup.destinations.sshKeyHelpAfter")}

{t("backup.destinations.generateNewSshKey")}
{generatedKey ? ( <>

{t("backup.destinations.appendAuthorizedKeyBefore")} ~{borgSshUser}/.ssh/authorized_keys: