Files
ProxMenux/AppImage/components/host-backup.tsx
T

8924 lines
404 KiB
TypeScript
Raw Normal View History

2026-06-09 00:13:24 +02:00
"use client"
2026-06-19 23:38:57 +02:00
import { useState, useEffect, useRef } from "react"
2026-06-09 00:13:24 +02:00
import useSWR from "swr"
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
import { Button } from "./ui/button"
import { Badge } from "./ui/badge"
2026-07-02 23:52:04 +02:00
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "./ui/dialog"
2026-06-09 00:13:24 +02:00
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
2026-06-19 23:38:57 +02:00
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"
2026-06-09 00:13:24 +02:00
import {
DatabaseBackup,
Clock,
HardDrive,
Server,
CheckCircle2,
AlertTriangle,
XCircle,
Loader2,
PlayCircle,
Archive,
FileSearch,
Calendar,
2026-06-19 23:38:57 +02:00
Trash2,
Plus,
ChevronRight,
ChevronLeft,
Pencil,
Save,
Download,
FileText,
Power,
RefreshCw,
2026-06-20 21:59:19 +02:00
Lock,
2026-06-21 00:35:22 +02:00
Eye,
Network,
Cpu,
Package,
ShieldCheck,
Info,
ListTree,
2026-06-22 11:38:06 +02:00
History,
2026-06-09 00:13:24 +02:00
} from "lucide-react"
2026-06-21 22:41:54 +02:00
import { ScriptTerminalModal } from "./script-terminal-modal"
2026-07-05 16:50:06 +02:00
import { RestoreProgressCard } from "./restore-progress-card"
2026-06-19 23:38:57 +02:00
import { fetchApi, getApiUrl } from "../lib/api-config"
2026-06-21 22:41:54 +02:00
import { fetchTerminalTicket } from "../lib/terminal-ws"
2026-06-24 23:40:22 +02:00
import { formatStorage, formatBytes } from "../lib/utils"
2026-06-25 00:00:12 +02:00
import { getStorageUsageColor } from "../lib/storage-usage-color"
2026-06-09 00:13:24 +02:00
// ── Shape contracts with the backend (flask_server.py: api_host_backups_*) ──
interface BackupJob {
id: string
destination: string
2026-06-13 19:05:00 +02:00
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"
2026-06-19 23:38:57 +02:00
manual: boolean // MANUAL_RUN=1 — one-shot, no schedule, won't re-fire
2026-06-09 00:13:24 +02:00
last_status: string | null
next_run: string | null
2026-06-20 21:59:19 +02:00
// 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
2026-06-09 00:13:24 +02:00
}
2026-06-19 23:38:57 +02:00
// 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
2026-06-20 21:59:19 +02:00
// 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
2026-06-19 23:38:57 +02:00
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
}
2026-06-09 00:13:24 +02:00
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<string, unknown> | 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)
2026-07-08 19:17:13 +02:00
// 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 }) => (
<div className={`text-xs rounded-md border border-amber-500/40 bg-amber-500/10 text-amber-100 px-3 py-3 space-y-2 ${className ?? ""}`}>
<div className="flex items-start gap-2">
<Lock className="h-4 w-4 mt-0.5 shrink-0 text-amber-400" />
<div className="flex-1 min-w-0 space-y-1">
<div className="font-semibold text-amber-300">Encrypted backup wrong keyfile on this host</div>
<div className="text-amber-100/90">
The keyfile installed here does not match the one used to create this backup, so PBS refuses to open it.
</div>
{err.manifestFp && (
<div className="pt-1">
<div className="text-[10.5px] uppercase tracking-wider text-amber-300/80">Required (manifest)</div>
<div className="font-mono text-[11px] break-all">{err.manifestFp}</div>
</div>
)}
{err.providedFp && (
<div>
<div className="text-[10.5px] uppercase tracking-wider text-amber-300/80">Currently installed</div>
<div className="font-mono text-[11px] break-all">{err.providedFp}</div>
</div>
)}
<div className="pt-1 text-amber-100/90">
Import the correct keyfile from{" "}
<span className="font-medium">Backup configuration Destinations PBS row Upload</span>
{" "}(you may need to Delete the current one first) and retry.
</div>
</div>
</div>
</div>
)
2026-06-09 00:13:24 +02:00
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
}
}
2026-06-19 23:38:57 +02:00
// Best-effort human form of systemd OnCalendar expressions. Whatever
// we don't recognise falls back to "<raw> (systemd OnCalendar)" so the
// operator at least knows what kind of string they're looking at.
// Handles the patterns the host-backup wizard can emit ("hourly",
// "daily", "weekly", "monthly", "*-*-* HH:MM:SS", "Mon..Sun *-*-* …").
const humanizeOnCalendar = (raw: string | null | undefined): string => {
if (!raw) return "—"
const s = raw.trim()
if (!s) return "—"
const lower = s.toLowerCase()
if (lower === "hourly") return "Every hour (at minute 0)"
if (lower === "daily") return "Every day at 00:00"
if (lower === "weekly") return "Every Monday at 00:00"
if (lower === "monthly") return "On the 1st of every month at 00:00"
if (lower === "yearly" || lower === "annually") return "On Jan 1st at 00:00"
if (lower === "minutely") return "Every minute"
// *-*-* HH:MM[:SS] → "Every day at HH:MM"
let m = s.match(/^\*-\*-\*\s+(\d{1,2}):(\d{2})(?::(\d{2}))?$/)
if (m) return `Every day at ${m[1].padStart(2, "0")}:${m[2]}`
// *-*-* *:MM:SS → "Every hour at minute MM"
m = s.match(/^\*-\*-\*\s+\*:(\d{2})(?::(\d{2}))?$/)
if (m) return `Every hour at minute ${m[1]}`
// Mon,Tue *-*-* HH:MM:SS → "<weekdays> 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<string, string> = {
mon: "Monday", tue: "Tuesday", wed: "Wednesday",
thu: "Thursday", fri: "Friday", sat: "Saturday", sun: "Sunday",
}
const rangeMatch = chunk.match(/^([A-Za-z]+)\.\.([A-Za-z]+)$/)
if (rangeMatch) {
const order = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
const start = order.indexOf(rangeMatch[1].slice(0, 3).toLowerCase())
const end = order.indexOf(rangeMatch[2].slice(0, 3).toLowerCase())
if (start >= 0 && end >= 0 && start <= end) {
return order.slice(start, end + 1).map((d) => days[d]).join(", ")
}
}
return chunk
.split(",")
.map((d) => days[d.trim().slice(0, 3).toLowerCase()] || d.trim())
.join(", ")
}
return `${expandWeekdays(m[1])} at ${m[2].padStart(2, "0")}:${m[3]}`
}
return `${s} (systemd OnCalendar)`
}
2026-06-20 21:59:19 +02:00
// 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)
}
2026-06-19 23:38:57 +02:00
// 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<string, string> = {}
for (const line of raw.split("\n")) {
const m = line.match(/^([A-Z_]+)=(.*)$/)
if (m) map[m[1]] = m[2]
}
return {
result: map["RESULT"] || null,
runAt: map["RUN_AT"] || null,
logFile: map["LOG_FILE"] || null,
}
}
// Backend-method color scheme, mirrored exactly from storage-overview.tsx
// (line 414 et al.) so a PBS job badge here looks identical to a PBS
// snapshot badge on the Storage page.
const methodBadgeCls = (m: string | undefined): string => {
switch ((m || "").toLowerCase()) {
case "pbs":
return "bg-purple-500/10 text-purple-400 border-purple-500/20"
case "borg":
return "bg-fuchsia-500/10 text-fuchsia-400 border-fuchsia-500/20"
case "local":
default:
return "bg-blue-500/10 text-blue-400 border-blue-500/20"
}
}
const formatRunAt = (iso: string | null) => {
if (!iso) return null
try {
return new Date(iso).toLocaleString(undefined, {
day: "2-digit",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})
} catch {
return iso
}
}
2026-07-06 00:30:47 +02:00
// ── 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
2026-07-08 12:18:52 +02:00
// 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.
// ──────────────────────────────────────────────────────────────
2026-07-14 19:22:33 +02:00
function PbsKeyfileActions({ pbsRepository }: { pbsRepository?: string }) {
2026-07-08 12:18:52 +02:00
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<File | null>(null)
const [importPath, setImportPath] = useState("")
const [busy, setBusy] = useState(false)
const [err, setErr] = useState<string | null>(null)
2026-07-14 19:22:33 +02:00
// Auto-discover a PVE-managed keyfile at /etc/pve/priv/storage/<NAME>.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
2026-07-08 12:18:52 +02:00
const closeUpload = () => {
setUploadOpen(false)
setImportFile(null)
setImportPath("")
setBusy(false)
setErr(null)
}
const closeDelete = () => {
setDeleteOpen(false)
setBusy(false)
setErr(null)
}
const download = () => {
// Same plain-<a>-download trick used elsewhere: bypasses fetch so
// an ad-blocker or a stale SWR cache can't intervene.
const a = document.createElement("a")
a.href = "/api/host-backups/pbs-encryption/download-keyfile"
a.download = "pbs-key.conf"
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}
const runUpload = async () => {
if (!importFile && !importPath.trim()) {
setErr("Pick a keyfile file or enter an absolute path on this host.")
return
}
setBusy(true)
setErr(null)
try {
const body: Record<string, unknown> = { 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 (
<div className="mt-2 border-t border-white/10 pt-2 space-y-1.5">
<div className="flex items-center gap-2 flex-wrap">
<div className="text-xs min-w-0 flex-1 flex items-center gap-1.5">
<span className="font-medium text-muted-foreground">Encryption keyfile:</span>{" "}
{installed ? (
<span
className="inline-flex items-center gap-1 text-emerald-400 font-medium"
title={info?.fingerprint ? `Keyfile fingerprint: ${info.fingerprint}` : undefined}
>
<CheckCircle2 className="h-4 w-4" />
installed
</span>
) : (
<span className="text-blue-400 font-medium">not installed on this host</span>
)}
</div>
<div className="flex items-center gap-1.5 shrink-0">
{installed && (
<>
<Button
type="button"
size="sm"
variant="outline"
className="h-7 text-[11px] !text-emerald-400 border-emerald-500/40 hover:bg-emerald-500/10"
onClick={download}
title="Download the keyfile as pbs-key.conf"
>
<Download className="h-3.5 w-3.5 mr-1" />
Download
</Button>
<Button
type="button"
size="sm"
variant="outline"
className="h-7 text-[11px] !text-red-400 border-red-500/40 hover:bg-red-500/10"
onClick={() => setDeleteOpen(true)}
title="Remove the local keyfile"
>
<Trash2 className="h-3.5 w-3.5 mr-1" />
Delete
</Button>
</>
)}
{!installed && (
<Button
type="button"
size="sm"
variant="outline"
className="h-7 text-[11px] !text-blue-400 border-blue-500/40 hover:bg-blue-500/10"
onClick={() => setUploadOpen(true)}
title="Import a keyfile you already have"
>
<Lock className="h-3.5 w-3.5 mr-1" />
Upload
</Button>
)}
</div>
</div>
<Dialog open={uploadOpen} onOpenChange={(v) => !v && closeUpload()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Upload PBS keyfile</DialogTitle>
<DialogDescription>
Import a keyfile you already have. It lands at <code className="font-mono">/usr/local/share/proxmenux/pbs-key.conf</code> and every subsequent encrypted backup reuses it. Recovery escrow stays off use the setup wizard if you want to enable it.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
2026-07-14 19:22:33 +02:00
{pveMatch && (
<div className="rounded-md border border-emerald-500/40 bg-emerald-500/10 p-3 space-y-2 text-xs">
<div className="flex items-start gap-2">
<Lock className="h-4 w-4 text-emerald-400 mt-0.5 shrink-0" />
<div className="flex-1 space-y-1">
<div className="font-semibold text-emerald-300">PVE-managed keyfile detected for this PBS</div>
<div className="text-muted-foreground">
Proxmox already stores an encryption key for storage <code className="font-mono text-[10.5px]">{pveMatch.name}</code> at{" "}
<code className="font-mono text-[10.5px] break-all">{pveMatch.path}</code>. Import it in one click.
</div>
</div>
</div>
<div className="flex justify-end">
<Button
size="sm"
onClick={() => {
setImportPath(pveMatch.path)
setImportFile(null)
}}
disabled={busy}
className="!bg-emerald-600 hover:!bg-emerald-700 !text-white h-7 text-[11px]"
>
Use this key
</Button>
</div>
</div>
)}
{pveMatch && <div className="text-[10px] text-center text-muted-foreground"> or upload your own </div>}
2026-07-08 12:18:52 +02:00
<div>
<Label htmlFor="pbsKfUploadFile" className="text-xs">Upload from your machine</Label>
<Input
id="pbsKfUploadFile"
type="file"
accept=".conf,.key,application/json,text/plain"
onChange={(e) => setImportFile(e.target.files?.[0] ?? null)}
disabled={busy || !!importPath.trim()}
className="h-9 mt-1"
/>
</div>
<div className="text-[10px] text-center text-muted-foreground"> or </div>
<div>
<Label htmlFor="pbsKfUploadPath" className="text-xs">Absolute path on this host</Label>
<Input
id="pbsKfUploadPath"
type="text"
value={importPath}
onChange={(e) => setImportPath(e.target.value)}
disabled={busy || !!importFile}
2026-07-14 19:22:33 +02:00
placeholder="e.g. /etc/pve/priv/storage/<NAME>.enc or /root/my-pbs-key"
2026-07-08 12:18:52 +02:00
className="h-9 mt-1 font-mono text-xs"
/>
</div>
{err && (
<div className="text-xs text-red-500 px-3 py-2 rounded-md border border-red-500/30 bg-red-500/10 whitespace-pre-wrap break-words">{err}</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={closeUpload} disabled={busy}>Cancel</Button>
<Button
onClick={runUpload}
disabled={busy || (!importFile && !importPath.trim())}
className="!bg-blue-500 hover:!bg-blue-600 !text-white"
>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : "Import"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={deleteOpen} onOpenChange={(v) => !v && closeDelete()}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-red-400" />
Delete keyfile
</DialogTitle>
<DialogDescription>
Backups already stored on PBS were encrypted with the current keyfile. After this action:
</DialogDescription>
</DialogHeader>
<ul className="text-xs text-muted-foreground list-disc list-inside space-y-1 pl-2">
<li>New backups will use no encryption on this host until a new keyfile is set up.</li>
<li>Downloading pre-existing encrypted backups from this host <strong className="text-red-400">will fail</strong> unless you kept a copy of the current key.</li>
<li>Existing recovery blobs on PBS stay intact they still recover the old key with its original passphrase.</li>
</ul>
{err && (
<div className="text-xs text-red-500 px-3 py-2 rounded-md border border-red-500/30 bg-red-500/10 whitespace-pre-wrap break-words">{err}</div>
)}
<DialogFooter>
<Button variant="outline" onClick={closeDelete} disabled={busy}>Cancel</Button>
<Button onClick={runDelete} disabled={busy} className="!bg-red-500 hover:!bg-red-600 !text-white">
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : "Delete"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
2026-07-06 12:00:43 +02:00
function KeyfileActionsBar({
2026-07-06 00:30:47 +02:00
mutateStatus,
2026-07-08 12:18:52 +02:00
escrowMode,
2026-07-06 00:30:47 +02:00
}: {
mutateStatus: () => Promise<unknown>
2026-07-08 12:18:52 +02:00
escrowMode?: "none" | "local" | "full"
2026-07-06 00:30:47 +02:00
}) {
const [busy, setBusy] = useState(false)
const [err, setErr] = useState<string | null>(null)
const [pass1, setPass1] = useState("")
const [pass2, setPass2] = useState("")
2026-07-08 15:20:38 +02:00
// 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")
2026-07-06 00:30:47 +02:00
2026-07-08 15:20:38 +02:00
const runApply = async () => {
// Yes → passphrase required + match. No → passphrase ignored.
if (pendingMode === "full") {
2026-07-08 12:18:52 +02:00
if (!pass1) {
2026-07-08 15:20:38 +02:00
setErr("Recovery passphrase is required.")
2026-07-08 12:18:52 +02:00
return
}
if (pass1 !== pass2) {
setErr("Passphrases do not match.")
return
}
}
2026-07-06 00:30:47 +02:00
setBusy(true)
setErr(null)
try {
2026-07-08 15:20:38 +02:00
const body: Record<string, unknown> = { escrow_mode: pendingMode }
if (pendingMode === "full") body.escrow_passphrase = pass1
2026-07-08 12:18:52 +02:00
await fetchApi("/api/host-backups/pbs-encryption/set-escrow-mode", {
2026-07-06 00:30:47 +02:00
method: "POST",
headers: { "Content-Type": "application/json" },
2026-07-08 12:18:52 +02:00
body: JSON.stringify(body),
2026-07-06 00:30:47 +02:00
})
await mutateStatus()
2026-07-08 15:20:38 +02:00
setPass1("")
setPass2("")
2026-07-06 00:30:47 +02:00
} catch (e) {
setErr(e instanceof Error ? e.message : String(e))
2026-07-08 15:20:38 +02:00
} finally {
2026-07-06 00:30:47 +02:00
setBusy(false)
}
}
2026-07-08 15:20:38 +02:00
// Contextual Apply-button label. Three transitions:
// No → Yes → "Start uploading"
// Yes → No → "Stop uploading"
// Yes → Yes (new pw) → "Update passphrase" (rewraps envelope)
const applyLabel =
pendingMode === "full" && !currentIsFull
? "Start uploading"
: pendingMode === "none" && currentIsFull
? "Stop uploading"
: pendingMode === "full" && currentIsFull
? "Update passphrase"
: "Apply"
// Apply is enabled when there is a real change to commit.
const canApply = (
(pendingMode !== (currentIsFull ? "full" : "none")) || // mode change
(pendingMode === "full" && !!pass1 && pass1 === pass2) // passphrase update
)
const download = () => {
const a = document.createElement("a")
a.href = "/api/host-backups/pbs-encryption/download-keyfile"
a.download = "pbs-key.conf"
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}
2026-07-08 12:18:52 +02:00
2026-07-06 00:30:47 +02:00
return (
2026-07-08 15:20:38 +02:00
<div className="space-y-3">
2026-07-06 12:00:43 +02:00
<div className="text-[11px] font-medium text-foreground">Manage installed keyfile</div>
2026-07-08 15:20:38 +02:00
{/* Current status — icon + colour by state, no truncated fp. */}
{escrowMode !== undefined && (
<div className="flex items-center gap-2 text-xs bg-background/40 border border-white/10 rounded px-2.5 py-1.5">
2026-07-08 12:18:52 +02:00
<span className="font-medium text-foreground">Upload to PBS:</span>
2026-07-08 15:20:38 +02:00
{currentIsFull ? (
<span className="inline-flex items-center gap-1 text-emerald-400 font-medium">
<CheckCircle2 className="h-3.5 w-3.5" />
Yes envelope uploaded on every backup
</span>
) : (
<span className="inline-flex items-center gap-1 text-blue-400 font-medium">
No kept only on this host
</span>
)}
2026-07-08 12:18:52 +02:00
</div>
)}
2026-07-08 15:20:38 +02:00
{/* 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). */}
<div className="space-y-2 rounded-md border border-blue-500/30 bg-blue-500/5 p-3">
<div className="text-[11px] font-medium text-foreground">Upload key to PBS?</div>
<div className="grid gap-1.5">
<label className="flex items-start gap-2 cursor-pointer text-[11px]">
<input
type="radio"
name="pendingModeChange"
checked={pendingMode === "none"}
onChange={() => { setPendingMode("none"); setPass1(""); setPass2("") }}
className="mt-1"
/>
<div className="flex-1">
<div className="font-medium">No, keep local only</div>
<div className="text-muted-foreground">Keyfile stays at <code className="font-mono text-[10.5px]">/usr/local/share/proxmenux/pbs-key.conf</code>. You handle the offsite copy use <em>Download keyfile</em> below.</div>
</div>
</label>
<label className="flex items-start gap-2 cursor-pointer text-[11px]">
<input
type="radio"
name="pendingModeChange"
checked={pendingMode === "full"}
onChange={() => setPendingMode("full")}
className="mt-1"
/>
<div className="flex-1">
<div className="font-medium">Yes, upload</div>
<div className="text-muted-foreground">A passphrase-wrapped copy of the keyfile is uploaded to PBS with every backup. Fill the passphrase pair below to enable or to rotate the current one.</div>
</div>
</label>
</div>
{pendingMode === "full" && (
<div className="space-y-2 pt-2 border-t border-blue-500/20">
<div>
<Label htmlFor="mgmtPass1" className="text-[11px]">
{currentIsFull ? "New recovery passphrase" : "Recovery passphrase"}
</Label>
<Input
id="mgmtPass1"
type="password"
value={pass1}
onChange={(e) => setPass1(e.target.value)}
placeholder={currentIsFull ? "Type a new passphrase to rotate" : "Long random string — write it down somewhere safe"}
className="font-mono mt-1 h-8 text-xs"
/>
</div>
<div>
<Label htmlFor="mgmtPass2" className="text-[11px]">Confirm passphrase</Label>
<Input
id="mgmtPass2"
type="password"
value={pass2}
onChange={(e) => setPass2(e.target.value)}
placeholder="Type it again"
className="font-mono mt-1 h-8 text-xs"
/>
{pass1 && pass2 && pass1 !== pass2 && (
<p className="text-[11px] text-red-400 mt-1">Passphrases don&apos;t match.</p>
)}
</div>
</div>
)}
{err && (
<div className="text-[11px] text-red-500 px-2 py-1.5 rounded-md border border-red-500/30 bg-red-500/10 whitespace-pre-wrap break-words">{err}</div>
)}
<div className="flex justify-end">
<Button
type="button"
size="sm"
onClick={runApply}
disabled={busy || !canApply}
className="!bg-blue-500 hover:!bg-blue-600 !text-white disabled:opacity-50"
>
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : applyLabel}
</Button>
</div>
</div>
2026-07-06 00:30:47 +02:00
<div className="flex flex-wrap gap-1.5">
2026-07-08 12:18:52 +02:00
<Button
type="button"
size="sm"
variant="outline"
className="h-7 text-[11px] !text-emerald-400 border-emerald-500/40 hover:bg-emerald-500/10"
2026-07-08 15:20:38 +02:00
onClick={download}
2026-07-08 12:18:52 +02:00
>
2026-07-08 15:20:38 +02:00
<Download className="h-3.5 w-3.5 mr-1" />
2026-07-08 12:18:52 +02:00
Download keyfile
</Button>
2026-07-06 00:30:47 +02:00
</div>
2026-07-08 15:20:38 +02:00
{/* Deletion lives inside the PBS destination row (PbsKeyfileActions);
setup menus of Create Job / Manual Backup never surface it. */}
2026-07-06 00:30:47 +02:00
</div>
)
}
2026-06-09 00:13:24 +02:00
export function HostBackup() {
2026-06-13 19:05:00 +02:00
const { data: jobsResp, error: jobsErr, mutate: mutateJobs } = useSWR<{ jobs: BackupJob[] }>(
2026-06-09 00:13:24 +02:00
"/api/host-backups/jobs",
fetcher,
{ refreshInterval: 30000 },
)
2026-06-13 19:05:00 +02:00
const [busyJobId, setBusyJobId] = useState<string | null>(null)
const [actionError, setActionError] = useState<string | null>(null)
2026-06-19 23:38:57 +02:00
const [jobToDelete, setJobToDelete] = useState<BackupJob | null>(null)
const [creatingJob, setCreatingJob] = useState<boolean>(false)
const [editingJobId, setEditingJobId] = useState<string | null>(null)
const [viewingJobId, setViewingJobId] = useState<string | null>(null)
2026-06-20 21:59:19 +02:00
const [watchingManualId, setWatchingManualId] = useState<string | null>(null)
2026-06-19 23:38:57 +02:00
const [runningManual, setRunningManual] = useState<boolean>(false)
2026-07-06 12:00:43 +02:00
// 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<string | null>(null)
2026-06-13 19:05:00 +02:00
2026-06-19 23:38:57 +02:00
async function confirmDeleteJob() {
if (!jobToDelete) return
const id = jobToDelete.id
2026-06-13 19:05:00 +02:00
setBusyJobId(id)
setActionError(null)
try {
2026-06-19 23:38:57 +02:00
await fetchApi(`/api/host-backups/jobs/${encodeURIComponent(id)}`, { method: "DELETE" })
2026-06-13 19:05:00 +02:00
mutateJobs()
2026-06-19 23:38:57 +02:00
setJobToDelete(null)
2026-06-13 19:05:00 +02:00
} catch (e) {
2026-06-19 23:38:57 +02:00
setActionError(`Failed to delete "${id}": ${e instanceof Error ? e.message : String(e)}`)
2026-06-13 19:05:00 +02:00
} finally {
setBusyJobId(null)
}
}
2026-06-19 23:38:57 +02:00
const { data: destinationsResp, mutate: mutateDestinations } = useSWR<DestinationsResp>(
"/api/host-backups/destinations",
fetcher,
{ refreshInterval: 60000 },
)
const { data: remoteArchivesResp, error: remoteArchivesErr, mutate: mutateRemoteArchives } = useSWR<RemoteArchivesResp>(
"/api/host-backups/remote-archives",
fetcher,
{ refreshInterval: 60000 },
)
const { data: archivesResp, error: archivesErr, mutate: mutateArchives } = useSWR<{ archives: BackupArchive[] }>(
2026-06-09 00:13:24 +02:00
"/api/host-backups/archives",
fetcher,
{ refreshInterval: 30000 },
)
2026-07-06 12:00:43 +02:00
// 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<JobDetail>(
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])
2026-06-19 23:38:57 +02:00
const [inspectingArchive, setInspectingArchive] = useState<UnifiedArchive | null>(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
})()
2026-06-09 00:13:24 +02:00
return (
<div className="space-y-4 md:space-y-6">
2026-07-05 16:50:06 +02:00
{/* ── 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. */}
<RestoreProgressCard />
2026-06-09 00:13:24 +02:00
{/* ── Scheduled jobs ───────────────────────────────── */}
<Card className="bg-card border-border">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
<div className="flex items-center gap-2">
<Calendar className="h-5 w-5 text-blue-500" />
<CardTitle className="text-base font-semibold">Scheduled Backup Jobs</CardTitle>
2026-06-19 23:38:57 +02:00
<Badge variant="outline" className="ml-1">
{jobsResp?.jobs?.filter((j) => !j.manual).length ?? 0}
</Badge>
2026-06-09 00:13:24 +02:00
</div>
2026-06-19 23:38:57 +02:00
<Button
size="sm"
className="h-8 px-3 bg-blue-500 hover:bg-blue-600 text-white"
onClick={() => setCreatingJob(true)}
>
<Plus className="h-4 w-4 mr-1" />
Create job
</Button>
2026-06-09 00:13:24 +02:00
</CardHeader>
<CardContent>
{jobsErr ? (
<div className="text-sm text-red-500 py-4">Failed to load jobs</div>
) : !jobsResp ? (
<div className="flex items-center gap-2 py-4 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Loading...
</div>
2026-06-19 23:38:57 +02:00
) : jobsResp.jobs.filter((j) => !j.manual).length === 0 ? null : (
<div className="space-y-2">
2026-06-13 19:05:00 +02:00
{actionError && (
<div className="text-xs text-red-500 px-3 py-2 rounded-md border border-red-500/30 bg-red-500/10 whitespace-pre-wrap break-words">
2026-06-13 19:05:00 +02:00
{actionError}
</div>
)}
2026-06-19 23:38:57 +02:00
{jobsResp.jobs.filter((j) => !j.manual).map((j) => {
const status = parseJobStatus(j.last_status)
2026-06-20 21:59:19 +02:00
const running = isJobRunning(j)
// While running we override the ok/failed pill with a
// running spinner — same way the modal does. Clicking
// the row re-opens JobDetailModal which auto-detects
// the in-progress state and resumes streaming.
const statusBadge = running
? { label: "running", cls: "bg-blue-500/10 border-blue-500/40 text-blue-300" }
: status?.result === "ok"
? { label: "ok", cls: "bg-emerald-500/10 border-emerald-500/40 text-emerald-400" }
: status?.result
? { label: status.result, cls: "bg-red-500/10 border-red-500/40 text-red-400" }
: null
2026-06-19 23:38:57 +02:00
const lastRunWhen = formatRunAt(status?.runAt ?? null)
return (
<button
key={j.id}
type="button"
onClick={() => setViewingJobId(j.id)}
className="w-full text-left flex items-start gap-3 p-3 rounded-md border border-border bg-card hover:bg-white/5 transition-colors group"
title="Click to open this job"
>
<div className="min-w-0 flex-1 w-full">
{/* Title row */}
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-base truncate" title={j.id}>{j.id}</span>
<Badge variant="outline" className={`text-[10px] uppercase tracking-wide ${methodBadgeCls(j.method)}`}>
{j.method}
</Badge>
{j.manual && (
<Badge variant="outline" className="text-[10px] text-purple-400 border-purple-400/40 bg-purple-500/5">
manual
</Badge>
)}
2026-06-13 19:05:00 +02:00
{j.attached && (
2026-06-19 23:38:57 +02:00
<Badge variant="outline" className="text-[10px] text-blue-400 border-blue-400/40 bg-blue-500/5">
2026-06-13 19:05:00 +02:00
attached
</Badge>
)}
2026-06-20 21:59:19 +02:00
{j.encrypted && (
<Badge
variant="outline"
className="text-[10px] uppercase tracking-wide border-emerald-500/40 text-emerald-400 bg-emerald-500/5"
title="Encrypted backups (client-side keyfile / borg repokey)"
>
<Lock className="h-3.5 w-3.5" />
</Badge>
)}
2026-06-24 23:40:22 +02:00
{/* Surface the profile mode at row level so the
operator can tell at a glance whether the
job ships the default path set or a custom
list — without having to open the detail
modal. Same wording the modal uses. */}
<Badge
variant="outline"
className={`text-[10px] uppercase tracking-wide ${
j.profile_mode === "custom"
? "border-cyan-500/40 text-cyan-400 bg-cyan-500/5"
: "border-border text-muted-foreground bg-background/40"
}`}
title={
j.profile_mode === "custom"
? "Custom path list — only the paths the operator picked"
: "Default path list — ProxMenux's recommended host config set"
}
>
{j.profile_mode === "custom" ? "custom" : "default"}
</Badge>
2026-06-19 23:38:57 +02:00
{!j.enabled && !j.manual && (
<Badge variant="outline" className="text-[10px] text-amber-500 border-amber-500/40 bg-amber-500/5">
2026-06-13 19:05:00 +02:00
disabled
2026-06-09 00:13:24 +02:00
</Badge>
)}
2026-06-19 23:38:57 +02:00
</div>
{/* Destination */}
<div className="mt-1 text-sm font-mono text-muted-foreground truncate" title={j.destination}>
{j.destination || "—"}
</div>
{/* Stats row — icons in green, values in foreground
white to match the rest of the dashboard. */}
<div className="mt-2 text-xs text-foreground flex items-center gap-x-4 gap-y-1 flex-wrap">
<span className="inline-flex items-center gap-1" title={j.on_calendar}>
<Calendar className="h-3.5 w-3.5 text-green-500" />
<span>{humanizeOnCalendar(j.on_calendar)}</span>
</span>
{j.retention && (
<span className="inline-flex items-center gap-1.5 flex-wrap" title="Retention policy">
<Archive className="h-3.5 w-3.5 text-green-500" />
{(() => {
// Backend returns retention as "last=7, daily=7, …".
// Parse and render as labelled chips so the row
// matches the visual style of the modal.
const pairs = j.retention
.split(",")
.map((s) => s.trim())
.map((s) => {
const m = s.match(/^([a-z]+)=(\d+)$/i)
return m && Number(m[2]) > 0 ? { label: m[1], value: m[2] } : null
})
.filter((x): x is { label: string; value: string } => x !== null)
if (pairs.length === 0) {
return <span>{j.retention}</span>
}
return pairs.map((p) => (
<span
key={p.label}
className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded border border-border bg-background/60"
>
<span className="text-[10px] uppercase tracking-wide text-muted-foreground">{p.label}</span>
<span className="font-mono text-xs text-foreground">{p.value}</span>
</span>
))
})()}
</span>
)}
{!j.attached && j.next_run && (
<span className="inline-flex items-center gap-1" title="Next scheduled run">
<Clock className="h-3.5 w-3.5 text-green-500" />
<span>next: {formatNext(j.next_run)}</span>
</span>
)}
{(statusBadge || lastRunWhen) && (
<span className="inline-flex items-center gap-1" title={status?.logFile ?? ""}>
<Clock className="h-3.5 w-3.5 text-green-500" />
<span>last:</span>
{statusBadge && (
2026-06-20 21:59:19 +02:00
<span className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] uppercase tracking-wide border ${statusBadge.cls}`}>
{running && <Loader2 className="h-2.5 w-2.5 animate-spin" />}
2026-06-19 23:38:57 +02:00
{statusBadge.label}
</span>
2026-06-13 19:05:00 +02:00
)}
2026-06-19 23:38:57 +02:00
{lastRunWhen && <span>{lastRunWhen}</span>}
</span>
)}
{!status && (
<span className="inline-flex items-center gap-1 text-muted-foreground">
<Clock className="h-3.5 w-3.5 text-green-500" />
<span>never run</span>
</span>
)}
</div>
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground group-hover:text-blue-400 transition-colors shrink-0 mt-1" />
</button>
)
})}
2026-06-09 00:13:24 +02:00
</div>
)}
</CardContent>
</Card>
2026-06-19 23:38:57 +02:00
{/* ── Backup configuration (destinations + custom paths) ── */}
<BackupConfigurationCard
destinations={destinationsResp}
onDestChanged={() => mutateDestinations()}
/>
{/* ── Manual backups (entry point) ──────────────────── */}
<Card className="bg-card border-border">
2026-06-20 21:59:19 +02:00
<CardHeader className="pb-3">
2026-06-19 23:38:57 +02:00
<div className="flex items-center gap-2">
<PlayCircle className="h-5 w-5 text-purple-400" />
<CardTitle className="text-base font-semibold">Manual backups</CardTitle>
</div>
2026-06-20 21:59:19 +02:00
</CardHeader>
<CardContent className="space-y-3">
<p className="text-xs text-muted-foreground">
Manual backups run once and stop no schedule.
</p>
2026-06-19 23:38:57 +02:00
<Button
2026-06-20 21:59:19 +02:00
className="w-full sm:w-auto bg-purple-500 hover:bg-purple-600 text-white"
2026-06-19 23:38:57 +02:00
onClick={() => setRunningManual(true)}
>
2026-06-20 21:59:19 +02:00
<PlayCircle className="h-4 w-4 mr-2" />
2026-06-19 23:38:57 +02:00
Run manual backup
</Button>
2026-06-20 21:59:19 +02:00
{/* 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) => (
<button
key={j.id}
type="button"
onClick={() => setWatchingManualId(j.id)}
className="w-full flex items-center gap-2 px-3 py-2 rounded-md border border-blue-500/40 bg-blue-500/5 hover:bg-blue-500/10 transition-colors text-left"
title="Click to re-open the live log"
>
<Loader2 className="h-4 w-4 animate-spin text-blue-400 shrink-0" />
<span className="text-sm min-w-0 flex-1 truncate">
Manual backup in progress <span className="font-mono">{j.id}</span>
</span>
<span className="text-xs text-blue-300 shrink-0">View progress</span>
</button>
))}
2026-06-19 23:38:57 +02:00
</CardContent>
</Card>
2026-06-09 00:13:24 +02:00
{/* ── Available archives ─────────────────────────────── */}
<Card className="bg-card border-border">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
<div className="flex items-center gap-2">
<Archive className="h-5 w-5 text-blue-500" />
<CardTitle className="text-base font-semibold">Available Archives</CardTitle>
</div>
2026-06-19 23:38:57 +02:00
<Badge variant="outline">{unifiedArchives.length}</Badge>
2026-06-09 00:13:24 +02:00
</CardHeader>
<CardContent>
2026-06-19 23:38:57 +02:00
<p className="text-[11px] text-muted-foreground mb-3">
2026-06-24 23:40:22 +02:00
All backups visible from this host local <code className="font-mono">.tar.zst</code> files (PVE default dump dir, configured local target, USB mountpoints, scheduled jobs' destinations) and PBS backups from every configured datastore. Click an entry to inspect, restore or download it — downloads of PBS backups are extracted on-demand only when you request them.
2026-06-19 23:38:57 +02:00
</p>
{remoteArchivesResp?.errors && remoteArchivesResp.errors.length > 0 && (
<div className="text-[11px] text-amber-500 mb-3 px-3 py-2 rounded-md border border-amber-500/30 bg-amber-500/5 space-y-1">
<div className="font-medium">Some remote backends couldn't be queried:</div>
{remoteArchivesResp.errors.map((e, i) => (
<div key={i} className="font-mono break-all">
{e.backend}/{e.repo_name}: {e.error}
</div>
))}
</div>
)}
{archivesErr && remoteArchivesErr ? (
2026-06-09 00:13:24 +02:00
<div className="text-sm text-red-500 py-4">Failed to load archives</div>
2026-06-19 23:38:57 +02:00
) : !archivesResp && !remoteArchivesResp ? (
2026-06-09 00:13:24 +02:00
<div className="flex items-center gap-2 py-4 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Loading...
</div>
2026-06-19 23:38:57 +02:00
) : unifiedArchives.length === 0 ? (
2026-06-09 00:13:24 +02:00
<div className="text-sm text-muted-foreground py-4">
2026-06-24 23:40:22 +02:00
No backups found yet. Use <span className="font-medium">Run manual backup</span> above, configure a scheduled job, or check that the configured PBS / Borg destinations have backups.
2026-06-09 00:13:24 +02:00
</div>
) : (
<div className="space-y-2">
2026-06-19 23:38:57 +02:00
{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
2026-06-21 00:35:22 +02:00
// Same palette as the DestinationRow accents so each
// backend reads identically across the two cards.
2026-06-19 23:38:57 +02:00
const sourceBadgeCls =
u.source === "pbs"
2026-06-21 00:35:22 +02:00
? "text-purple-400 border-purple-500/20 bg-purple-500/10"
2026-06-19 23:38:57 +02:00
: u.source === "borg"
2026-06-21 00:35:22 +02:00
? "text-fuchsia-400 border-fuchsia-500/20 bg-fuchsia-500/10"
: "text-blue-400 border-blue-500/20 bg-blue-500/10"
2026-06-19 23:38:57 +02:00
return (
<button
key={`${u.source}:${u.display_id}`}
type="button"
onClick={() => setInspectingArchive(u)}
className="w-full text-left flex items-center justify-between gap-3 p-3 rounded-md border border-border bg-background/40 hover:bg-white/5 hover:border-blue-500/40 transition-colors group"
title="Click to inspect, restore or download this backup"
2026-06-09 00:13:24 +02:00
>
2026-06-19 23:38:57 +02:00
<div className="min-w-0 flex-1">
<div className="font-mono text-xs truncate group-hover:text-blue-400 transition-colors" title={localPath || u.display_id}>
{u.display_id}
</div>
<div className="text-[11px] text-muted-foreground mt-0.5 flex items-center gap-3 flex-wrap">
<span className={`uppercase tracking-wide text-[10px] px-1.5 py-0.5 rounded border ${sourceBadgeCls}`}>
{u.source}
</span>
2026-06-20 21:59:19 +02:00
{u.remote?.encrypted && (
<span
className="uppercase tracking-wide text-[10px] px-1.5 py-0.5 rounded border border-emerald-500/40 text-emerald-400 bg-emerald-500/5 inline-flex items-center"
title="Encrypted backup"
>
<Lock className="h-3.5 w-3.5" />
</span>
)}
2026-06-19 23:38:57 +02:00
<span className="inline-flex items-center gap-1">
<Clock className="h-3 w-3" />
{formatMtime(u.created_at)}
</span>
<span className="inline-flex items-center gap-1">
<HardDrive className="h-3 w-3" />
2026-06-21 00:35:22 +02:00
{formatBytes(u.size_bytes)}
2026-06-19 23:38:57 +02:00
</span>
<span title={u.source_label}>
at: <code className="font-mono">{u.source_label}</code>
</span>
{u.source === "local" && localKind === "scheduled" && localJobId ? (
<span>job: <code className="font-mono">{localJobId}</code></span>
) : u.source === "local" && localKind === "legacy" ? (
<span className="uppercase tracking-wide text-[10px] px-1.5 py-0.5 rounded bg-amber-500/10 border border-amber-500/40 text-amber-400">
legacy
</span>
) : u.source === "local" && localKind === "manual" ? (
<span className="uppercase tracking-wide text-[10px] px-1.5 py-0.5 rounded bg-white/5 border border-border">
manual
</span>
) : null}
{(u.source === "pbs" || u.source === "borg") && u.remote?.backup_id && (
<span>{u.source === "pbs" ? "group" : "archive"}: <code className="font-mono">{u.remote.backup_id}</code></span>
)}
{u.source === "local" && localHost && (
<span>host: <code className="font-mono">{localHost}</code></span>
)}
</div>
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground group-hover:text-blue-400 transition-colors shrink-0" />
</button>
)
})}
2026-06-09 00:13:24 +02:00
</div>
)}
</CardContent>
</Card>
{/* ── Inspect / preflight modal ──────────────────────── */}
<InspectModal
archive={inspectingArchive}
onClose={() => setInspectingArchive(null)}
2026-06-19 23:38:57 +02:00
onDeleted={() => {
setInspectingArchive(null)
2026-06-22 00:55:34 +02:00
// Refresh both lists — a deleted item might have been local
// or remote (PBS/Borg). Cheap enough to revalidate both.
2026-06-19 23:38:57 +02:00
mutateArchives()
2026-06-22 00:55:34 +02:00
mutateRemoteArchives()
2026-06-19 23:38:57 +02:00
}}
2026-06-09 00:13:24 +02:00
/>
2026-06-19 23:38:57 +02:00
{/* ── Create / Edit job wizard ─────────────────────── */}
<CreateJobDialog
open={creatingJob}
onClose={() => setCreatingJob(false)}
onCreated={() => {
setCreatingJob(false)
mutateJobs()
}}
/>
<CreateJobDialog
open={editingJobId !== null}
editingJobId={editingJobId}
onClose={() => setEditingJobId(null)}
onCreated={() => {
setEditingJobId(null)
mutateJobs()
}}
/>
{/* ── One-shot manual backup ─────────────────────── */}
<ManualBackupDialog
open={runningManual}
onClose={() => setRunningManual(false)}
2026-06-20 21:59:19 +02:00
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.
2026-06-19 23:38:57 +02:00
setRunningManual(false)
mutateJobs()
2026-06-20 21:59:19 +02:00
setWatchingManualId(jobId)
2026-07-06 12:00:43 +02:00
// 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)
2026-06-19 23:38:57 +02:00
}}
/>
{/* ── Job detail / actions modal ─────────────────────────── */}
<JobDetailModal
jobId={viewingJobId}
onClose={() => 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)
}
}}
2026-06-20 21:59:19 +02:00
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). */}
<ManualJobWatchModal
jobId={watchingManualId}
onClose={() => 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()
}}
2026-06-19 23:38:57 +02:00
/>
{/* ── Delete confirmation ────────────────────────────── */}
<Dialog open={jobToDelete !== null} onOpenChange={(v) => { if (!v) setJobToDelete(null) }}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-amber-500" />
Delete backup job?
</DialogTitle>
<DialogDescription>
This action cannot be undone.
</DialogDescription>
</DialogHeader>
{jobToDelete && (
<div className="space-y-3 text-sm">
<div className="rounded-md border border-border bg-background/50 p-3 space-y-1">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground">Job ID</div>
<div className="font-mono text-sm">{jobToDelete.id}</div>
{jobToDelete.attached && jobToDelete.pve_storage && (
<>
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mt-2">Type</div>
<div className="text-xs">attached to PVE storage <span className="font-mono">{jobToDelete.pve_storage}</span></div>
</>
)}
</div>
{jobToDelete.attached ? (
<p className="text-xs text-muted-foreground">
Only the ProxMenux host backup hook is removed.
PVE vzdump jobs targeting this storage stay intact and keep running.
</p>
) : (
<p className="text-xs text-muted-foreground">
The systemd timer and service for this job will be stopped, disabled and removed.
Existing backup archives on disk are NOT deleted.
</p>
)}
</div>
)}
<div className="flex justify-end gap-2 mt-2">
<Button
variant="ghost"
onClick={() => setJobToDelete(null)}
disabled={busyJobId === jobToDelete?.id}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={confirmDeleteJob}
disabled={busyJobId === jobToDelete?.id}
>
{busyJobId === jobToDelete?.id ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : (
<Trash2 className="h-4 w-4 mr-2" />
)}
Delete
</Button>
</div>
</DialogContent>
</Dialog>
2026-06-09 00:13:24 +02:00
</div>
)
}
// ──────────────────────────────────────────────────────────────
// 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,
2026-06-19 23:38:57 +02:00
onDeleted,
2026-06-09 00:13:24 +02:00
}: {
2026-06-19 23:38:57 +02:00
archive: UnifiedArchive | null
2026-06-09 00:13:24 +02:00
onClose: () => void
2026-06-19 23:38:57 +02:00
onDeleted?: () => void
2026-06-09 00:13:24 +02:00
}) {
const open = archive !== null
2026-06-19 23:38:57 +02:00
// Aliases to the source-specific payloads — saves on `.local!` /
// `.remote!` repetition later. PBS and Borg share the same shape.
const localArc = archive?.source === "local" ? archive.local : undefined
const remoteArc = archive && archive.source !== "local" ? archive.remote : undefined
const isRemote = archive?.source === "pbs" || archive?.source === "borg"
const backendLabel = archive?.source === "pbs" ? "PBS" : archive?.source === "borg" ? "Borg" : "Local"
2026-06-09 00:13:24 +02:00
const [mode, setMode] = useState<string>("full")
const [report, setReport] = useState<PreflightReport | null>(null)
const [running, setRunning] = useState(false)
const [error, setError] = useState<string | null>(null)
2026-06-19 23:38:57 +02:00
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 `<stem>.log` next to the `<stem>.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)
2026-06-21 00:35:22 +02:00
const [viewingContents, setViewingContents] = useState(false)
2026-06-21 22:41:54 +02:00
// 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<string | null>(null)
const [restoreOptions, setRestoreOptions] = useState<{
stagingPath: string
pathsAvailable: string[]
rollbackPlan?: RollbackPlan
2026-07-03 18:59:45 +02:00
// 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[]
}
2026-06-21 22:41:54 +02:00
} | null>(null)
const [restoreTerminal, setRestoreTerminal] = useState<{
stagingPath: string
mode: "full" | "custom"
paths: string[]
2026-06-21 23:49:41 +02:00
rollbackExecute?: boolean
2026-06-21 22:41:54 +02:00
} | null>(null)
2026-07-08 12:18:52 +02:00
// ── 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)
2026-07-14 19:22:33 +02:00
// 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/<NAME>.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
2026-07-08 12:18:52 +02:00
const [importFile, setImportFile] = useState<File | null>(null)
const [importPath, setImportPath] = useState<string>("")
const [importing, setImporting] = useState(false)
const [importError, setImportError] = useState<string | null>(null)
const runImportKeyfile = async () => {
// Either a file upload OR a typed absolute path on this host —
// both land at /usr/local/share/proxmenux/pbs-key.conf with
// escrow_mode='none'. When both are provided the file wins.
if (!importFile && !importPath.trim()) {
setImportError("Pick a keyfile file or enter an absolute path on this host.")
return
}
setImporting(true)
setImportError(null)
try {
const body: Record<string, unknown> = { 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)
}
}
2026-06-21 22:41:54 +02:00
const beginRestore = async () => {
if (!archive) return
setRestoreError(null)
setRestorePreparing(true)
const body: Record<string, string> = { source: archive.source }
if (archive.source === "local") {
if (!localArc?.path) { setRestoreError("Local archive path missing"); setRestorePreparing(false); return }
body.path = localArc.path
} else if (remoteArc) {
body.repo_name = remoteArc.repo_name
body.snapshot = remoteArc.snapshot
} else {
setRestoreError("Snapshot info missing")
setRestorePreparing(false)
return
}
try {
const r: any = await fetchApi("/api/host-backups/restore/prepare", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
if (!r?.staging_path) throw new Error("backend did not return a staging path")
2026-07-02 23:52:04 +02:00
const ck = r.cross_kernel || {}
2026-07-03 18:59:45 +02:00
const hyd = r.hydration || {}
2026-06-21 22:41:54 +02:00
setRestoreOptions({
stagingPath: r.staging_path,
pathsAvailable: Array.isArray(r.paths_available) ? r.paths_available : [],
rollbackPlan: r.rollback_plan,
2026-07-03 18:59:45 +02:00
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,
2026-06-21 22:41:54 +02:00
})
} catch (e) {
setRestoreError(e instanceof Error ? e.message : String(e))
} finally {
setRestorePreparing(false)
}
}
2026-06-19 23:38:57 +02:00
const [deletingArchive, setDeletingArchive] = useState(false)
const [archiveDeleteResult, setArchiveDeleteResult] = useState<string[] | null>(null)
2026-06-21 22:41:54 +02:00
// Local download. Uses a single-use ticket appended to the URL +
// `<a download>` 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.
2026-06-19 23:38:57 +02:00
const downloadLocalArchive = async () => {
if (!localArc) return
setDownloading(true)
setError(null)
try {
2026-06-21 22:41:54 +02:00
const ticket = await fetchTerminalTicket()
let url = getApiUrl(`/api/host-backups/archives/${encodeURIComponent(localArc.id)}/download`)
if (ticket) url += `?ticket=${encodeURIComponent(ticket)}`
2026-06-19 23:38:57 +02:00
const a = document.createElement("a")
a.href = url
a.download = localArc.id
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
} catch (e) {
setError(`Download failed: ${e instanceof Error ? e.message : String(e)}`)
} finally {
2026-06-21 22:41:54 +02:00
// The download itself runs in the browser's network stack;
// we just initiated it. Clear the spinner immediately.
2026-06-19 23:38:57 +02:00
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<ExportTask | null>(null)
const downloadRemoteArchive = async () => {
if (!remoteArc) return
setDownloading(true)
setError(null)
setExportTask({
task_id: "",
backend: remoteArc.backend,
repo_name: remoteArc.repo_name,
snapshot: remoteArc.snapshot,
state: "queued",
message: "Starting export…",
size_bytes: 0,
output_path: null,
error: null,
})
try {
const started = await fetchApi<{ task_id: string; state: string }>(
"/api/host-backups/remote-archives/export",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
backend: remoteArc.backend,
repo_name: remoteArc.repo_name,
snapshot: remoteArc.snapshot,
}),
},
)
// Poll until done. The export can take from seconds (small host
// config) to minutes (multi-GB pxar) — poll every 1.5s so the
// UI feels responsive without DoS-ing Flask.
let task: ExportTask | null = null
while (true) {
await new Promise((res) => setTimeout(res, 1500))
task = await fetchApi<ExportTask>(
`/api/host-backups/remote-archives/export/${encodeURIComponent(started.task_id)}`,
)
setExportTask(task)
if (task.state === "completed" || task.state === "failed") break
}
if (!task || task.state !== "completed") {
throw new Error(task?.error || "export did not complete")
}
2026-06-21 22:41:54 +02:00
// Stream the resulting .tar.zst with a ticketed URL + <a download>.
// 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, "_")
2026-06-19 23:38:57 +02:00
const a = document.createElement("a")
a.href = url
a.download = `${remoteArc.backend}-${remoteArc.repo_name}-${safeSnap}.tar.zst`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
setExportTask(null)
} catch (e) {
setError(`Download failed: ${e instanceof Error ? e.message : String(e)}`)
} finally {
setDownloading(false)
}
}
const downloadArchive = () => {
if (isRemote) return downloadRemoteArchive()
return downloadLocalArchive()
}
2026-06-22 00:55:34 +02:00
// Archive deletion — works for local, PBS and Borg backends.
// • local: DELETE /archives/<id> → removes .tar.zst + sidecar + run log.
// • pbs: DELETE /remote-archives → `proxmox-backup-client snapshot forget`.
// • borg: DELETE /remote-archives → `borg delete <repo>::<archive>`.
// 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
2026-06-19 23:38:57 +02:00
setDeletingArchive(true)
setError(null)
try {
2026-06-22 00:55:34 +02:00
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)
2026-06-19 23:38:57 +02:00
setShowDeleteArchiveConfirm(false)
if (onDeleted) onDeleted(); else onClose()
} catch (e) {
setError(`Failed to delete: ${e instanceof Error ? e.message : String(e)}`)
} finally {
setDeletingArchive(false)
}
}
// Reset instructions when archive or mode changes — the previous
// command is no longer relevant.
useEffect(() => {
setInstructions(null)
}, [archive, mode])
const fetchInstructions = async () => {
if (!localArc) return
setFetchingInstructions(true)
setError(null)
try {
const res = await fetchApi<typeof instructions extends infer T ? T : never>(
`/api/host-backups/archives/${encodeURIComponent(localArc.id)}/prepare-restore`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mode }),
}
) as NonNullable<typeof instructions>
setInstructions(res)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setFetchingInstructions(false)
}
}
2026-06-09 00:13:24 +02:00
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[] } }
}>(
2026-06-19 23:38:57 +02:00
localArc ? `/api/host-backups/archives/${encodeURIComponent(localArc.id)}/manifest` : null,
2026-06-09 00:13:24 +02:00
fetcher,
)
const runPreflight = async () => {
2026-06-19 23:38:57 +02:00
if (!localArc) return
2026-06-09 00:13:24 +02:00
setRunning(true)
setError(null)
setReport(null)
try {
const res = await fetchApi<PreflightReport>(
2026-06-19 23:38:57 +02:00
`/api/host-backups/archives/${encodeURIComponent(localArc.id)}/preflight`,
2026-06-09 00:13:24 +02:00
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mode }),
},
)
setReport(res)
} catch (e: any) {
setError(e?.message || "Preflight failed")
} finally {
setRunning(false)
}
}
2026-06-19 23:38:57 +02:00
// 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}` : ""
2026-06-09 00:13:24 +02:00
return (
2026-06-19 23:38:57 +02:00
<>
2026-06-09 00:13:24 +02:00
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose() }}>
2026-06-21 22:41:54 +02:00
<DialogContent key={archiveKey} className="max-w-3xl bg-card border-border p-0 flex flex-col gap-0 max-h-[90vh]">
<DialogHeader className="px-4 sm:px-6 pt-4 pb-3 shrink-0 border-b border-border">
2026-06-22 11:38:06 +02:00
<DialogTitle className="flex items-center gap-2 flex-wrap text-base pr-8">
2026-06-21 22:41:54 +02:00
<DatabaseBackup className="h-5 w-5 text-blue-500 shrink-0" />
<span className="font-mono text-xs sm:text-sm break-all flex-1 min-w-0">{archive?.display_id}</span>
2026-06-09 00:13:24 +02:00
</DialogTitle>
</DialogHeader>
2026-06-21 22:41:54 +02:00
{/* ── Body: same shape for the 3 backends ─────────────── */}
<ScrollArea className="flex-1 min-h-0">
<div className="px-4 sm:px-6 py-4 space-y-4">
2026-06-22 11:38:06 +02:00
{/* 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). */}
2026-06-21 22:41:54 +02:00
<section className="rounded-md border border-border bg-background/40 p-3 space-y-1 text-xs">
2026-06-22 11:38:06 +02:00
<div className="flex items-center justify-between gap-2 flex-wrap">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground">Backup</div>
<div className="flex items-center gap-1.5">
{archive && (
<Badge variant="outline" className={`text-[10px] uppercase tracking-wide ${
archive.source === "pbs"
? "text-purple-400 border-purple-500/40 bg-purple-500/10"
: archive.source === "borg"
? "text-fuchsia-400 border-fuchsia-500/40 bg-fuchsia-500/10"
: "text-blue-400 border-blue-500/40 bg-blue-500/10"
}`}>
{archive.source}
</Badge>
)}
{remoteArc?.encrypted && (
<Badge
variant="outline"
className="text-emerald-400 border-emerald-500/40 bg-emerald-500/10"
title="Encrypted"
>
<Lock className="h-3.5 w-3.5" />
</Badge>
)}
</div>
</div>
2026-06-19 23:38:57 +02:00
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-1">
2026-06-21 22:41:54 +02:00
{/* Time + size — present for every backend. */}
{archive && (archive.source === "pbs" || archive.source === "borg") && remoteArc ? (
<>
2026-06-22 11:38:06 +02:00
<div><span className="text-muted-foreground">Backup time:</span> {formatMtime(remoteArc.backup_time)}</div>
2026-06-21 22:41:54 +02:00
{remoteArc.size_bytes > 0 && <div><span className="text-muted-foreground">Size:</span> {formatBytes(remoteArc.size_bytes)}</div>}
<div><span className="text-muted-foreground">Repository:</span> <code className="font-mono break-all">{remoteArc.repo_repository}</code></div>
<div><span className="text-muted-foreground">Repo name:</span> <code className="font-mono">{remoteArc.repo_name}</code></div>
<div>
<span className="text-muted-foreground">{remoteArc.backend === "pbs" ? "Backup group:" : "Archive name:"}</span>{" "}
<code className="font-mono break-all">{remoteArc.backend === "pbs" ? `${remoteArc.backup_type}/${remoteArc.backup_id}` : remoteArc.backup_id}</code>
</div>
{remoteArc.owner && <div><span className="text-muted-foreground">Owner:</span> <code className="font-mono">{remoteArc.owner}</code></div>}
{remoteArc.borg_id && <div className="sm:col-span-2"><span className="text-muted-foreground">Borg id:</span> <code className="font-mono text-[10px] break-all">{remoteArc.borg_id}</code></div>}
</>
) : localArc ? (
<>
<div><span className="text-muted-foreground">Created:</span> {formatMtime(localArc.mtime)}</div>
<div><span className="text-muted-foreground">Size:</span> {formatBytes(localArc.size_bytes)}</div>
<div className="sm:col-span-2"><span className="text-muted-foreground">Path:</span> <code className="font-mono break-all">{localArc.path}</code></div>
{localArc.job_id && <div><span className="text-muted-foreground">Job id:</span> <code className="font-mono">{localArc.job_id}</code></div>}
{localArc.profile && <div><span className="text-muted-foreground">Profile:</span> <code className="font-mono">{localArc.profile}</code></div>}
{localArc.source_hostname && <div><span className="text-muted-foreground">Source host:</span> <code className="font-mono">{localArc.source_hostname}</code></div>}
<div><span className="text-muted-foreground">Detected via:</span> <code className="font-mono text-[10px]">{localArc.detected_via}</code></div>
</>
) : null}
2026-06-19 23:38:57 +02:00
</div>
2026-06-21 22:41:54 +02:00
{/* PBS pxar files list — only PBS exposes this. */}
{remoteArc?.files && remoteArc.files.length > 0 && (
<div className="pt-2 mt-2 border-t border-border/50">
2026-06-22 11:38:06 +02:00
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">Files in this backup</div>
2026-06-19 23:38:57 +02:00
<ul className="space-y-0.5">
{remoteArc.files.map((f) => (
<li key={f.filename} className="font-mono text-[11px] flex items-center justify-between gap-2">
<span>{f.filename}</span>
2026-06-21 22:41:54 +02:00
<span className="text-muted-foreground">{formatBytes(f.size)}</span>
2026-06-19 23:38:57 +02:00
</li>
))}
</ul>
</div>
)}
2026-06-21 22:41:54 +02:00
</section>
2026-06-19 23:38:57 +02:00
2026-06-21 22:41:54 +02:00
{/* Run log — only Local has a co-located <stem>.log. */}
{archive?.source === "local" && archiveLog && archiveLog.log_path && archiveLog.tail.length > 0 && (
<section className="space-y-2">
<h4 className="text-xs font-semibold uppercase tracking-wide flex items-center gap-1.5 text-green-500">
<FileText className="h-3.5 w-3.5" /> Run log
</h4>
<div className="rounded-md border border-border bg-background/60 p-2">
<pre className="text-[11px] font-mono whitespace-pre-wrap break-all max-h-48 overflow-auto text-foreground/90">
2026-06-19 23:38:57 +02:00
{archiveLog.tail.join("\n")}
2026-06-21 22:41:54 +02:00
</pre>
<div className="flex items-center justify-between mt-2 gap-2 flex-wrap">
<span className="text-[10px] text-muted-foreground inline-flex items-center gap-2 min-w-0">
<span>tail · {formatBytes(archiveLog.size)}</span>
<span className="font-mono break-all">{archiveLog.log_path}</span>
</span>
<Button size="sm" variant="ghost" className="h-7 text-xs shrink-0" onClick={() => setShowArchiveFullLog(true)}>
<FileText className="h-3.5 w-3.5 mr-1" />
Open full log
</Button>
</div>
</div>
</section>
)}
2026-06-19 23:38:57 +02:00
2026-06-21 22:41:54 +02:00
{/* In-flight export task feedback (Download for PBS/Borg). */}
{exportTask && (
<div className="text-[11px] space-y-1 px-3 py-2 rounded-md border border-border bg-background/40">
<div className="flex items-center gap-2">
<Loader2 className={`h-3.5 w-3.5 ${exportTask.state === "completed" || exportTask.state === "failed" ? "" : "animate-spin"}`} />
<span className="font-medium capitalize">{exportTask.state}</span>
<span className="text-muted-foreground"> {exportTask.message}</span>
</div>
{exportTask.state === "failed" && exportTask.error && (
<div className="text-red-500 mt-1">{exportTask.error}</div>
)}
{exportTask.state === "completed" && exportTask.size_bytes > 0 && (
<div className="text-emerald-400">Packed size: {formatBytes(exportTask.size_bytes)}</div>
)}
</div>
)}
2026-07-08 19:17:13 +02:00
{error && (() => {
const kf = parseKeyfileError(error)
return kf ? (
<KeyfileErrorBlock err={kf} />
) : (
<div className="text-xs text-red-500 px-3 py-2 rounded-md border border-red-500/30 bg-red-500/10 whitespace-pre-wrap break-words">
{error}
</div>
)
})()}
2026-07-08 12:18:52 +02:00
{/* 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 && (
<section className="rounded-md border border-amber-500/40 bg-amber-500/5 p-3 space-y-2 text-xs">
<div className="flex items-start gap-2">
<AlertTriangle className="h-4 w-4 text-amber-400 mt-0.5 shrink-0" />
<div className="flex-1 space-y-1">
<div className="font-semibold text-amber-300">Encrypted backup keyfile required</div>
<div className="text-muted-foreground leading-relaxed">
This snapshot is encrypted but no local keyfile is installed at
{" "}<code className="font-mono text-[10.5px]">/usr/local/share/proxmenux/pbs-key.conf</code>.
Import the keyfile that was used at backup time to continue.
</div>
</div>
</div>
2026-07-14 19:22:33 +02:00
{pveMatchInspect && (
<div className="rounded-md border border-emerald-500/40 bg-emerald-500/10 p-2.5 space-y-1.5 text-[11px]">
<div className="flex items-start gap-2">
<Lock className="h-3.5 w-3.5 text-emerald-400 mt-0.5 shrink-0" />
<div className="flex-1 space-y-1">
<div className="font-semibold text-emerald-300">PVE-managed keyfile detected for this PBS</div>
<div className="text-muted-foreground">
Proxmox stores an encryption key for storage <code className="font-mono text-[10.5px]">{pveMatchInspect.name}</code> at{" "}
<code className="font-mono text-[10.5px] break-all">{pveMatchInspect.path}</code>. Import it in one click.
</div>
</div>
</div>
<div className="flex justify-end">
<Button
size="sm"
onClick={() => {
setImportPath(pveMatchInspect.path)
setImportFile(null)
}}
disabled={importing}
className="!bg-emerald-600 hover:!bg-emerald-700 !text-white h-6 text-[10.5px] px-2"
>
Use this key
</Button>
</div>
</div>
)}
2026-07-08 12:18:52 +02:00
<div className="space-y-1.5 pt-1 border-t border-amber-500/30">
2026-07-14 19:22:33 +02:00
<Label htmlFor="keyfileImport" className="text-[11px] font-medium">
{pveMatchInspect ? "Or upload from your machine" : "Upload from your machine"}
</Label>
2026-07-08 12:18:52 +02:00
<Input
id="keyfileImport"
type="file"
accept=".conf,.key,application/json,text/plain"
onChange={(e) => setImportFile(e.target.files?.[0] ?? null)}
disabled={importing || !!importPath.trim()}
className="h-8 text-[11px]"
/>
</div>
<div className="text-[10px] text-center text-muted-foreground"> or </div>
<div className="space-y-1.5">
<Label htmlFor="keyfileImportPath" className="text-[11px] font-medium">Absolute path on this host</Label>
<Input
id="keyfileImportPath"
type="text"
value={importPath}
onChange={(e) => setImportPath(e.target.value)}
disabled={importing || !!importFile}
2026-07-14 19:22:33 +02:00
placeholder="e.g. /etc/pve/priv/storage/<NAME>.enc or /root/my-pbs-key"
2026-07-08 12:18:52 +02:00
className="h-8 text-[11px] font-mono"
/>
</div>
{importError && (
<div className="text-red-500 px-2 py-1.5 rounded border border-red-500/30 bg-red-500/10 whitespace-pre-wrap break-words">
{importError}
</div>
)}
<div className="flex justify-end">
<Button
size="sm"
onClick={runImportKeyfile}
disabled={importing || (!importFile && !importPath.trim())}
className="bg-amber-500/20 border border-amber-500/40 text-amber-300 hover:bg-amber-500/30"
variant="outline"
>
{importing ? (
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
) : (
<Lock className="h-3.5 w-3.5 mr-1.5" />
)}
Import keyfile
</Button>
</div>
</section>
)}
2026-06-21 22:41:54 +02:00
</div>
</ScrollArea>
{/* ── 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. */}
<div className="px-4 sm:px-6 py-3 border-t border-border shrink-0 flex items-center justify-between gap-2">
<div className="flex items-center gap-2 flex-wrap">
<Button
onClick={beginRestore}
2026-07-08 12:18:52 +02:00
disabled={restorePreparing || needsKeyfile}
className="bg-green-600 hover:bg-green-700 text-white disabled:opacity-50"
title={needsKeyfile ? "Import the encryption keyfile above to enable Restore" : "Restore this snapshot to the current host (Complete or Custom by paths)"}
2026-06-21 22:41:54 +02:00
>
{restorePreparing ? (
<Loader2 className="h-4 w-4 sm:mr-2 animate-spin" />
) : (
<DatabaseBackup className="h-4 w-4 sm:mr-2" />
)}
<span>Restore</span>
</Button>
2026-06-19 23:38:57 +02:00
<Button
onClick={downloadArchive}
2026-07-08 12:18:52 +02:00
disabled={downloading || needsKeyfile}
className="bg-blue-500/10 border border-blue-500/40 text-blue-400 hover:bg-blue-500/20 hover:text-blue-300 disabled:opacity-50"
2026-06-21 22:41:54 +02:00
variant="outline"
2026-07-08 12:18:52 +02:00
title={needsKeyfile ? "Import the encryption keyfile above to enable Download" : "Download the snapshot as a .tar.zst"}
2026-06-19 23:38:57 +02:00
>
{downloading ? (
2026-06-21 22:41:54 +02:00
<Loader2 className="h-4 w-4 sm:mr-2 animate-spin" />
2026-06-09 00:13:24 +02:00
) : (
2026-06-21 22:41:54 +02:00
<Download className="h-4 w-4 sm:mr-2" />
2026-06-09 00:13:24 +02:00
)}
2026-06-21 22:41:54 +02:00
<span className="hidden sm:inline">Download</span>
2026-06-19 23:38:57 +02:00
</Button>
<Button
2026-06-21 00:35:22 +02:00
onClick={() => setViewingContents(true)}
2026-07-08 12:18:52 +02:00
disabled={needsKeyfile}
className="bg-blue-500/10 border border-blue-500/40 text-blue-400 hover:bg-blue-500/20 hover:text-blue-300 disabled:opacity-50"
2026-06-19 23:38:57 +02:00
variant="outline"
2026-07-08 12:18:52 +02:00
title={needsKeyfile ? "Import the encryption keyfile above to enable View contents" : "Extract + show manifest, plan, files, metadata as HTML"}
2026-06-19 23:38:57 +02:00
>
2026-06-21 22:41:54 +02:00
<Eye className="h-4 w-4 sm:mr-2" />
<span className="hidden sm:inline">View contents</span>
2026-06-09 00:13:24 +02:00
</Button>
</div>
2026-06-22 00:55:34 +02:00
<Button
onClick={() => setShowDeleteArchiveConfirm(true)}
disabled={deletingArchive}
className="bg-red-500/10 border border-red-500/40 text-red-400 hover:bg-red-500/20 hover:text-red-300"
variant="outline"
title={
archive?.source === "local"
? "Permanently delete this archive (.tar.zst + sidecar + log)"
: archive?.source === "pbs"
? "Permanently forget this PBS snapshot (proxmox-backup-client snapshot forget)"
: "Permanently delete this Borg archive (borg delete)"
}
>
{deletingArchive ? (
<Loader2 className="h-4 w-4 sm:mr-2 animate-spin" />
) : (
<Trash2 className="h-4 w-4 sm:mr-2" />
)}
<span className="hidden sm:inline">Delete</span>
</Button>
2026-06-19 23:38:57 +02:00
</div>
</DialogContent>
</Dialog>
2026-06-21 00:35:22 +02:00
{/* ── View contents — extract + parse the snapshot, show HTML ──── */}
<ArchiveContentsModal
open={viewingContents}
onClose={() => 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}
/>
2026-06-21 22:41:54 +02:00
{/* ── Restore error toast (prepare failed) ─────────────────── */}
2026-07-08 19:17:13 +02:00
{restoreError && (() => {
const kf = parseKeyfileError(restoreError)
return (
<Dialog open={true} onOpenChange={() => setRestoreError(null)}>
<DialogContent className="max-w-md bg-card border-border">
<DialogHeader>
<DialogTitle className="text-base flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-red-500" />
{kf ? "Restore blocked — encrypted backup" : "Restore preparation failed"}
</DialogTitle>
{!kf && (
<DialogDescription className="text-xs text-red-400 break-all">
{restoreError}
</DialogDescription>
)}
</DialogHeader>
{kf && <KeyfileErrorBlock err={kf} />}
<div className="flex justify-end">
<Button variant="ghost" onClick={() => setRestoreError(null)}>Close</Button>
</div>
</DialogContent>
</Dialog>
)
})()}
2026-06-21 22:41:54 +02:00
{/* ── Restore options modal: opens only AFTER the prepare step,
so the Custom checklist already knows what's inside. ──── */}
<RestoreOptionsModal
open={restoreOptions !== null}
stagingPath={restoreOptions?.stagingPath ?? ""}
pathsAvailable={restoreOptions?.pathsAvailable ?? []}
rollbackPlan={restoreOptions?.rollbackPlan}
2026-07-03 18:59:45 +02:00
crossKernel={restoreOptions?.crossKernel}
hydration={restoreOptions?.hydration}
2026-06-21 22:41:54 +02:00
display_id={archive?.display_id}
onClose={() => {
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)
}
}}
2026-06-21 23:49:41 +02:00
onLaunch={(mode, paths, rollbackExecute) => {
2026-06-21 22:41:54 +02:00
if (!restoreOptions) return
const sp = restoreOptions.stagingPath
setRestoreOptions(null)
2026-06-21 23:49:41 +02:00
setRestoreTerminal({ stagingPath: sp, mode, paths, rollbackExecute })
2026-06-21 22:41:54 +02:00
}}
/>
{/* ── 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 && (
<ScriptTerminalModal
key={restoreTerminal.stagingPath}
open={true}
onClose={() => {
const sp = restoreTerminal.stagingPath
fetchApi("/api/host-backups/restore/cleanup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ staging_path: sp }),
}).catch(() => undefined)
setRestoreTerminal(null)
}}
// Auto-dismiss when the script's PTY closes (i.e. the bash
// wrapper exited cleanly — the operator pressed Enter at the
// "Press Enter to close" prompt). Without this the operator
// would have to click Close manually after each restore.
onComplete={() => {
const sp = restoreTerminal.stagingPath
fetchApi("/api/host-backups/restore/cleanup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ staging_path: sp }),
}).catch(() => undefined)
setRestoreTerminal(null)
}}
scriptPath="/usr/local/share/proxmenux/scripts/backup_restore/restore/monitor_apply.sh"
scriptName="monitor_apply"
title={`Restore — ${restoreTerminal.mode === "full" ? "Complete" : "Custom by paths"}`}
description={
restoreTerminal.mode === "custom"
? `${restoreTerminal.paths.length} path(s) selected`
: "Complete restore — applies the whole backup"
}
params={{
EXECUTION_MODE: "web",
STAGING: restoreTerminal.stagingPath,
MODE: restoreTerminal.mode,
...(restoreTerminal.mode === "custom" && restoreTerminal.paths.length > 0
? { PATHS: restoreTerminal.paths.join(",") }
: {}),
2026-06-21 23:49:41 +02:00
...(restoreTerminal.rollbackExecute ? { ROLLBACK_EXECUTE: "1" } : {}),
2026-06-21 22:41:54 +02:00
}}
/>
)}
2026-06-19 23:38:57 +02:00
{/* ── Confirm archive deletion ───────────────────────────── */}
<Dialog open={showDeleteArchiveConfirm} onOpenChange={setShowDeleteArchiveConfirm}>
<DialogContent className="max-w-md bg-card border-border">
<DialogHeader>
<DialogTitle className="text-base flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-red-500" />
2026-06-22 00:55:34 +02:00
Delete {backendLabel} backup
2026-06-19 23:38:57 +02:00
</DialogTitle>
<DialogDescription className="text-xs">
2026-06-22 00:55:34 +02:00
{archive?.source === "local"
? "Removes the archive, its sidecar JSON and the matching run log. The action is permanent — restore needs an off-host copy."
: archive?.source === "pbs"
? `Forgets this snapshot from the PBS repository "${remoteArc?.repo_name ?? ""}". The action is permanent — PBS GC may reclaim the underlying chunks at the next garbage-collection run.`
: `Deletes this archive from the Borg repository "${remoteArc?.repo_name ?? ""}". The action is permanent — Borg compacts the freed space at the next prune.`}
2026-06-19 23:38:57 +02:00
</DialogDescription>
</DialogHeader>
<div className="text-sm font-mono px-3 py-2 rounded-md border border-border bg-background/40 break-all">
2026-06-22 00:55:34 +02:00
{archive?.source === "local" ? localArc?.id : remoteArc?.snapshot}
2026-06-19 23:38:57 +02:00
</div>
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={() => setShowDeleteArchiveConfirm(false)} disabled={deletingArchive}>
Cancel
</Button>
2026-06-22 00:55:34 +02:00
<Button variant="destructive" onClick={deleteArchive} disabled={deletingArchive}>
2026-06-19 23:38:57 +02:00
{deletingArchive ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Trash2 className="h-4 w-4 mr-2" />
)}
2026-06-22 00:55:34 +02:00
Delete
2026-06-19 23:38:57 +02:00
</Button>
2026-06-09 00:13:24 +02:00
</div>
</DialogContent>
</Dialog>
2026-06-19 23:38:57 +02:00
{/* ── Full run log viewer ─────────────────────────────────── */}
<Dialog open={showArchiveFullLog} onOpenChange={setShowArchiveFullLog}>
<DialogContent className="max-w-4xl bg-card border-border">
<DialogHeader>
<DialogTitle className="text-base flex items-center gap-2">
<FileText className="h-5 w-5 text-blue-500" />
Run log
</DialogTitle>
<DialogDescription className="text-xs font-mono break-all">
{archiveLog?.log_path}
</DialogDescription>
</DialogHeader>
<pre className="text-[11px] font-mono whitespace-pre-wrap break-all max-h-[60vh] overflow-auto rounded-md border border-border bg-background/60 p-3 text-foreground/90">
{archiveLog?.content ?? ""}
</pre>
<div className="flex justify-end">
<Button variant="ghost" onClick={() => setShowArchiveFullLog(false)}>Close</Button>
</div>
</DialogContent>
</Dialog>
</>
2026-06-09 00:13:24 +02:00
)
}
// ── Manifest summary panel ───────────────────────────────────
function ManifestSummary({
manifest,
}: {
manifest: {
source_host: ManifestSourceHost
proxmenux_installed_components: Array<{ id: string; version_at_backup: string | null }>
vms_lxcs_at_backup: { vms: unknown[]; lxcs: unknown[] }
storage_inventory?: { zfs_pools?: unknown[]; lvm?: { vgs?: unknown[] } }
}
}) {
const sh = manifest.source_host
const zfsCount = manifest.storage_inventory?.zfs_pools?.length ?? 0
const lvmCount = manifest.storage_inventory?.lvm?.vgs?.length ?? 0
return (
<div className="space-y-3 py-2">
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 text-xs">
<Field icon={<Server className="h-3.5 w-3.5" />} label="Source host" value={sh.hostname} />
<Field label="PVE version" value={sh.pve_version || "—"} />
<Field label="Roles" value={sh.roles.join(", ")} />
<Field label="Kernel" value={sh.kernel} mono />
<Field label="Boot mode" value={sh.boot_mode} />
<Field label="Memory" value={`${Math.round(sh.memory_kb / 1024)} MB`} />
<Field label="ZFS pools" value={String(zfsCount)} />
<Field label="LVM VGs" value={String(lvmCount)} />
<Field label="VMs / LXCs" value={`${manifest.vms_lxcs_at_backup.vms.length} VM / ${manifest.vms_lxcs_at_backup.lxcs.length} LXC`} />
</div>
{manifest.proxmenux_installed_components.length > 0 && (
<div className="text-xs">
<div className="text-muted-foreground mb-1">ProxMenux components at backup time:</div>
<div className="flex flex-wrap gap-1.5">
{manifest.proxmenux_installed_components.map((c) => (
<Badge key={c.id} variant="outline" className="font-mono text-[10px]">
{c.id}{c.version_at_backup ? ` @ ${c.version_at_backup}` : ""}
</Badge>
))}
</div>
</div>
)}
</div>
)
}
2026-06-19 23:38:57 +02:00
function Field({ icon, label, value, mono, labelClassName }: { icon?: React.ReactNode; label: string; value: string; mono?: boolean; labelClassName?: string }) {
2026-06-09 00:13:24 +02:00
return (
2026-06-19 23:38:57 +02:00
<div className="min-w-0">
<div className={`text-[10px] uppercase tracking-wider flex items-center gap-1 ${labelClassName ?? "text-muted-foreground"}`}>
2026-06-09 00:13:24 +02:00
{icon}
{label}
</div>
2026-06-19 23:38:57 +02:00
<div className={`${mono ? "font-mono break-all" : "break-words"}`} title={value}>
2026-06-09 00:13:24 +02:00
{value}
</div>
</div>
)
}
2026-06-19 23:38:57 +02:00
// 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<string, string | undefined> }) {
const order: Array<[string, string]> = [
["keep_last", "last"],
["keep_hourly", "hourly"],
["keep_daily", "daily"],
["keep_weekly", "weekly"],
["keep_monthly", "monthly"],
["keep_yearly", "yearly"],
]
const items = order
.map(([k, lbl]) => {
const v = (retention[k] || "").trim()
const n = Number.parseInt(v, 10)
if (!v || !Number.isFinite(n) || n <= 0) return null
return { label: lbl, value: n }
})
.filter((x): x is { label: string; value: number } => x !== null)
return (
<div>
<div className="text-[10px] uppercase tracking-wider flex items-center gap-1 text-green-500/90">
<Archive className="h-3 w-3 text-green-500/80" /> retention
</div>
{items.length === 0 ? (
<div className="text-muted-foreground italic text-xs mt-1">No retention rules backups will accumulate.</div>
) : (
<div className="mt-1 flex flex-wrap gap-1.5">
{items.map((it) => (
<span
key={it.label}
className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded border border-border bg-background/60"
>
<span className="text-[10px] uppercase tracking-wide text-muted-foreground">{it.label}</span>
<span className="font-mono text-xs text-foreground">{it.value}</span>
</span>
))}
</div>
)}
</div>
)
}
// Renders the backup profile paths as a 2-column grid of monospaced
// rows instead of a single ` · `-separated line. With ~30 paths the
// old layout forced a horizontal scroll on the dialog because the
// line couldn't be wrapped without breaking the path identifiers.
function PathsDisplay({ paths }: { paths: string[] }) {
return (
<div>
<div className="text-[10px] uppercase tracking-wider flex items-center gap-1 text-green-500/90">
<HardDrive className="h-3 w-3 text-green-500/80" /> paths
<span className="ml-1 text-muted-foreground normal-case">({paths.length})</span>
</div>
<div className="mt-1 grid grid-cols-1 sm:grid-cols-2 gap-x-3 gap-y-0.5">
{paths.map((p) => (
<code
key={p}
className="font-mono text-[11px] text-foreground/90 truncate"
title={p}
>
{p}
</code>
))}
</div>
</div>
)
}
2026-06-09 00:13:24 +02:00
// ── Preflight report view ────────────────────────────────────
function PreflightReportView({ report }: { report: PreflightReport }) {
const { summary, checks } = report.preflight
const passColor = "text-emerald-500"
const warnColor = "text-amber-500"
const failColor = "text-red-500"
return (
<div className="space-y-3 border border-border rounded-md p-3 bg-muted/30">
{/* Summary line */}
<div className="flex items-center gap-4 text-sm">
<span className={`inline-flex items-center gap-1 ${passColor}`}>
<CheckCircle2 className="h-4 w-4" />
{summary.pass} pass
</span>
<span className={`inline-flex items-center gap-1 ${warnColor}`}>
<AlertTriangle className="h-4 w-4" />
{summary.warn} warn
</span>
<span className={`inline-flex items-center gap-1 ${failColor}`}>
<XCircle className="h-4 w-4" />
{summary.fail} fail
</span>
{summary.fail > 0 && (
<span className="ml-auto text-xs text-red-500">
--apply would be refused
</span>
)}
</div>
{/* Per-check list */}
<div className="space-y-1.5">
{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 (
<div key={c.id} className="flex items-start gap-2 text-xs">
<Icon className={`h-3.5 w-3.5 ${color} flex-shrink-0 mt-0.5`} />
<div className="flex-1 min-w-0">
<span className={`font-mono ${color}`}>{c.id}</span>
<span className="text-muted-foreground ml-2">{c.message}</span>
</div>
</div>
)
})}
</div>
{/* Storage / network counts */}
<div className="grid grid-cols-2 gap-3 text-xs pt-2 border-t border-border/40">
<div>
<div className="text-muted-foreground mb-1">Storage [in mode: {String(report.storage.in_selected_mode)}]</div>
<div>
{report.storage.zfs.length} ZFS pool(s) ·
{" "}{report.storage.lvm.length} LVM VG(s) ·
{" "}{report.storage.pve_storage.length} PVE storage(s)
</div>
</div>
<div>
<div className="text-muted-foreground mb-1">Network [in mode: {String(report.network.in_selected_mode)}]</div>
<div>
{report.network.keep.length} keep ·
{" "}{report.network.remap.length} remap ·
{" "}{report.network.orphan.length} orphan ·
{" "}{report.network.new.length} new
</div>
</div>
</div>
{/* Driver plan */}
{report.driver_reinstall.plan.length > 0 && (
<div className="text-xs pt-2 border-t border-border/40">
<div className="text-muted-foreground mb-1.5">Driver reinstall plan ({report.driver_reinstall.plan.length})</div>
<div className="space-y-1">
{report.driver_reinstall.plan.map((p) => (
<div key={p.component_id} className="flex items-center justify-between gap-2">
<span className="font-mono">{p.component_id}</span>
<Badge variant="outline" className="text-[10px]">{p.action}</Badge>
</div>
))}
</div>
</div>
)}
{/* Abort reason (if --apply would have been refused) */}
{report.abort_reason && (
<div className="text-xs text-red-500 p-2 border border-red-500/30 rounded-md bg-red-500/10">
{report.abort_reason}
</div>
)}
</div>
)
}
2026-06-19 23:38:57 +02:00
// ──────────────────────────────────────────────────────────────
// 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
}
2026-06-20 21:59:19 +02:00
// 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.
2026-06-19 23:38:57 +02:00
interface PbsRepo {
name: string
repository: string
fingerprint: string | null
source: "proxmox" | "manual"
2026-06-20 21:59:19 +02:00
jobs_using?: string[]
2026-06-19 23:38:57 +02:00
}
interface BorgRepo {
name: string
repository: string
ssh_key_path?: string
2026-06-20 21:59:19 +02:00
// 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[]
2026-06-19 23:38:57 +02:00
}
interface LocalTargetEntry {
path: string
source: "default" | "custom"
removable: boolean
2026-06-20 21:59:19 +02:00
jobs_using?: string[]
2026-06-19 23:38:57 +02:00
}
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<string, string | undefined>
pbs_repository: string | null
pbs_backup_id: string | null
pbs_fingerprint: string | null
has_pbs_password: boolean
2026-07-05 09:30:13 +02:00
// 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.
2026-06-20 21:59:19 +02:00
pbs_encrypt: boolean
2026-07-05 09:30:13 +02:00
pbs_encrypt_mode?: "none" | "new" | "existing"
2026-06-19 23:38:57 +02:00
local_dest_dir: string | null
local_archive_ext: string | null
borg_repo: string | null
borg_encrypt_mode: string
has_borg_passphrase: boolean
// Log fields from Sprint Pulido P1
last_result: string | null // "ok" / "failed" / etc.
last_run_at: string | null // ISO-8601
last_log_path: string | null
last_log_size: number
last_log_tail: string[]
}
function CreateJobDialog({
open,
onClose,
onCreated,
editingJobId,
}: {
open: boolean
onClose: () => void
onCreated: () => void
editingJobId?: string | null
}) {
const isEdit = !!editingJobId
const [step, setStep] = useState<1 | 2 | 3 | 4 | 5>(1)
const [jobId, setJobId] = useState("")
const [backend, setBackend] = useState<"pbs" | "local" | "borg">("pbs")
const [mode, setMode] = useState<"new" | "attach">("new")
const [pveJobId, setPveJobId] = useState<string>("")
const [profileMode, setProfileMode] = useState<"default" | "custom">("default")
const [customPaths, setCustomPaths] = useState<Set<string>>(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<string>("02:00")
const [scheduleMinute, setScheduleMinute] = useState<string>("0")
const [scheduleWeekdays, setScheduleWeekdays] = useState<Set<string>>(new Set(["Sun"]))
const [scheduleDayOfMonth, setScheduleDayOfMonth] = useState<string>("1")
const [scheduleAdvanced, setScheduleAdvanced] = useState<string>("daily")
const [keepLast, setKeepLast] = useState<string>("7")
const [keepHourly, setKeepHourly] = useState<string>("0")
const [keepDaily, setKeepDaily] = useState<string>("7")
const [keepWeekly, setKeepWeekly] = useState<string>("4")
const [keepMonthly, setKeepMonthly] = useState<string>("3")
const [keepYearly, setKeepYearly] = useState<string>("0")
// Backend-specific fields
const [pbsRepository, setPbsRepository] = useState<string>("")
const [pbsBackupId, setPbsBackupId] = useState<string>("")
2026-07-05 09:30:13 +02:00
// 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<File | null>(null)
2026-07-08 12:18:52 +02:00
// 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<string>("")
2026-07-05 09:30:13 +02:00
const [pbsImportBusy, setPbsImportBusy] = useState<boolean>(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"
2026-06-20 21:59:19 +02:00
// 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<string>("")
const [pbsRecoveryPass2, setPbsRecoveryPass2] = useState<string>("")
2026-06-21 00:35:22 +02:00
// 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<boolean>(false)
2026-07-08 12:18:52 +02:00
// 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<boolean>(false)
2026-06-20 21:59:19 +02:00
// 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<{
2026-07-08 12:18:52 +02:00
has_keyfile: boolean; has_recovery: boolean; has_keyfile_passphrase?: boolean; escrow_mode?: "none" | "local" | "full"
2026-06-20 21:59:19 +02:00
}>(
open && backend === "pbs" ? "/api/host-backups/pbs-recovery/status" : null,
fetcher,
)
2026-07-08 12:18:52 +02:00
// 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)
2026-06-19 23:38:57 +02:00
const [localDestDir, setLocalDestDir] = useState<string>("")
const [borgRepoSelected, setBorgRepoSelected] = useState<string>("")
const [borgPassphrase, setBorgPassphrase] = useState<string>("")
const [borgEncryptMode, setBorgEncryptMode] = useState<"none" | "repokey" | "keyfile" | "authenticated">("repokey")
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(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<JobDetail>(
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)
2026-07-05 09:30:13 +02:00
// 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")
}
2026-06-19 23:38:57 +02:00
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<DestinationsResp>(
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("")
2026-07-05 09:30:13 +02:00
setPbsEncryptMode("none")
setPbsImportFile(null)
2026-07-08 12:18:52 +02:00
setPbsImportPath("")
2026-07-05 09:30:13 +02:00
setPbsImportBusy(false)
2026-06-20 21:59:19 +02:00
setPbsRecoveryPass("")
setPbsRecoveryPass2("")
2026-06-21 00:35:22 +02:00
setPbsRecoveryChange(false)
2026-07-08 12:18:52 +02:00
setPbsUploadToPbs(false)
2026-06-19 23:38:57 +02:00
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
2026-06-20 21:59:19 +02:00
: /* borg */ !!borgRepoSelected
2026-06-19 23:38:57 +02:00
2026-07-02 20:57:11 +02:00
// 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.
2026-07-08 12:18:52 +02:00
// 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.
2026-07-02 20:07:05 +02:00
const pbsRecoveryOk = !(backend === "pbs" && pbsEncrypt) ||
2026-07-08 12:18:52 +02:00
pbsRecoveryStatus?.has_keyfile ||
!pbsUploadToPbs ||
2026-07-02 20:57:11 +02:00
(pbsRecoveryPass !== "" && pbsRecoveryPass === pbsRecoveryPass2)
2026-07-02 20:07:05 +02:00
2026-06-19 23:38:57 +02:00
const canSubmit =
2026-07-02 20:07:05 +02:00
canAdvanceFrom1 && canAdvanceFrom2 && canAdvanceFrom3 && canAdvanceFrom4 && backendValid && pbsRecoveryOk
2026-06-19 23:38:57 +02:00
async function handleCreate() {
if (!canSubmit) return
if (mode === "attach" && !selectedPveJob) return
setSubmitting(true)
setError(null)
2026-07-05 16:50:06 +02:00
// 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.
2026-07-08 12:18:52 +02:00
// 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 =
2026-07-05 16:50:06 +02:00
backend === "pbs" &&
2026-07-08 12:18:52 +02:00
pbsEncrypt &&
2026-07-05 16:50:06 +02:00
!pbsRecoveryStatus?.has_keyfile
2026-07-08 12:18:52 +02:00
if (needsInstall) {
// Existing + no PVE auto-detect requires either a file upload
// or a typed absolute path — mirrors the shell wizard.
// Existing + PVE auto-detect uses source=pve-storage.
if (pbsEncryptMode === "existing" && !pbsPveMatch && !pbsImportFile && !pbsImportPath.trim()) {
setError("Pick a keyfile file or enter an absolute path on this host.")
setSubmitting(false)
return
}
if (pbsUploadToPbs && pbsRecoveryPass !== pbsRecoveryPass2) {
setError("Recovery passphrases don't match.")
2026-07-05 09:30:13 +02:00
setSubmitting(false)
return
}
setPbsImportBusy(true)
try {
2026-07-08 12:18:52 +02:00
// Source picker mirrors the shell wizard's dispatch:
// generate → new keyfile via proxmox-backup-client
// pve-storage → reuse /etc/pve/priv/storage/<name>.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<string, unknown> = {
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()
}
2026-07-05 09:30:13 +02:00
await fetchApi("/api/host-backups/pbs-encryption/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
2026-07-08 12:18:52 +02:00
body: JSON.stringify(body),
2026-07-05 09:30:13 +02:00
})
mutatePbsRecovery()
} catch (e) {
const err = e as Error & { body?: { tool_output?: string; tool_exit_code?: number } }
const detail = err.body?.tool_output
? `${err.message}\n\nproxmox-backup-client output:\n${err.body.tool_output}`
: err.message
2026-07-08 12:18:52 +02:00
setError(`Encryption setup failed: ${detail || String(e)}`)
2026-07-05 09:30:13 +02:00
setPbsImportBusy(false)
setSubmitting(false)
return
}
setPbsImportBusy(false)
}
2026-07-08 12:18:52 +02:00
// NOTE: recovery escrow is now bundled into the /pbs-encryption/import
// call above. No separate /pbs-recovery/setup pass is needed.
2026-06-19 23:38:57 +02:00
try {
const body: Record<string, unknown> = {
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
2026-07-05 09:30:13 +02:00
body.pbs_encrypt_mode = pbsEncryptMode
2026-06-19 23:38:57 +02:00
} else if (backend === "local") {
if (localDestDir.trim()) body.local_dest_dir = localDestDir.trim()
} else if (backend === "borg") {
2026-06-20 21:59:19 +02:00
// Passphrase + encrypt_mode are inherited from the destination
// (the runner reads borg-pass-<name>.txt + the 4th field of
// borg-targets.txt). Only send overrides when the operator
// really typed a new one in an edit flow.
2026-06-19 23:38:57 +02:00
body.borg_repo = borgRepoSelected
2026-06-20 21:59:19 +02:00
if (isEdit && borgPassphrase) body.borg_passphrase = borgPassphrase
2026-06-19 23:38:57 +02:00
}
// 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 (
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose() }}>
<DialogContent className="max-w-2xl max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{isEdit ? (
<Pencil className="h-5 w-5 text-blue-500" />
) : (
<Plus className="h-5 w-5 text-blue-500" />
)}
{isEdit ? "Edit scheduled backup job" : "Create scheduled backup job"}
</DialogTitle>
<DialogDescription>
Step {step} of 5 · {mode === "attach" ? "Attached to PVE vzdump" : "Standalone scheduled job"}
</DialogDescription>
</DialogHeader>
{/* Step progress bar */}
<div className="flex items-center gap-1">
{[1, 2, 3, 4, 5].map((n) => (
<div
key={n}
className={`h-1 flex-1 rounded-full ${n <= step ? "bg-blue-500" : "bg-border"}`}
/>
))}
</div>
<div className="overflow-y-auto pr-2 -mr-2 space-y-4 flex-1">
{/* ── Step 1: ID + Backend ─────────────────────── */}
{step === 1 && (
<div className="space-y-4">
<div>
<Label htmlFor="jobId">Job name</Label>
<Input
id="jobId"
value={jobId}
onChange={(e) => setJobId(e.target.value)}
disabled={isEdit}
className="font-mono mt-1"
placeholder="my-host-backup"
/>
<p className="text-xs text-muted-foreground mt-1">
{isEdit
? "The job name can't be changed. Delete and recreate the job if you want to rename it."
: <>A short name to identify this job in the list, logs, and shell menu. Letters, digits, <code className="font-mono">_</code> and <code className="font-mono">-</code> only (no spaces or accents).</>}
</p>
{!idValid && jobId.length > 0 && !isEdit && (
<p className="text-xs text-red-500 mt-1">Invalid characters. Use letters, digits, _ or -.</p>
)}
</div>
<div>
<Label>Backend</Label>
{isEdit && (
<p className="text-xs text-muted-foreground mb-2">
You can change where the backup is sent. The destination of the new option is set on Step 5.
</p>
)}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2 mt-1">
{(["pbs", "local", "borg"] as const).map((b) => {
const Icon = b === "pbs" ? Server : b === "local" ? HardDrive : Archive
const desc = b === "pbs"
? "Proxmox Backup Server. Incremental, encrypted, dedup."
: b === "local"
? "tar.zst archive into a local directory or mounted disk."
: "Borg repo over SSH or on a local/USB disk (timer only)."
return (
<button
key={b}
type="button"
onClick={() => setBackend(b)}
className={`text-left p-3 rounded-md border transition-colors ${
backend === b ? "border-blue-500 bg-blue-500/5" : "border-border bg-background/40"
} hover:bg-white/5`}
>
<div className="flex items-center gap-2 font-medium text-sm">
<Icon className="h-4 w-4" />
{b.toUpperCase()}
</div>
<div className="text-xs text-muted-foreground mt-1">{desc}</div>
</button>
)
})}
</div>
</div>
</div>
)}
{/* ── Step 2: How to schedule ──────────────────── */}
{step === 2 && (
<div className="space-y-3">
<div>
<Label>How to schedule</Label>
<p className="text-xs text-muted-foreground mb-2">
{backend === "borg"
? "Borg backups only run on their own timer — they're not produced by PVE vzdump."
: "Either run on a schedule you define here, or hook into an existing PVE vzdump job and inherit its schedule + retention."}
</p>
</div>
<div className="space-y-2">
<button
type="button"
onClick={() => setMode("new")}
className={`w-full text-left p-3 rounded-md border transition-colors ${mode === "new" ? "border-blue-500 bg-blue-500/5" : "border-border bg-background/40"} hover:bg-white/5`}
>
<div className="text-sm font-medium flex items-center gap-2">
<Calendar className="h-4 w-4" />
New scheduled job
</div>
<div className="text-xs text-muted-foreground mt-1">
Own systemd timer with the OnCalendar and retention policy you pick on the next steps.
</div>
</button>
<button
type="button"
onClick={() => backend !== "borg" && setMode("attach")}
disabled={backend === "borg"}
className={`w-full text-left p-3 rounded-md border transition-colors ${
mode === "attach" && backend !== "borg"
? "border-blue-500 bg-blue-500/5"
: "border-border bg-background/40"
} ${backend === "borg" ? "opacity-60 cursor-not-allowed" : "hover:bg-white/5"}`}
>
<div className="text-sm font-medium flex items-center gap-2">
<Server className="h-4 w-4" />
Attach to existing PVE vzdump job
{backend === "borg" && (
<Badge variant="outline" className="text-[10px] text-muted-foreground">
not available for borg
</Badge>
)}
</div>
<div className="text-xs text-muted-foreground mt-1">
Fires on every <code className="font-mono">job-end</code> of a PVE vzdump job that writes to a <code className="font-mono">{backend}</code> storage. Inherits schedule + retention.
</div>
</button>
</div>
</div>
)}
{/* ── Step 3: branches by mode ─────────────────── */}
{step === 3 && mode === "attach" && (
<div className="space-y-3">
<div>
<Label>Pick the parent PVE vzdump job</Label>
<p className="text-xs text-muted-foreground mb-2">
The host config backup will fire on every <code className="font-mono">job-end</code> of this job.
</p>
</div>
{compatibleJobs.length === 0 ? (
<div className="p-4 rounded-md border border-amber-500/40 bg-amber-500/5 text-sm">
<div className="flex items-center gap-2 text-amber-500 font-medium">
<AlertTriangle className="h-4 w-4" />
No compatible PVE vzdump job
</div>
<p className="text-xs text-muted-foreground mt-1">
No PVE vzdump job currently uses a <span className="font-mono">{backend}</span> storage. Create one in <span className="font-medium">Datacenter Backup</span> first, then come back here to attach.
</p>
</div>
) : (
<div className="space-y-1">
{compatibleJobs.map((j) => (
<button
key={j.id}
type="button"
onClick={() => setPveJobId(j.id)}
className={`w-full text-left p-3 rounded-md border ${pveJobId === j.id ? "border-blue-500 bg-blue-500/5" : "border-border bg-background/40"} hover:bg-white/5 transition-colors`}
>
<div className="flex items-center justify-between gap-2 flex-wrap">
<span className="font-mono text-xs">{j.id}</span>
{!j.enabled && (
<Badge variant="outline" className="text-[10px] text-amber-500 border-amber-500/40">
disabled
</Badge>
)}
</div>
<div className="mt-1 text-xs text-muted-foreground flex items-center gap-x-3 gap-y-1 flex-wrap">
<span>storage: <code className="font-mono">{j.storage}</code></span>
<span>schedule: <code className="font-mono">{j.schedule || "—"}</code></span>
<span>retention: <code className="font-mono">{j.prune || "—"}</code></span>
</div>
</button>
))}
</div>
)}
</div>
)}
{step === 3 && mode === "new" && (
<div className="space-y-4">
<div>
<Label>Schedule</Label>
<p className="text-xs text-muted-foreground mb-2">
Pick how often this backup runs. The expression is built and validated for you.
</p>
<Select
value={scheduleType}
onValueChange={(v) => setScheduleType(v as typeof scheduleType)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="daily">Daily every day at a specific time</SelectItem>
<SelectItem value="hourly">Hourly every hour at a specific minute</SelectItem>
<SelectItem value="weekly">Weekly on specific weekdays at a time</SelectItem>
<SelectItem value="monthly">Monthly on a specific day of the month</SelectItem>
<SelectItem value="advanced">Advanced type the OnCalendar expression myself</SelectItem>
</SelectContent>
</Select>
</div>
{scheduleType === "daily" && (
<div>
<Label htmlFor="schedTime">Time of day</Label>
<Input
id="schedTime"
type="time"
value={scheduleTime}
onChange={(e) => setScheduleTime(e.target.value)}
className="font-mono mt-1 max-w-[140px]"
/>
</div>
)}
{scheduleType === "hourly" && (
<div>
<Label htmlFor="schedMinute">Minute of the hour (059)</Label>
<Input
id="schedMinute"
type="number"
min="0"
max="59"
value={scheduleMinute}
onChange={(e) => setScheduleMinute(e.target.value)}
className="font-mono mt-1 max-w-[120px]"
/>
<p className="text-xs text-muted-foreground mt-1">
The job fires every hour at this minute. <code className="font-mono">0</code> = on the hour, <code className="font-mono">30</code> = half past, etc.
</p>
</div>
)}
{scheduleType === "weekly" && (
<div className="space-y-3">
<div>
<Label>Days of the week</Label>
<div className="flex flex-wrap gap-1 mt-1">
{["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"].map((d) => {
const active = scheduleWeekdays.has(d)
return (
<button
key={d}
type="button"
onClick={() => {
setScheduleWeekdays((prev) => {
const next = new Set(prev)
if (next.has(d)) next.delete(d)
else next.add(d)
return next
})
}}
className={`px-3 py-1.5 rounded-md text-xs font-mono border transition-colors ${
active
? "border-blue-500 bg-blue-500/10 text-blue-400"
: "border-border bg-background/40 text-muted-foreground hover:bg-white/5"
}`}
>
{d}
</button>
)
})}
</div>
{scheduleWeekdays.size === 0 && (
<p className="text-xs text-amber-500 mt-1">Pick at least one day.</p>
)}
</div>
<div>
<Label htmlFor="schedTimeW">Time of day</Label>
<Input
id="schedTimeW"
type="time"
value={scheduleTime}
onChange={(e) => setScheduleTime(e.target.value)}
className="font-mono mt-1 max-w-[140px]"
/>
</div>
</div>
)}
{scheduleType === "monthly" && (
<div className="space-y-3">
<div>
<Label htmlFor="schedDay">Day of the month (131)</Label>
<Input
id="schedDay"
type="number"
min="1"
max="31"
value={scheduleDayOfMonth}
onChange={(e) => setScheduleDayOfMonth(e.target.value)}
className="font-mono mt-1 max-w-[120px]"
/>
<p className="text-xs text-muted-foreground mt-1">
If the chosen day doesn't exist in a given month (e.g. 31 in February), systemd skips that month.
</p>
</div>
<div>
<Label htmlFor="schedTimeM">Time of day</Label>
<Input
id="schedTimeM"
type="time"
value={scheduleTime}
onChange={(e) => setScheduleTime(e.target.value)}
className="font-mono mt-1 max-w-[140px]"
/>
</div>
</div>
)}
{scheduleType === "advanced" && (
<div>
<Label htmlFor="schedAdv">OnCalendar expression</Label>
<Input
id="schedAdv"
value={scheduleAdvanced}
onChange={(e) => setScheduleAdvanced(e.target.value)}
className="font-mono mt-1"
placeholder="*-*-* 02:00, Mon..Fri *-*-* 04:00, daily, ..."
/>
<p className="text-xs text-muted-foreground mt-1">
Any expression accepted by <code className="font-mono">systemd-analyze calendar</code>. See <span className="font-mono">man systemd.time</span> for the full grammar.
</p>
</div>
)}
{/* Live preview from the backend */}
<div className="rounded-md border border-border bg-background/40 p-3 space-y-1 text-xs">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground">Preview</div>
<div className="flex items-baseline gap-2">
<span className="text-muted-foreground">Expression:</span>
<code className="font-mono">{onCalendar}</code>
</div>
{calendarPreview ? (
calendarPreview.valid ? (
<>
{calendarPreview.normalized && calendarPreview.normalized !== onCalendar && (
<div className="flex items-baseline gap-2">
<span className="text-muted-foreground">Normalized:</span>
<code className="font-mono">{calendarPreview.normalized}</code>
</div>
)}
{calendarPreview.next_elapse && (
<div className="flex items-baseline gap-2">
<span className="text-muted-foreground">Next run:</span>
<span className="text-emerald-400">{calendarPreview.next_elapse}</span>
{calendarPreview.from_now && (
<span className="text-muted-foreground">({calendarPreview.from_now})</span>
)}
</div>
)}
</>
) : (
<div className="text-red-500">
<span className="font-medium">Invalid:</span> {calendarPreview.error}
</div>
)
) : (
<div className="text-muted-foreground italic">checking…</div>
)}
</div>
<div>
<Label>Retention</Label>
<p className="text-xs text-muted-foreground mb-2">Zero disables that bucket.</p>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{[
{ id: "keep-last", lbl: "keep-last", v: keepLast, set: setKeepLast },
{ id: "keep-hourly", lbl: "keep-hourly", v: keepHourly, set: setKeepHourly },
{ id: "keep-daily", lbl: "keep-daily", v: keepDaily, set: setKeepDaily },
{ id: "keep-weekly", lbl: "keep-weekly", v: keepWeekly, set: setKeepWeekly },
{ id: "keep-monthly", lbl: "keep-monthly", v: keepMonthly, set: setKeepMonthly },
{ id: "keep-yearly", lbl: "keep-yearly", v: keepYearly, set: setKeepYearly },
].map((row) => (
<div key={row.id}>
<Label htmlFor={row.id} className="text-xs">{row.lbl}</Label>
<Input
id={row.id}
type="number"
min="0"
value={row.v}
onChange={(e) => row.set(e.target.value)}
className="font-mono mt-1"
/>
</div>
))}
</div>
</div>
</div>
)}
{/* ── Step 4: Profile + paths ──────────────────── */}
{step === 4 && (
<div className="space-y-4">
<div>
<Label>Backup profile</Label>
<div className="grid grid-cols-2 gap-2 mt-1">
<button
type="button"
onClick={() => setProfileMode("default")}
className={`text-left p-3 rounded-md border ${profileMode === "default" ? "border-blue-500 bg-blue-500/5" : "border-border bg-background/40"} hover:bg-white/5 transition-colors`}
>
<div className="text-sm font-medium">Default</div>
<div className="text-xs text-muted-foreground mt-1">
Critical config paths ({defaultPaths.length} entries): /etc/pve, /etc/network, /root, /usr/local, etc.
</div>
</button>
<button
type="button"
onClick={() => setProfileMode("custom")}
className={`text-left p-3 rounded-md border ${profileMode === "custom" ? "border-blue-500 bg-blue-500/5" : "border-border bg-background/40"} hover:bg-white/5 transition-colors`}
>
<div className="text-sm font-medium">Custom</div>
<div className="text-xs text-muted-foreground mt-1">
Pick from the default list which paths to include. At least one is required.
</div>
</button>
</div>
</div>
{profileMode === "custom" && (
<div>
<Label>Paths to include ({customPaths.size}/{defaultPaths.length})</Label>
<ScrollArea className="h-64 mt-1 rounded-md border border-border p-2">
{defaultPaths.map((p) => (
<label
key={p}
className="flex items-center gap-2 py-1 px-1 hover:bg-white/5 rounded text-xs cursor-pointer"
>
<Checkbox
checked={customPaths.has(p)}
onCheckedChange={() => togglePath(p)}
/>
<span className="font-mono">{p}</span>
</label>
))}
</ScrollArea>
</div>
)}
</div>
)}
{/* ── Step 5: Backend-specific + summary ──────── */}
{step === 5 && (
<div className="space-y-4">
{backend === "pbs" && (
<>
<div>
<Label>PBS repository</Label>
{destResp?.pbs?.length ? (
<div className="mt-1 flex items-center gap-2">
<Select value={pbsRepository} onValueChange={setPbsRepository}>
2026-06-21 00:35:22 +02:00
<SelectTrigger className="flex-1 min-w-0">
{/* Manual render: SelectValue's default mode
duplicates the item children and cuts
them in the middle on mobile. We render
"name — repository" ourselves with a
proper trailing ellipsis. */}
{(() => {
const sel = destResp.pbs.find((r) => r.repository === pbsRepository)
if (!sel) {
return <span className="truncate text-muted-foreground">Pick a PBS repository</span>
}
return (
<span className="truncate font-mono text-left">
{sel.name}
<span className="text-muted-foreground"> {sel.repository}</span>
</span>
)
})()}
2026-06-19 23:38:57 +02:00
</SelectTrigger>
<SelectContent>
{destResp.pbs.map((r) => (
<SelectItem key={`${r.name}-${r.repository}`} value={r.repository}>
<span className="font-mono">{r.name}</span>
<span className="text-muted-foreground ml-2"> {r.repository}</span>
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
size="sm"
variant="outline"
className="shrink-0 bg-blue-500/10 border-blue-500/40 !text-blue-400 hover:bg-blue-500/20 hover:!text-blue-300"
onClick={() => setAddingDestType("pbs")}
title="Save another PBS repository"
>
<Plus className="h-3.5 w-3.5 mr-1" />
Add another
</Button>
</div>
) : (
<div className="mt-1 p-3 rounded-md border border-amber-500/40 bg-amber-500/5 text-xs space-y-2">
<div>No PBS repository configured yet. You can add one without leaving this wizard.</div>
<Button
type="button"
size="sm"
variant="outline"
className="bg-blue-500/10 border-blue-500/40 !text-blue-400 hover:bg-blue-500/20 hover:!text-blue-300"
onClick={() => setAddingDestType("pbs")}
>
<Plus className="h-3.5 w-3.5 mr-1" />
Add PBS repository
</Button>
</div>
)}
</div>
<div>
<Label htmlFor="pbsBackupId">Group name in PBS</Label>
<Input
id="pbsBackupId"
value={pbsBackupId}
onChange={(e) => setPbsBackupId(e.target.value)}
placeholder="Leave blank for the default"
className="font-mono mt-1"
/>
<p className="text-xs text-muted-foreground mt-1">
PBS organises backups into named groups, each with its own retention. Leave blank to use the automatic default for this host (recommended).
</p>
</div>
2026-07-05 16:50:06 +02:00
{/* 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. */}
2026-06-20 21:59:19 +02:00
<div className="pt-2 border-t border-border space-y-3">
2026-07-05 16:50:06 +02:00
<label className="flex items-start gap-2 cursor-pointer">
<Checkbox
checked={pbsEncrypt}
onCheckedChange={(v) => {
const checked = !!v
if (!checked) {
setPbsEncryptMode("none")
} else {
// Yes → if the host already has a keyfile, submit
// "existing" (reuse it silently). If not, default
// to "new" (Generate) — the operator can flip to
// "existing" (Import) via the radio below.
setPbsEncryptMode(pbsRecoveryStatus?.has_keyfile ? "existing" : "new")
}
}}
className="mt-0.5"
/>
<div className="flex-1">
<div className="inline-flex items-center gap-1.5 text-sm">
<Lock className="h-3.5 w-3.5 text-emerald-400" />
Encrypt backups (client-side keyfile)
</div>
<p className="text-[11px] text-muted-foreground mt-1">
Adds <code className="font-mono">--keyfile</code> to <code className="font-mono">proxmox-backup-client backup</code>. Encryption happens before upload so chunks land on PBS already ciphered. The keyfile is installed at <code className="font-mono">/usr/local/share/proxmenux/pbs-key.conf</code> (chmod 0600) and reused by every encrypted PBS job on this host.
2026-07-05 09:30:13 +02:00
</p>
</div>
2026-07-05 16:50:06 +02:00
</label>
{pbsEncrypt && !pbsRecoveryStatus?.has_keyfile && (
<div className="space-y-2 rounded-md border border-emerald-500/30 bg-emerald-500/5 p-3">
<div className="text-[11px] font-medium">
No encryption key is stored on this host. Choose how to set one up:
</div>
<label className="flex items-start gap-2 cursor-pointer text-[11px]">
<input
type="radio"
name="pbsEncryptSetupCreate"
checked={pbsEncryptMode === "new"}
onChange={() => setPbsEncryptMode("new")}
className="mt-1"
/>
<div className="flex-1">
<div className="font-medium">Generate a new keyfile (per host safest isolation)</div>
<div className="text-muted-foreground">Creates a fresh keyfile at <code className="font-mono">/usr/local/share/proxmenux/pbs-key.conf</code>. Backup up the recovery passphrase (below) to survive host loss.</div>
</div>
</label>
<label className="flex items-start gap-2 cursor-pointer text-[11px]">
<input
type="radio"
name="pbsEncryptSetupCreate"
checked={pbsEncryptMode === "existing"}
onChange={() => setPbsEncryptMode("existing")}
className="mt-1"
/>
<div className="flex-1">
<div className="font-medium">Import an existing keyfile (shared across hosts)</div>
<div className="text-muted-foreground">Use the same keyfile another host already has enables cross-host restore of encrypted backups.</div>
</div>
</label>
2026-07-08 12:18:52 +02:00
{pbsEncryptMode === "existing" && pbsPveMatch && (
<div className="pt-1 rounded-md border border-emerald-500/40 bg-emerald-500/5 p-2.5 text-[11px] space-y-1">
<div className="flex items-start gap-1.5">
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-400 mt-0.5 shrink-0" />
<div className="flex-1">
<div className="font-medium text-foreground">Auto-detected from PVE storage &apos;{pbsPveMatch.name}&apos;</div>
<div className="text-muted-foreground mt-0.5">
Existing key at <code className="font-mono text-[10.5px]">{pbsPveMatch.path}</code>
</div>
<div className="text-muted-foreground mt-0.5">Will be copied to the ProxMenux state directory. No file upload needed.</div>
</div>
</div>
</div>
)}
{pbsEncryptMode === "existing" && !pbsPveMatch && (
2026-07-05 23:58:58 +02:00
<div className="space-y-2 pt-1">
<div className="space-y-1.5">
<Label htmlFor="pbsImportFile" className="text-[11px] font-medium">
2026-07-08 12:18:52 +02:00
Upload from your machine
2026-07-05 23:58:58 +02:00
</Label>
<Input
id="pbsImportFile"
type="file"
accept=".conf,.key,application/json,text/plain"
onChange={(e) => setPbsImportFile(e.target.files?.[0] ?? null)}
2026-07-08 12:18:52 +02:00
disabled={pbsImportBusy || !!pbsImportPath.trim()}
2026-07-05 23:58:58 +02:00
className="h-8 text-[11px]"
/>
</div>
2026-07-08 12:18:52 +02:00
<div className="text-[10px] text-center text-muted-foreground"> or </div>
2026-07-05 23:58:58 +02:00
<div className="space-y-1.5">
2026-07-08 12:18:52 +02:00
<Label htmlFor="pbsImportPath" className="text-[11px] font-medium">
Absolute path on this host
2026-07-05 23:58:58 +02:00
</Label>
<Input
2026-07-08 12:18:52 +02:00
id="pbsImportPath"
type="text"
value={pbsImportPath}
onChange={(e) => setPbsImportPath(e.target.value)}
disabled={pbsImportBusy || !!pbsImportFile}
2026-07-14 19:22:33 +02:00
placeholder="e.g. /etc/pve/priv/storage/<NAME>.enc or /root/my-pbs-key"
2026-07-05 23:58:58 +02:00
className="h-8 text-[11px] font-mono"
/>
<p className="text-[10px] text-muted-foreground">
2026-07-08 12:18:52 +02:00
Either option works. The file lands at <code className="font-mono">/usr/local/share/proxmenux/pbs-key.conf</code> and every subsequent encrypted backup reuses it.
2026-07-05 23:58:58 +02:00
</p>
</div>
2026-07-05 16:50:06 +02:00
</div>
)}
</div>
)}
2026-06-20 21:59:19 +02:00
{pbsEncrypt && (
2026-07-08 12:18:52 +02:00
<div className="pl-7 space-y-3 rounded-md border border-blue-500/30 bg-blue-500/5 p-3">
{pbsRecoveryStatus?.has_keyfile && !pbsRecoveryChange && (
2026-06-21 00:35:22 +02:00
<div className="flex items-start gap-2 text-[11px]">
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-400 mt-0.5 shrink-0" />
<div className="flex-1">
2026-07-08 12:18:52 +02:00
<div className="text-foreground">Keyfile installed. Backups reuse it silently.</div>
<div className="text-muted-foreground mt-0.5">The current PBS-upload setting stays in effect.</div>
2026-06-21 00:35:22 +02:00
</div>
<button
type="button"
onClick={() => setPbsRecoveryChange(true)}
className="text-blue-400 hover:text-blue-300 underline underline-offset-2 shrink-0"
>
Change
</button>
</div>
2026-07-08 12:18:52 +02:00
)}
{pbsRecoveryStatus?.has_keyfile && pbsRecoveryChange && (
<>
<KeyfileActionsBar
mutateStatus={mutatePbsRecovery}
escrowMode={pbsRecoveryStatus?.escrow_mode}
/>
<button
type="button"
onClick={() => setPbsRecoveryChange(false)}
className="text-[11px] text-muted-foreground hover:text-foreground underline underline-offset-2"
>
Done hide keyfile options
</button>
</>
)}
{!pbsRecoveryStatus?.has_keyfile && (
2026-06-21 00:35:22 +02:00
<>
<div>
2026-07-08 12:18:52 +02:00
<div className="text-[11px] font-medium text-foreground mb-2">Upload key to PBS?</div>
<div className="grid gap-1.5">
<label className="flex items-start gap-2 cursor-pointer text-[11px]">
<input
type="radio"
name="pbsUploadToPbsCreate"
checked={!pbsUploadToPbs}
onChange={() => setPbsUploadToPbs(false)}
className="mt-1"
/>
<div className="flex-1">
<div className="font-medium">No, keep local only</div>
<div className="text-muted-foreground">Keyfile stays only at <code className="font-mono text-[10.5px]">/usr/local/share/proxmenux/pbs-key.conf</code>. You are responsible for keeping an offsite copy losing it makes every encrypted backup unreadable.</div>
</div>
</label>
<label className="flex items-start gap-2 cursor-pointer text-[11px]">
<input
type="radio"
name="pbsUploadToPbsCreate"
checked={pbsUploadToPbs}
onChange={() => setPbsUploadToPbs(true)}
className="mt-1"
/>
<div className="flex-1">
<div className="font-medium">Yes, upload</div>
<div className="text-muted-foreground">Set a recovery passphrase now. ProxMenux uploads a passphrase-wrapped copy of the keyfile to PBS with every backup so you can recover it on a reinstalled host with just the passphrase.</div>
</div>
</label>
</div>
2026-06-21 00:35:22 +02:00
</div>
2026-07-08 12:18:52 +02:00
{pbsUploadToPbs ? (
<div className="space-y-2 pt-1 border-t border-blue-500/20">
<div>
<Label htmlFor="pbsRecPass" className="text-[11px]">Recovery passphrase</Label>
<Input
id="pbsRecPass"
type="password"
value={pbsRecoveryPass}
onChange={(e) => setPbsRecoveryPass(e.target.value)}
placeholder="Long random string — write it down somewhere safe"
className="font-mono mt-1 h-8 text-xs"
/>
</div>
<div>
<Label htmlFor="pbsRecPass2" className="text-[11px]">Confirm passphrase</Label>
<Input
id="pbsRecPass2"
type="password"
value={pbsRecoveryPass2}
onChange={(e) => setPbsRecoveryPass2(e.target.value)}
placeholder="Type it again"
className="font-mono mt-1 h-8 text-xs"
/>
{pbsRecoveryPass && pbsRecoveryPass2 && pbsRecoveryPass !== pbsRecoveryPass2 && (
<p className="text-[11px] text-red-400 mt-1">Passphrases don't match.</p>
)}
</div>
</div>
) : (
<div className="pt-1 border-t border-blue-500/20 text-[11px] text-amber-300 bg-amber-500/10 border border-amber-500/30 rounded px-3 py-2 leading-relaxed space-y-1">
<div className="font-semibold">Keep an offsite copy of the keyfile.</div>
<div>After the job is created, copy <code className="font-mono text-[10.5px] bg-amber-500/20 rounded px-1">/usr/local/share/proxmenux/pbs-key.conf</code> to an external medium (USB, password manager, another host).</div>
<div>Without that copy, losing this host makes every encrypted backup on PBS unreadable.</div>
</div>
2026-06-21 00:35:22 +02:00
)}
</>
)}
2026-06-20 21:59:19 +02:00
</div>
)}
</div>
2026-06-19 23:38:57 +02:00
</>
)}
{backend === "local" && (
<div className="space-y-2">
<Label htmlFor="localDest">Destination directory</Label>
{mode === "attach" ? (
<>
<Input
id="localDest"
value={localDestDir}
onChange={(e) => setLocalDestDir(e.target.value)}
placeholder="Auto (derived from the PVE storage path)"
className="font-mono"
/>
<p className="text-xs text-muted-foreground">
Leave blank to derive automatically from the parent PVE job's storage at backup time. Default is the storage's <code className="font-mono">/dump</code> subdir, falling back to <code className="font-mono">/var/lib/vz/dump</code>.
</p>
</>
) : (
<>
2026-06-20 21:59:19 +02:00
{(destResp?.local?.entries?.length ?? 0) > 0 && (
<div className="flex items-center gap-2">
<Select
value={localDestDir || (destResp?.local?.effective ?? "")}
onValueChange={(v) => setLocalDestDir(v)}
>
<SelectTrigger className="flex-1">
<SelectValue placeholder="Pick a configured local destination" />
</SelectTrigger>
<SelectContent>
{destResp?.local?.entries?.map((e) => (
<SelectItem key={e.path} value={e.path}>
<span className="font-mono">{e.path}</span>
{e.source === "default" && (
<span className="text-muted-foreground ml-2 text-[10px] uppercase">default</span>
)}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
size="sm"
variant="outline"
className="shrink-0 bg-blue-500/10 border-blue-500/40 !text-blue-400 hover:bg-blue-500/20 hover:!text-blue-300"
onClick={() => setAddingDestType("local")}
title="Save another local destination"
>
<Plus className="h-3.5 w-3.5 mr-1" />
Add another
</Button>
</div>
)}
2026-06-19 23:38:57 +02:00
<Input
id="localDest"
value={localDestDir}
onChange={(e) => setLocalDestDir(e.target.value)}
placeholder={destResp?.local?.effective || "/var/lib/vz/dump"}
className="font-mono"
/>
<p className="text-xs text-muted-foreground">
2026-06-20 21:59:19 +02:00
Pick a configured destination above, or type a custom directory path. Empty input falls back to <span className="font-mono">{destResp?.local?.effective || "/var/lib/vz/dump"}</span>.
2026-06-19 23:38:57 +02:00
</p>
</>
)}
</div>
)}
{backend === "borg" && (
<>
<div>
<Label>Borg repository</Label>
{destResp?.borg?.length ? (
<div className="mt-1 flex items-center gap-2">
<Select value={borgRepoSelected} onValueChange={setBorgRepoSelected}>
2026-06-21 00:35:22 +02:00
<SelectTrigger className="flex-1 min-w-0">
{(() => {
const sel = destResp.borg.find((r) => r.repository === borgRepoSelected)
if (!sel) {
return <span className="truncate text-muted-foreground">Pick a Borg repository</span>
}
return (
<span className="truncate font-mono text-left">
{sel.name}
<span className="text-muted-foreground"> — {sel.repository}</span>
</span>
)
})()}
2026-06-19 23:38:57 +02:00
</SelectTrigger>
<SelectContent>
{destResp.borg.map((r) => (
<SelectItem key={`${r.name}-${r.repository}`} value={r.repository}>
<span className="font-mono">{r.name}</span>
<span className="text-muted-foreground ml-2">— {r.repository}</span>
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
size="sm"
variant="outline"
className="shrink-0 bg-fuchsia-500/10 border-fuchsia-500/40 !text-fuchsia-400 hover:bg-fuchsia-500/20 hover:!text-fuchsia-300"
onClick={() => setAddingDestType("borg")}
title="Save another Borg repository"
>
<Plus className="h-3.5 w-3.5 mr-1" />
Add another
</Button>
</div>
) : (
<div className="mt-1 p-3 rounded-md border border-amber-500/40 bg-amber-500/5 text-xs space-y-2">
<div>No Borg repository configured yet. You can add one without leaving this wizard (SSH or local/USB path supported).</div>
<Button
type="button"
size="sm"
variant="outline"
className="bg-fuchsia-500/10 border-fuchsia-500/40 !text-fuchsia-400 hover:bg-fuchsia-500/20 hover:!text-fuchsia-300"
onClick={() => setAddingDestType("borg")}
>
<Plus className="h-3.5 w-3.5 mr-1" />
Add Borg repository
</Button>
</div>
)}
</div>
2026-06-20 21:59:19 +02:00
{/* 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 (
<p className="text-[11px] text-muted-foreground">
Encryption + passphrase are taken from the destination ({mode === "none" ? "no encryption" : mode}). Edit the destination if you need to change them.
2026-06-19 23:38:57 +02:00
</p>
2026-06-20 21:59:19 +02:00
)
})()}
2026-06-19 23:38:57 +02:00
</>
)}
{/* Summary preview — mirrors the JobDetailModal styling so
what the operator sees at Create time is what they'll
see when they open the job later. */}
{(() => {
const retentionPairs: Array<[string, string]> = [
["last", String(keepLast || "")],
["hourly", String(keepHourly || "")],
["daily", String(keepDaily || "")],
["weekly", String(keepWeekly || "")],
["monthly", String(keepMonthly || "")],
["yearly", String(keepYearly || "")],
].filter(([, v]) => Number(v) > 0) as Array<[string, string]>
return (
<div className="rounded-md border border-border bg-background/40 p-3 space-y-2 text-xs">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">Summary</div>
<div><span className="text-muted-foreground">Name:</span> <span className="font-mono text-foreground">{jobId}</span></div>
<div className="flex items-center gap-2 flex-wrap">
<span className="text-muted-foreground">Backend:</span>
<Badge variant="outline" className={`text-[10px] uppercase tracking-wide ${methodBadgeCls(backend)}`}>
{backend}
</Badge>
<Badge variant="outline" className="text-[10px] text-blue-400 border-blue-400/40 bg-blue-500/5">
{mode === "attach" ? "attached" : "scheduled"}
</Badge>
</div>
{mode === "attach" && selectedPveJob && (
<>
<div><span className="text-muted-foreground">PVE job:</span> <span className="font-mono text-foreground">{selectedPveJob.id}</span></div>
<div className="flex items-center gap-1.5 flex-wrap">
<Calendar className="h-3 w-3 text-green-500/80" />
<span className="text-green-500/90 uppercase tracking-wider text-[10px]">inherited schedule:</span>
<span className="text-foreground">{humanizeOnCalendar(selectedPveJob.schedule)}</span>
</div>
<div className="flex items-center gap-1.5 flex-wrap">
<Archive className="h-3 w-3 text-green-500/80" />
<span className="text-green-500/90 uppercase tracking-wider text-[10px]">inherited retention:</span>
<span className="font-mono text-foreground">{selectedPveJob.prune}</span>
</div>
</>
)}
{mode === "new" && (
<>
<div className="flex items-center gap-1.5 flex-wrap">
<Calendar className="h-3 w-3 text-green-500/80" />
<span className="text-green-500/90 uppercase tracking-wider text-[10px]">when:</span>
<span className="text-foreground">{humanizeOnCalendar(onCalendar)}</span>
</div>
<div className="flex items-start gap-1.5 flex-wrap">
<span className="text-green-500/90 uppercase tracking-wider text-[10px] inline-flex items-center gap-1 pt-0.5">
<Archive className="h-3 w-3 text-green-500/80" /> retention:
</span>
{retentionPairs.length === 0 ? (
<span className="text-muted-foreground italic">No retention rules</span>
) : (
<div className="flex flex-wrap gap-1.5">
{retentionPairs.map(([k, v]) => (
<span key={k} className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded border border-border bg-background/60">
<span className="text-[10px] uppercase tracking-wide text-muted-foreground">{k}</span>
<span className="font-mono text-xs text-foreground">{v}</span>
</span>
))}
</div>
)}
</div>
</>
)}
<div className="inline-flex items-center gap-1.5">
<FileSearch className="h-3 w-3 text-green-500/80" />
<span className="text-green-500/90 uppercase tracking-wider text-[10px]">profile:</span>
<span className="text-foreground">{profileMode}</span>
<span className="text-muted-foreground">({profileMode === "default" ? defaultPaths.length : customPaths.size} paths)</span>
</div>
</div>
)
})()}
{error && (
<div className="text-xs text-red-500 px-3 py-2 rounded-md border border-red-500/30 bg-red-500/10 whitespace-pre-wrap break-words">
2026-06-19 23:38:57 +02:00
{error}
</div>
)}
</div>
)}
</div>
{/* Footer navigation */}
<div className="flex items-center justify-between gap-2 pt-3 border-t border-border">
<Button
variant="ghost"
onClick={onClose}
disabled={submitting}
>
Cancel
</Button>
<div className="flex items-center gap-2">
{step > 1 && (
<Button
variant="outline"
onClick={() => setStep((s) => (s - 1) as 1 | 2 | 3 | 4 | 5)}
disabled={submitting}
>
<ChevronLeft className="h-4 w-4 mr-1" />
Back
</Button>
)}
{step < 5 ? (
<Button
onClick={() => setStep((s) => (s + 1) as 1 | 2 | 3 | 4 | 5)}
disabled={
(step === 1 && !canAdvanceFrom1) ||
(step === 2 && !canAdvanceFrom2) ||
(step === 3 && !canAdvanceFrom3) ||
(step === 4 && !canAdvanceFrom4)
}
>
Next
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
) : (
<Button
onClick={handleCreate}
disabled={!canSubmit || submitting}
2026-06-22 11:38:06 +02:00
className="bg-blue-600 hover:bg-blue-700 text-white"
2026-06-19 23:38:57 +02:00
>
{submitting ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : isEdit ? (
<Save className="h-4 w-4 mr-2" />
) : (
<Plus className="h-4 w-4 mr-2" />
)}
{isEdit ? "Save changes" : "Create job"}
</Button>
)}
</div>
</div>
</DialogContent>
{/* 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. */}
<AddDestinationDialog
type={addingDestType}
onClose={() => setAddingDestType(null)}
onSaved={(repo) => {
if (repo && (addingDestType === "pbs" || addingDestType === "borg")) {
setPendingAutoSelectDest({ kind: addingDestType, repository: repo })
}
setAddingDestType(null)
mutateDest()
}}
/>
</Dialog>
)
}
// ──────────────────────────────────────────────────────────────
// 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-<ts>` .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
2026-06-20 21:59:19 +02:00
// 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
2026-06-19 23:38:57 +02:00
}) {
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<Set<string>>(new Set())
const [pbsRepository, setPbsRepository] = useState<string>("")
const [pbsBackupId, setPbsBackupId] = useState<string>("")
2026-07-05 09:30:13 +02:00
// 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<File | null>(null)
2026-07-08 12:18:52 +02:00
// 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<string>("")
2026-07-05 09:30:13 +02:00
const [pbsImportBusy, setPbsImportBusy] = useState<boolean>(false)
const pbsEncrypt = pbsEncryptMode !== "none"
2026-06-20 21:59:19 +02:00
const [pbsRecoveryPass, setPbsRecoveryPass] = useState<string>("")
const [pbsRecoveryPass2, setPbsRecoveryPass2] = useState<string>("")
2026-06-21 00:35:22 +02:00
// 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<boolean>(false)
2026-07-08 12:18:52 +02:00
// 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<boolean>(false)
2026-06-20 21:59:19 +02:00
// 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<{
2026-07-08 12:18:52 +02:00
has_keyfile: boolean; has_recovery: boolean; has_keyfile_passphrase?: boolean; escrow_mode?: "none" | "local" | "full"
2026-06-20 21:59:19 +02:00
}>(
open && backend === "pbs" ? "/api/host-backups/pbs-recovery/status" : null,
fetcher,
)
2026-07-08 12:18:52 +02:00
// 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)
2026-06-19 23:38:57 +02:00
const [localDestDir, setLocalDestDir] = useState<string>("")
const [borgRepoSelected, setBorgRepoSelected] = useState<string>("")
const [borgPassphrase, setBorgPassphrase] = useState<string>("")
const [borgEncryptMode, setBorgEncryptMode] = useState<"none" | "repokey" | "keyfile" | "authenticated">("repokey")
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const { data: destResp, mutate: mutateDest } = useSWR<DestinationsResp>(
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("")
2026-07-05 09:30:13 +02:00
setPbsEncryptMode("none")
setPbsImportFile(null)
2026-07-08 12:18:52 +02:00
setPbsImportPath("")
2026-07-05 09:30:13 +02:00
setPbsImportBusy(false)
2026-06-20 21:59:19 +02:00
setPbsRecoveryPass("")
setPbsRecoveryPass2("")
2026-06-21 00:35:22 +02:00
setPbsRecoveryChange(false)
2026-07-08 12:18:52 +02:00
setPbsUploadToPbs(false)
2026-06-19 23:38:57 +02:00
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
2026-06-20 21:59:19 +02:00
// Borg destination carries its own passphrase + encryption.
// No need to gate on local state — the wizard no longer asks.
: !!borgRepoSelected
2026-07-02 20:57:11 +02:00
// 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.
2026-07-08 12:18:52 +02:00
// 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.
2026-07-02 20:07:05 +02:00
const pbsRecoveryOk = !(backend === "pbs" && pbsEncrypt) ||
2026-07-08 12:18:52 +02:00
pbsRecoveryStatus?.has_keyfile ||
!pbsUploadToPbs ||
2026-07-02 20:57:11 +02:00
(pbsRecoveryPass !== "" && pbsRecoveryPass === pbsRecoveryPass2)
2026-07-02 20:07:05 +02:00
const canSubmit = canAdvanceFrom1 && backendValid && pbsRecoveryOk
2026-06-19 23:38:57 +02:00
async function handleRun() {
if (!canSubmit) return
setSubmitting(true)
setError(null)
2026-07-08 12:18:52 +02:00
// 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 =
2026-07-05 16:50:06 +02:00
backend === "pbs" &&
2026-07-08 12:18:52 +02:00
pbsEncrypt &&
2026-07-05 16:50:06 +02:00
!pbsRecoveryStatus?.has_keyfile
2026-07-08 12:18:52 +02:00
if (needsInstall) {
if (pbsEncryptMode === "existing" && !pbsPveMatch && !pbsImportFile && !pbsImportPath.trim()) {
setError("Pick a keyfile file or enter an absolute path on this host.")
setSubmitting(false)
return
}
if (pbsUploadToPbs && pbsRecoveryPass !== pbsRecoveryPass2) {
setError("Recovery passphrases don't match.")
2026-07-05 09:30:13 +02:00
setSubmitting(false)
return
}
setPbsImportBusy(true)
try {
2026-07-08 12:18:52 +02:00
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<string, unknown> = {
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()
}
2026-07-05 09:30:13 +02:00
await fetchApi("/api/host-backups/pbs-encryption/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
2026-07-08 12:18:52 +02:00
body: JSON.stringify(body),
2026-07-05 09:30:13 +02:00
})
mutatePbsRecovery()
} catch (e) {
const err = e as Error & { body?: { tool_output?: string; tool_exit_code?: number } }
const detail = err.body?.tool_output
? `${err.message}\n\nproxmox-backup-client output:\n${err.body.tool_output}`
: err.message
2026-07-08 12:18:52 +02:00
setError(`Encryption setup failed: ${detail || String(e)}`)
2026-07-05 09:30:13 +02:00
setPbsImportBusy(false)
setSubmitting(false)
return
}
setPbsImportBusy(false)
}
2026-07-08 12:18:52 +02:00
// NOTE: recovery escrow is now bundled into the /pbs-encryption/import
// call above. No separate /pbs-recovery/setup pass is needed.
2026-06-19 23:38:57 +02:00
try {
const body: Record<string, unknown> = {
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
2026-07-05 09:30:13 +02:00
body.pbs_encrypt_mode = pbsEncryptMode
2026-06-19 23:38:57 +02:00
} else if (backend === "local") {
if (localDestDir.trim()) body.local_dest_dir = localDestDir.trim()
} else if (backend === "borg") {
2026-06-20 21:59:19 +02:00
// Passphrase + encrypt_mode are inherited from the destination
// (borg-pass-<name>.txt sidecar + 4th field of borg-targets.txt).
2026-06-19 23:38:57 +02:00
body.borg_repo = borgRepoSelected
}
2026-06-20 21:59:19 +02:00
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)
2026-06-19 23:38:57 +02:00
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setSubmitting(false)
}
}
return (
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose() }}>
<DialogContent className="max-w-2xl max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<PlayCircle className="h-5 w-5 text-blue-500" />
Run a one-shot backup
</DialogTitle>
<DialogDescription>
Step {step} of 2 · The backup runs once. It appears in the list with a "manual" badge and won't fire again.
</DialogDescription>
</DialogHeader>
<div className="flex items-center gap-1">
{[1, 2].map((n) => (
<div
key={n}
className={`h-1 flex-1 rounded-full ${n <= step ? "bg-blue-500" : "bg-border"}`}
/>
))}
</div>
<div className="overflow-y-auto pr-2 -mr-2 space-y-4 flex-1">
{step === 1 && (
<div className="space-y-4">
<div>
<Label>Backend</Label>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2 mt-1">
{(["pbs", "local", "borg"] as const).map((b) => {
const Icon = b === "pbs" ? Server : b === "local" ? HardDrive : Archive
const desc = b === "pbs"
? "Proxmox Backup Server."
: b === "local"
? "tar.zst archive into a local directory."
: "Borg repo (SSH or local/USB disk)."
return (
<button
key={b}
type="button"
onClick={() => setBackend(b)}
className={`text-left p-3 rounded-md border ${backend === b ? "border-blue-500 bg-blue-500/5" : "border-border bg-background/40"} hover:bg-white/5 transition-colors`}
>
<div className="flex items-center gap-2 font-medium text-sm">
<Icon className="h-4 w-4" />
{b.toUpperCase()}
</div>
<div className="text-xs text-muted-foreground mt-1">{desc}</div>
</button>
)
})}
</div>
</div>
<div>
<Label>Backup profile</Label>
<div className="grid grid-cols-2 gap-2 mt-1">
<button
type="button"
onClick={() => setProfileMode("default")}
className={`text-left p-3 rounded-md border ${profileMode === "default" ? "border-blue-500 bg-blue-500/5" : "border-border bg-background/40"} hover:bg-white/5 transition-colors`}
>
<div className="text-sm font-medium">Default</div>
<div className="text-xs text-muted-foreground mt-1">
Critical config paths ({defaultPaths.length} entries).
</div>
</button>
<button
type="button"
onClick={() => setProfileMode("custom")}
className={`text-left p-3 rounded-md border ${profileMode === "custom" ? "border-blue-500 bg-blue-500/5" : "border-border bg-background/40"} hover:bg-white/5 transition-colors`}
>
<div className="text-sm font-medium">Custom</div>
<div className="text-xs text-muted-foreground mt-1">
Pick which paths to include.
</div>
</button>
</div>
</div>
{profileMode === "custom" && (
<div>
<Label>Paths to include ({customPaths.size}/{defaultPaths.length})</Label>
<ScrollArea className="h-48 mt-1 rounded-md border border-border p-2">
{defaultPaths.map((p) => (
<label
key={p}
className="flex items-center gap-2 py-1 px-1 hover:bg-white/5 rounded text-xs cursor-pointer"
>
<Checkbox
checked={customPaths.has(p)}
onCheckedChange={() => togglePath(p)}
/>
<span className="font-mono">{p}</span>
</label>
))}
</ScrollArea>
</div>
)}
</div>
)}
{step === 2 && (
<div className="space-y-4">
{backend === "pbs" && (
<>
<div>
<Label>PBS repository</Label>
{destResp?.pbs?.length ? (
<div className="mt-1 flex items-center gap-2">
<Select value={pbsRepository} onValueChange={setPbsRepository}>
2026-06-21 00:35:22 +02:00
<SelectTrigger className="flex-1 min-w-0">
{/* Manual render: SelectValue's default mode
duplicates the item children and cuts
them in the middle on mobile. We render
"name — repository" ourselves with a
proper trailing ellipsis. */}
{(() => {
const sel = destResp.pbs.find((r) => r.repository === pbsRepository)
if (!sel) {
return <span className="truncate text-muted-foreground">Pick a PBS repository</span>
}
return (
<span className="truncate font-mono text-left">
{sel.name}
<span className="text-muted-foreground"> {sel.repository}</span>
</span>
)
})()}
2026-06-19 23:38:57 +02:00
</SelectTrigger>
<SelectContent>
{destResp.pbs.map((r) => (
<SelectItem key={`${r.name}-${r.repository}`} value={r.repository}>
<span className="font-mono">{r.name}</span>
<span className="text-muted-foreground ml-2"> {r.repository}</span>
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
size="sm"
variant="outline"
className="shrink-0 bg-blue-500/10 border-blue-500/40 !text-blue-400 hover:bg-blue-500/20 hover:!text-blue-300"
onClick={() => setAddingDestType("pbs")}
title="Save another PBS repository"
>
<Plus className="h-3.5 w-3.5 mr-1" />
Add another
</Button>
</div>
) : (
<div className="mt-1 p-3 rounded-md border border-amber-500/40 bg-amber-500/5 text-xs space-y-2">
<div>No PBS repository configured yet. You can add one without leaving this dialog.</div>
<Button
type="button"
size="sm"
variant="outline"
className="bg-blue-500/10 border-blue-500/40 !text-blue-400 hover:bg-blue-500/20 hover:!text-blue-300"
onClick={() => setAddingDestType("pbs")}
>
<Plus className="h-3.5 w-3.5 mr-1" />
Add PBS repository
</Button>
</div>
)}
</div>
<div>
<Label htmlFor="manualPbsBackupId">Group name in PBS</Label>
<Input
id="manualPbsBackupId"
value={pbsBackupId}
onChange={(e) => setPbsBackupId(e.target.value)}
placeholder="Leave blank for the default"
className="font-mono mt-1"
/>
</div>
2026-06-20 21:59:19 +02:00
<div className="pt-2 border-t border-border space-y-3">
2026-07-05 16:50:06 +02:00
<label className="flex items-start gap-2 cursor-pointer">
<Checkbox
checked={pbsEncrypt}
onCheckedChange={(v) => {
const checked = !!v
if (!checked) {
setPbsEncryptMode("none")
} else {
setPbsEncryptMode(pbsRecoveryStatus?.has_keyfile ? "existing" : "new")
}
}}
className="mt-0.5"
/>
<div className="flex-1">
<div className="inline-flex items-center gap-1.5 text-sm">
<Lock className="h-3.5 w-3.5 text-emerald-400" />
Encrypt this backup (client-side keyfile)
</div>
<p className="text-[11px] text-muted-foreground mt-1">
Uses the shared keyfile at <code className="font-mono">/usr/local/share/proxmenux/pbs-key.conf</code>.
2026-07-05 09:30:13 +02:00
</p>
</div>
2026-07-05 16:50:06 +02:00
</label>
{pbsEncrypt && !pbsRecoveryStatus?.has_keyfile && (
<div className="space-y-2 rounded-md border border-emerald-500/30 bg-emerald-500/5 p-3">
<div className="text-[11px] font-medium">
No encryption key is stored on this host. Choose how to set one up:
</div>
<label className="flex items-start gap-2 cursor-pointer text-[11px]">
<input
type="radio"
name="manualPbsEncryptSetup"
checked={pbsEncryptMode === "new"}
onChange={() => setPbsEncryptMode("new")}
className="mt-1"
/>
<div className="flex-1">
<div className="font-medium">Generate a new keyfile (per host safest isolation)</div>
<div className="text-muted-foreground">Creates a fresh keyfile. Set a recovery passphrase (below) to survive host loss.</div>
</div>
</label>
<label className="flex items-start gap-2 cursor-pointer text-[11px]">
<input
type="radio"
name="manualPbsEncryptSetup"
checked={pbsEncryptMode === "existing"}
onChange={() => setPbsEncryptMode("existing")}
className="mt-1"
/>
<div className="flex-1">
<div className="font-medium">Import an existing keyfile (shared across hosts)</div>
<div className="text-muted-foreground">Reuse the same keyfile another host already has.</div>
</div>
</label>
2026-07-08 12:18:52 +02:00
{pbsEncryptMode === "existing" && pbsPveMatch && (
<div className="pt-1 rounded-md border border-emerald-500/40 bg-emerald-500/5 p-2.5 text-[11px] space-y-1">
<div className="flex items-start gap-1.5">
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-400 mt-0.5 shrink-0" />
<div className="flex-1">
<div className="font-medium text-foreground">Auto-detected from PVE storage &apos;{pbsPveMatch.name}&apos;</div>
<div className="text-muted-foreground mt-0.5">
Existing key at <code className="font-mono text-[10.5px]">{pbsPveMatch.path}</code>
</div>
<div className="text-muted-foreground mt-0.5">Will be copied to the ProxMenux state directory. No file upload needed.</div>
</div>
</div>
</div>
)}
{pbsEncryptMode === "existing" && !pbsPveMatch && (
2026-07-05 23:58:58 +02:00
<div className="space-y-2 pt-1">
<div className="space-y-1.5">
<Label htmlFor="manualPbsImportFile" className="text-[11px] font-medium">
2026-07-08 12:18:52 +02:00
Upload from your machine
2026-07-05 23:58:58 +02:00
</Label>
<Input
id="manualPbsImportFile"
type="file"
accept=".conf,.key,application/json,text/plain"
onChange={(e) => setPbsImportFile(e.target.files?.[0] ?? null)}
2026-07-08 12:18:52 +02:00
disabled={pbsImportBusy || !!pbsImportPath.trim()}
2026-07-05 23:58:58 +02:00
className="h-8 text-[11px]"
/>
</div>
2026-07-08 12:18:52 +02:00
<div className="text-[10px] text-center text-muted-foreground"> or </div>
2026-07-05 23:58:58 +02:00
<div className="space-y-1.5">
2026-07-08 12:18:52 +02:00
<Label htmlFor="manualPbsImportPath" className="text-[11px] font-medium">
Absolute path on this host
2026-07-05 23:58:58 +02:00
</Label>
<Input
2026-07-08 12:18:52 +02:00
id="manualPbsImportPath"
type="text"
value={pbsImportPath}
onChange={(e) => setPbsImportPath(e.target.value)}
disabled={pbsImportBusy || !!pbsImportFile}
2026-07-14 19:22:33 +02:00
placeholder="e.g. /etc/pve/priv/storage/<NAME>.enc or /root/my-pbs-key"
2026-07-05 23:58:58 +02:00
className="h-8 text-[11px] font-mono"
/>
<p className="text-[10px] text-muted-foreground">
2026-07-08 12:18:52 +02:00
Either option works. The file lands at <code className="font-mono">/usr/local/share/proxmenux/pbs-key.conf</code> and every subsequent encrypted backup reuses it.
2026-07-05 23:58:58 +02:00
</p>
</div>
2026-07-05 16:50:06 +02:00
</div>
)}
</div>
)}
2026-06-20 21:59:19 +02:00
{pbsEncrypt && (
2026-07-08 12:18:52 +02:00
<div className="pl-7 space-y-3 rounded-md border border-blue-500/30 bg-blue-500/5 p-3">
{pbsRecoveryStatus?.has_keyfile && !pbsRecoveryChange && (
2026-06-21 00:35:22 +02:00
<div className="flex items-start gap-2 text-[11px]">
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-400 mt-0.5 shrink-0" />
<div className="flex-1">
2026-07-08 12:18:52 +02:00
<div className="text-foreground">Keyfile installed. This backup reuses it silently.</div>
<div className="text-muted-foreground mt-0.5">The current PBS-upload setting stays in effect.</div>
2026-06-21 00:35:22 +02:00
</div>
<button
type="button"
onClick={() => setPbsRecoveryChange(true)}
className="text-blue-400 hover:text-blue-300 underline underline-offset-2 shrink-0"
>
Change
</button>
</div>
2026-07-08 12:18:52 +02:00
)}
{pbsRecoveryStatus?.has_keyfile && pbsRecoveryChange && (
<>
<KeyfileActionsBar
mutateStatus={mutatePbsRecovery}
escrowMode={pbsRecoveryStatus?.escrow_mode}
/>
<button
type="button"
onClick={() => setPbsRecoveryChange(false)}
className="text-[11px] text-muted-foreground hover:text-foreground underline underline-offset-2"
>
Done hide keyfile options
</button>
</>
)}
{!pbsRecoveryStatus?.has_keyfile && (
2026-06-21 00:35:22 +02:00
<>
<div>
2026-07-08 12:18:52 +02:00
<div className="text-[11px] font-medium text-foreground mb-2">Upload key to PBS?</div>
<div className="grid gap-1.5">
<label className="flex items-start gap-2 cursor-pointer text-[11px]">
<input
type="radio"
name="pbsUploadToPbsManual"
checked={!pbsUploadToPbs}
onChange={() => setPbsUploadToPbs(false)}
className="mt-1"
/>
<div className="flex-1">
<div className="font-medium">No, keep local only</div>
<div className="text-muted-foreground">Keyfile stays only at <code className="font-mono text-[10.5px]">/usr/local/share/proxmenux/pbs-key.conf</code>. You are responsible for keeping an offsite copy losing it makes every encrypted backup unreadable.</div>
</div>
</label>
<label className="flex items-start gap-2 cursor-pointer text-[11px]">
<input
type="radio"
name="pbsUploadToPbsManual"
checked={pbsUploadToPbs}
onChange={() => setPbsUploadToPbs(true)}
className="mt-1"
/>
<div className="flex-1">
<div className="font-medium">Yes, upload</div>
<div className="text-muted-foreground">Set a recovery passphrase now. ProxMenux uploads a passphrase-wrapped copy of the keyfile to PBS with every backup so you can recover it on a reinstalled host with just the passphrase.</div>
</div>
</label>
</div>
2026-06-21 00:35:22 +02:00
</div>
2026-07-08 12:18:52 +02:00
{pbsUploadToPbs ? (
<div className="space-y-2 pt-1 border-t border-blue-500/20">
<div>
<Label htmlFor="manualPbsRecPass" className="text-[11px]">Recovery passphrase</Label>
<Input
id="manualPbsRecPass"
type="password"
value={pbsRecoveryPass}
onChange={(e) => setPbsRecoveryPass(e.target.value)}
placeholder="Long random string — write it down somewhere safe"
className="font-mono mt-1 h-8 text-xs"
/>
</div>
<div>
<Label htmlFor="manualPbsRecPass2" className="text-[11px]">Confirm passphrase</Label>
<Input
id="manualPbsRecPass2"
type="password"
value={pbsRecoveryPass2}
onChange={(e) => setPbsRecoveryPass2(e.target.value)}
placeholder="Type it again"
className="font-mono mt-1 h-8 text-xs"
/>
{pbsRecoveryPass && pbsRecoveryPass2 && pbsRecoveryPass !== pbsRecoveryPass2 && (
<p className="text-[11px] text-red-400 mt-1">Passphrases don't match.</p>
)}
</div>
</div>
) : (
<div className="pt-1 border-t border-blue-500/20 text-[11px] text-amber-300 bg-amber-500/10 border border-amber-500/30 rounded px-3 py-2 leading-relaxed space-y-1">
<div className="font-semibold">Keep an offsite copy of the keyfile.</div>
<div>After this backup, copy <code className="font-mono text-[10.5px] bg-amber-500/20 rounded px-1">/usr/local/share/proxmenux/pbs-key.conf</code> to an external medium (USB, password manager, another host).</div>
<div>Without that copy, losing this host makes every encrypted backup on PBS unreadable.</div>
</div>
2026-06-21 00:35:22 +02:00
)}
</>
)}
2026-06-20 21:59:19 +02:00
</div>
)}
</div>
2026-06-19 23:38:57 +02:00
</>
)}
{backend === "local" && (
<div className="space-y-2">
2026-06-20 21:59:19 +02:00
<Label>Local destination</Label>
{(destResp?.local?.entries?.length ?? 0) > 0 && (
<div className="flex items-center gap-2">
<Select
value={localDestDir || (destResp?.local?.effective ?? "")}
onValueChange={(v) => setLocalDestDir(v)}
>
<SelectTrigger className="flex-1">
<SelectValue placeholder="Pick a configured local destination" />
</SelectTrigger>
<SelectContent>
{destResp?.local?.entries?.map((e) => (
<SelectItem key={e.path} value={e.path}>
<span className="font-mono">{e.path}</span>
{e.source === "default" && (
<span className="text-muted-foreground ml-2 text-[10px] uppercase">default</span>
)}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
size="sm"
variant="outline"
className="shrink-0 bg-blue-500/10 border-blue-500/40 !text-blue-400 hover:bg-blue-500/20 hover:!text-blue-300"
onClick={() => setAddingDestType("local")}
title="Save another local destination"
>
<Plus className="h-3.5 w-3.5 mr-1" />
Add another
</Button>
</div>
)}
<p className="text-xs text-muted-foreground">
Pick one of the configured local destinations, or type a custom directory path below. Empty input falls back to the selected destination above.
</p>
2026-06-19 23:38:57 +02:00
<Input
id="manualLocalDest"
value={localDestDir}
onChange={(e) => setLocalDestDir(e.target.value)}
placeholder={destResp?.local?.effective || "/var/lib/vz/dump"}
className="font-mono"
/>
</div>
)}
{backend === "borg" && (
<>
<div>
<Label>Borg repository</Label>
{destResp?.borg?.length ? (
<div className="mt-1 flex items-center gap-2">
<Select value={borgRepoSelected} onValueChange={setBorgRepoSelected}>
2026-06-21 00:35:22 +02:00
<SelectTrigger className="flex-1 min-w-0">
{(() => {
const sel = destResp.borg.find((r) => r.repository === borgRepoSelected)
if (!sel) {
return <span className="truncate text-muted-foreground">Pick a Borg repository</span>
}
return (
<span className="truncate font-mono text-left">
{sel.name}
<span className="text-muted-foreground"> — {sel.repository}</span>
</span>
)
})()}
2026-06-19 23:38:57 +02:00
</SelectTrigger>
<SelectContent>
{destResp.borg.map((r) => (
<SelectItem key={`${r.name}-${r.repository}`} value={r.repository}>
<span className="font-mono">{r.name}</span>
<span className="text-muted-foreground ml-2">— {r.repository}</span>
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
size="sm"
variant="outline"
className="shrink-0 bg-fuchsia-500/10 border-fuchsia-500/40 !text-fuchsia-400 hover:bg-fuchsia-500/20 hover:!text-fuchsia-300"
onClick={() => setAddingDestType("borg")}
title="Save another Borg repository"
>
<Plus className="h-3.5 w-3.5 mr-1" />
Add another
</Button>
</div>
) : (
<div className="mt-1 p-3 rounded-md border border-amber-500/40 bg-amber-500/5 text-xs space-y-2">
<div>No Borg repository configured yet. You can add one without leaving this dialog (SSH or local/USB path supported).</div>
<Button
type="button"
size="sm"
variant="outline"
className="bg-fuchsia-500/10 border-fuchsia-500/40 !text-fuchsia-400 hover:bg-fuchsia-500/20 hover:!text-fuchsia-300"
onClick={() => setAddingDestType("borg")}
>
<Plus className="h-3.5 w-3.5 mr-1" />
Add Borg repository
</Button>
</div>
)}
</div>
2026-06-20 21:59:19 +02:00
{(() => {
const dest = destResp?.borg?.find((r) => r.repository === borgRepoSelected)
if (!dest) return null
const mode = dest.encrypt_mode || "repokey"
return (
<p className="text-[11px] text-muted-foreground">
Encryption + passphrase are taken from the destination ({mode === "none" ? "no encryption" : mode}). Edit the destination if you need to change them.
</p>
)
})()}
2026-06-19 23:38:57 +02:00
</>
)}
{/* Summary — mirrors the styling of the JobDetailModal. */}
<div className="rounded-md border border-border bg-background/40 p-3 space-y-2 text-xs">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">Summary</div>
2026-06-20 21:59:19 +02:00
<div className="flex items-center gap-2 flex-wrap">
2026-06-19 23:38:57 +02:00
<span className="text-muted-foreground">Backend:</span>
<Badge variant="outline" className={`text-[10px] uppercase tracking-wide ${methodBadgeCls(backend)}`}>
{backend}
</Badge>
<Badge variant="outline" className="text-[10px] text-purple-400 border-purple-400/40 bg-purple-500/5">
manual / one-shot
</Badge>
</div>
2026-06-20 21:59:19 +02:00
<div className="flex items-center gap-1.5 flex-wrap">
2026-06-19 23:38:57 +02:00
<FileSearch className="h-3 w-3 text-green-500/80" />
<span className="text-green-500/90 uppercase tracking-wider text-[10px]">profile:</span>
<span className="text-foreground">{profileMode}</span>
<span className="text-muted-foreground">({profileMode === "default" ? defaultPaths.length : customPaths.size} paths)</span>
</div>
</div>
{error && (
<div className="text-xs text-red-500 px-3 py-2 rounded-md border border-red-500/30 bg-red-500/10 whitespace-pre-wrap break-words">
2026-06-19 23:38:57 +02:00
{error}
</div>
)}
</div>
)}
</div>
<div className="flex items-center justify-between gap-2 pt-3 border-t border-border">
<Button variant="ghost" onClick={onClose} disabled={submitting}>
Cancel
</Button>
<div className="flex items-center gap-2">
{step > 1 && (
<Button
variant="outline"
onClick={() => setStep((s) => (s - 1) as 1 | 2)}
disabled={submitting}
>
<ChevronLeft className="h-4 w-4 mr-1" />
Back
</Button>
)}
{step < 2 ? (
<Button
onClick={() => setStep(2)}
disabled={!canAdvanceFrom1}
>
Next
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
) : (
<Button
onClick={handleRun}
disabled={!canSubmit || submitting}
2026-06-20 21:59:19 +02:00
className="bg-purple-500 hover:bg-purple-600 text-white"
2026-06-19 23:38:57 +02:00
>
{submitting ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : (
<PlayCircle className="h-4 w-4 mr-2" />
)}
Run backup
</Button>
)}
</div>
</div>
</DialogContent>
{/* AddDestinationDialog spawned from inside the manual-backup
dialog. Same auto-select-on-save behavior as the wizard. */}
<AddDestinationDialog
type={addingDestType}
onClose={() => setAddingDestType(null)}
onSaved={(repo) => {
if (repo && (addingDestType === "pbs" || addingDestType === "borg")) {
setPendingAutoSelectDest({ kind: addingDestType, repository: repo })
}
setAddingDestType(null)
mutateDest()
}}
/>
</Dialog>
)
}
// ──────────────────────────────────────────────────────────────
// 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 =
2026-06-20 21:59:19 +02:00
| { id: string; kind: "local"; path: string; source: "default" | "custom"; removable: boolean; jobsUsing: string[] }
2026-06-19 23:38:57 +02:00
| {
id: string; kind: "pbs"; name: string; repository: string;
source: "proxmox" | "manual"; fingerprint?: string; removable: boolean
2026-06-20 21:59:19 +02:00
jobsUsing: string[]
2026-06-19 23:38:57 +02:00
}
| {
id: string; kind: "borg"; name: string; repository: string;
isSsh: boolean; ssh?: { user: string; host: string; remotePath: string };
2026-06-20 21:59:19 +02:00
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[]
2026-06-19 23:38:57 +02:00
}
interface CapacityInfo {
id: string
total?: number
available?: number
used?: number
is_usb?: boolean
remote?: boolean
error?: string
}
// Parse a Borg repository string into {user, host, remotePath} when
// it's an SSH URL — used for the capacity probe over ssh.
function parseBorgSsh(repo: string): { user: string; host: string; remotePath: string } | null {
const m = repo.match(/^ssh:\/\/([^@]+)@([^/]+)\/(.+)$/)
if (!m) return null
return { user: m[1], host: m[2], remotePath: `/${m[3]}` }
}
function DestinationsSection({
destinations,
onChanged,
}: {
destinations?: DestinationsResp
onChanged: () => void
}) {
const [configuring, setConfiguring] = useState<boolean>(false)
2026-06-20 21:59:19 +02:00
const [editingDest, setEditingDest] = useState<EditingDest | null>(null)
const [confirmingDest, setConfirmingDest] = useState<UnifiedDest | null>(null)
2026-06-19 23:38:57 +02:00
const [busyKey, setBusyKey] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
2026-06-20 21:59:19 +02:00
// 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<RemoteArchivesResp>(
"/api/host-backups/remote-archives",
fetcher,
{ refreshInterval: 60_000, revalidateOnFocus: false },
)
// Recovery state — only needed when at least one PBS destination
2026-07-08 12:18:52 +02:00
// exists. Drives the "Recover keyfile" banner and the "PBS encryption
// key" management block.
2026-06-20 21:59:19 +02:00
const hasPbs = (destinations?.pbs?.length ?? 0) > 0
const { data: pbsRecoveryStatus, mutate: mutatePbsRecovery } = useSWR<{
2026-07-08 12:18:52 +02:00
has_keyfile: boolean; has_recovery: boolean; has_keyfile_passphrase?: boolean; escrow_mode?: "none" | "local" | "full"; keyfile_fingerprint?: string
2026-06-20 21:59:19 +02:00
}>(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<boolean>(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
}
2026-06-19 23:38:57 +02:00
// 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,
2026-06-20 21:59:19 +02:00
jobsUsing: e.jobs_using || [],
2026-06-19 23:38:57 +02:00
})
}
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",
2026-06-20 21:59:19 +02:00
jobsUsing: r.jobs_using || [],
2026-06-19 23:38:57 +02:00
})
}
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,
2026-06-20 21:59:19 +02:00
encryptMode: r.encrypt_mode,
hasPassphrase: r.has_passphrase,
2026-06-19 23:38:57 +02:00
removable: true,
2026-06-20 21:59:19 +02:00
jobsUsing: r.jobs_using || [],
2026-06-19 23:38:57 +02:00
})
}
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<string, CapacityInfo>(
(capacityResp?.results || []).map((r) => [r.id, r] as const),
)
2026-06-20 21:59:19 +02:00
async function removePbs(name: string, force = false) {
2026-06-19 23:38:57 +02:00
setBusyKey(`pbs:${name}`)
setError(null)
try {
2026-06-20 21:59:19 +02:00
await fetchApi(`/api/host-backups/destinations/pbs/${encodeURIComponent(name)}${force ? "?force=true" : ""}`, { method: "DELETE" })
2026-06-19 23:38:57 +02:00
onChanged()
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setBusyKey(null)
}
}
2026-06-20 21:59:19 +02:00
async function removeBorg(name: string, force = false) {
2026-06-19 23:38:57 +02:00
setBusyKey(`borg:${name}`)
setError(null)
try {
2026-06-20 21:59:19 +02:00
await fetchApi(`/api/host-backups/destinations/borg/${encodeURIComponent(name)}${force ? "?force=true" : ""}`, { method: "DELETE" })
2026-06-19 23:38:57 +02:00
onChanged()
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setBusyKey(null)
}
}
2026-06-20 21:59:19 +02:00
async function removeLocal(path: string, force = false) {
2026-06-19 23:38:57 +02:00
setBusyKey(`local:${path}`)
setError(null)
try {
2026-06-20 21:59:19 +02:00
const params = new URLSearchParams({ path })
if (force) params.set("force", "true")
2026-06-19 23:38:57 +02:00
await fetchApi(
2026-06-20 21:59:19 +02:00
`/api/host-backups/destinations/local?${params.toString()}`,
2026-06-19 23:38:57 +02:00
{ method: "DELETE" },
)
onChanged()
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setBusyKey(null)
}
}
2026-06-20 21:59:19 +02:00
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)
}
2026-06-19 23:38:57 +02:00
// 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 (
<div className="space-y-3">
2026-06-20 21:59:19 +02:00
<div className="flex justify-end">
2026-06-19 23:38:57 +02:00
<Button
size="sm"
2026-06-20 21:59:19 +02:00
className="h-8 px-3 bg-blue-500 hover:bg-blue-600 text-white"
2026-06-19 23:38:57 +02:00
onClick={() => setConfiguring(true)}
>
<Plus className="h-4 w-4 mr-1" />
Configure destination
</Button>
</div>
{error && (
<div className="text-xs text-red-500 px-3 py-2 rounded-md border border-red-500/30 bg-red-500/10 whitespace-pre-wrap break-words">
2026-06-19 23:38:57 +02:00
{error}
</div>
)}
2026-06-20 21:59:19 +02:00
{/* 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 && (
<button
type="button"
onClick={() => setRecoveringKeyfile(true)}
className="w-full flex items-start gap-2 px-3 py-2.5 rounded-md border border-emerald-500/40 bg-emerald-500/5 hover:bg-emerald-500/10 transition-colors text-left"
title="A recovery copy of the PBS keyfile was found on this PBS server"
>
<Lock className="h-4 w-4 mt-0.5 text-emerald-400 shrink-0" />
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-foreground">
PBS keyfile is missing recover it from PBS
</div>
<div className="text-[11px] text-muted-foreground mt-0.5">
2026-07-05 16:50:06 +02:00
An encrypted recovery copy of the keyfile is available ({pbsRecoveryAvailable!.snapshots.length} backup{pbsRecoveryAvailable!.snapshots.length === 1 ? "" : "s"}). Click to rebuild the keyfile using your recovery passphrase.
2026-06-20 21:59:19 +02:00
</div>
</div>
<span className="text-xs text-emerald-300 shrink-0 self-center">Recover </span>
</button>
)}
2026-07-08 12:18:52 +02:00
2026-06-19 23:38:57 +02:00
<div className="space-y-2">
{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}`)
2026-06-20 21:59:19 +02:00
// 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}`
2026-06-19 23:38:57 +02:00
return (
<DestinationRow
key={it.id}
item={it}
capacity={cap}
busy={busy}
unmountBusy={unmountBusy}
2026-06-20 21:59:19 +02:00
onDelete={() => setConfirmingDest(it)}
2026-06-19 23:38:57 +02:00
onUnmount={
2026-06-20 21:59:19 +02:00
umountTarget && cap?.is_usb
? () => unmountUsb(umountTarget)
2026-06-19 23:38:57 +02:00
: undefined
}
2026-06-20 21:59:19 +02:00
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
}
2026-06-19 23:38:57 +02:00
/>
)
})}
</div>
<ConfigureDestinationWizard
open={configuring}
onClose={() => setConfiguring(false)}
onSaved={() => {
setConfiguring(false)
onChanged()
}}
/>
2026-06-20 21:59:19 +02:00
{/* 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. */}
<AddDestinationDialog
type={editingDest?.kind ?? null}
editing={editingDest}
onClose={() => setEditingDest(null)}
onSaved={() => {
setEditingDest(null)
onChanged()
}}
/>
<PbsKeyfileRecoveryDialog
open={recoveringKeyfile}
snapshots={pbsRecoveryAvailable?.snapshots || []}
onClose={() => 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 (
<Dialog open={true} onOpenChange={(v) => { if (!v) setConfirmingDest(null) }}>
<DialogContent className="max-w-md bg-card border-border">
<DialogHeader>
<DialogTitle className="text-base flex items-center gap-2">
<AlertTriangle className={`h-5 w-5 ${jobs.length > 0 ? "text-amber-500" : "text-red-500"}`} />
Remove destination
</DialogTitle>
<DialogDescription className="text-xs">
{jobs.length === 0 && backups === 0
? "Removes the destination from the list. Nothing else depends on it."
: "The destination + its saved credentials will be removed. Backups already stored stay where they are — you decide what to do with them later."}
</DialogDescription>
</DialogHeader>
<div className="text-sm font-mono px-3 py-2 rounded-md border border-border bg-background/40 break-all">
{headline}
</div>
{(jobs.length > 0 || backups > 0) && (
<div className="text-xs space-y-1.5 text-foreground">
{jobs.length > 0 && (
<div className="px-3 py-2 rounded-md border border-amber-500/40 bg-amber-500/5">
<div className="font-medium text-amber-400">
{jobs.length} job{jobs.length === 1 ? "" : "s"} will be deleted (cascade):
</div>
<div className="mt-1 flex flex-wrap gap-1">
{jobs.map((j) => (
<span key={j} className="font-mono text-[11px] px-1.5 py-0.5 rounded bg-background/60 border border-border">
{j}
</span>
))}
</div>
</div>
)}
{backups > 0 && (
<div className="px-3 py-2 rounded-md border border-blue-500/40 bg-blue-500/5">
<span className="font-medium text-blue-400">{backups}</span> backup{backups === 1 ? "" : "s"} already at this destination they are <span className="font-medium">kept</span>.
</div>
)}
</div>
)}
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={() => setConfirmingDest(null)}>
Cancel
</Button>
<Button variant="destructive" onClick={confirmDeleteDest}>
<Trash2 className="h-4 w-4 mr-2" />
{jobs.length > 0 ? "Remove + delete jobs" : "Remove destination"}
</Button>
</div>
</DialogContent>
</Dialog>
)
})()}
2026-06-19 23:38:57 +02:00
</div>
)
}
// 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,
2026-06-20 21:59:19 +02:00
onEdit,
2026-06-19 23:38:57 +02:00
}: {
item: UnifiedDest
capacity?: CapacityInfo
busy: boolean
unmountBusy: boolean
onDelete: () => void
onUnmount?: () => void
2026-06-20 21:59:19 +02:00
onEdit?: () => void
2026-06-19 23:38:57 +02:00
}) {
const accent =
item.kind === "pbs" ? "bg-purple-500/10 text-purple-400 border-purple-500/20"
: item.kind === "borg" ? "bg-fuchsia-500/10 text-fuchsia-400 border-fuchsia-500/20"
: "bg-blue-500/10 text-blue-400 border-blue-500/20"
const iconColor =
item.kind === "pbs" ? "text-purple-400"
: item.kind === "borg" ? "text-fuchsia-400"
: "text-blue-400"
const Icon = item.kind === "pbs" ? Server : item.kind === "borg" ? Archive : HardDrive
const headline =
item.kind === "pbs" ? item.name
: item.kind === "borg" ? item.name
: item.path
const subline =
item.kind === "pbs" ? item.repository
: item.kind === "borg" ? item.repository
: null
const isUsb = !!capacity?.is_usb || (item.kind === "borg" && !item.isSsh && /\/(?:mnt|media)\//.test(item.repository))
const sourceLabel =
item.kind === "local" ? (item.source === "default" ? "built-in default" : "manually added")
: item.kind === "pbs" ? (item.source === "proxmox" ? "Datacenter → Storage" : "manually added")
2026-06-20 21:59:19 +02:00
// Borg targets are always operator-configured (no Datacenter
// auto-discovery path), so they all share the "manually added"
// wording for visual consistency with the Local custom row.
: "manually added"
2026-06-19 23:38:57 +02:00
const pct = capacity?.total && capacity.available !== undefined
? Math.min(100, Math.round(((capacity.total - capacity.available) / capacity.total) * 100))
: null
return (
<div className="rounded-lg border border-white/10 bg-card p-4">
2026-06-20 21:59:19 +02:00
{/* 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. */}
2026-06-19 23:38:57 +02:00
<div className="space-y-2 mb-3">
2026-06-20 21:59:19 +02:00
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1 flex items-start gap-2 flex-wrap">
<Icon className={`h-5 w-5 flex-shrink-0 ${iconColor} mt-0.5`} />
<h3 className="font-mono font-semibold text-sm break-all">{headline}</h3>
<Badge variant="outline" className={`text-[10px] uppercase tracking-wide ${accent}`}>
{item.kind}
2026-06-19 23:38:57 +02:00
</Badge>
{item.kind === "local" && item.source === "default" && (
<Badge variant="outline" className="text-[10px] uppercase tracking-wide border-border text-muted-foreground">
default
</Badge>
)}
2026-06-20 21:59:19 +02:00
{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 ? (
<Badge variant="outline" className="text-[10px] uppercase tracking-wide border-cyan-500/40 text-cyan-400 bg-cyan-500/5">
ssh
</Badge>
) : isUsb ? (
<Badge variant="outline" className="text-[10px] uppercase tracking-wide border-orange-500/40 text-orange-400 bg-orange-500/5 gap-1">
<HardDrive className="h-2.5 w-2.5" />
USB
</Badge>
) : (
<Badge variant="outline" className="text-[10px] uppercase tracking-wide border-amber-500/40 text-amber-400 bg-amber-500/5">
local
</Badge>
)}
{/* 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" && (
<Badge
variant="outline"
className="text-[10px] uppercase tracking-wide border-emerald-500/40 text-emerald-400 bg-emerald-500/5"
title={`Encrypted (${item.encryptMode})${item.hasPassphrase ? " · passphrase saved" : " · passphrase NOT saved"}`}
>
{/* 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. */}
<Lock className="h-3.5 w-3.5" />
</Badge>
)}
</>
) : isUsb && (
// PBS / Local destinations: USB badge is additive (the kind
// badge alone doesn't say where the path lives).
2026-06-19 23:38:57 +02:00
<Badge variant="outline" className="text-[10px] uppercase tracking-wide border-orange-500/40 text-orange-400 bg-orange-500/5 gap-1">
<HardDrive className="h-2.5 w-2.5" />
USB
</Badge>
)}
2026-06-20 21:59:19 +02:00
</div>
{/* 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. */}
<div className="shrink-0 flex items-start gap-1.5">
{onEdit && (
<button
type="button"
disabled={busy || unmountBusy}
onClick={onEdit}
title="Edit destination — update credentials, encryption, repository path"
className="inline-flex items-center justify-center h-7 w-7 rounded-md border border-emerald-500/40 bg-emerald-500/10 text-emerald-400 hover:bg-emerald-500/20 hover:text-emerald-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Pencil className="h-3.5 w-3.5" />
</button>
)}
{onUnmount && (
<button
type="button"
disabled={unmountBusy || busy}
onClick={onUnmount}
title="Unmount — detach the USB filesystem only. The destination stays in the list so re-plugging the disk picks it up again."
className="inline-flex items-center justify-center h-7 w-7 rounded-md border border-amber-500/40 bg-amber-500/10 text-amber-400 hover:bg-amber-500/20 hover:text-amber-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{unmountBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Power className="h-3.5 w-3.5" />}
</button>
)}
{item.removable ? (
<button
type="button"
disabled={busy || unmountBusy}
onClick={onDelete}
title="Remove from the destinations list — the filesystem on disk is left untouched"
className="inline-flex items-center justify-center h-7 w-7 rounded-md border border-red-500/40 bg-red-500/10 text-red-400 hover:bg-red-500/20 hover:text-red-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Trash2 className="h-3.5 w-3.5" />}
</button>
) : (
<span className="text-[10px] text-muted-foreground italic self-center px-2 whitespace-nowrap">
{item.kind === "local" ? "built-in" : "managed by PVE"}
</span>
)}
</div>
2026-06-19 23:38:57 +02:00
</div>
{subline && (
<p className="text-xs font-mono text-muted-foreground break-all pl-7" title={subline}>
{subline}
</p>
)}
</div>
{/* Capacity bar — matches the height of the <Progress> component
2026-06-25 00:00:12 +02:00
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. */}
2026-06-19 23:38:57 +02:00
{capacity?.total && capacity.available !== undefined && (
<div className="mb-3 h-2 bg-muted rounded-full overflow-hidden">
2026-06-25 00:00:12 +02:00
<div
className={`h-full rounded-full ${getStorageUsageColor(pct ?? 0).bgClass}`}
style={{ width: `${pct}%` }}
/>
2026-06-19 23:38:57 +02:00
</div>
)}
{/* Stats grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
{capacity?.total !== undefined && (
<div>
<p className="text-sm text-muted-foreground">Size</p>
<p className="font-medium">{formatBytes(capacity.total)}</p>
</div>
)}
{capacity?.used !== undefined && capacity.total !== undefined && (
<div>
<p className="text-sm text-muted-foreground">Used</p>
<p className="font-medium">
{formatBytes(capacity.used)}
{pct !== null && <span className="text-muted-foreground"> ({pct}%)</span>}
</p>
</div>
)}
{capacity?.available !== undefined && (
<div>
<p className="text-sm text-muted-foreground">Free</p>
2026-06-25 00:00:12 +02:00
<p
className={`font-medium ${
pct === null ? "" : getStorageUsageColor(pct).textClass
}`}
>
2026-06-19 23:38:57 +02:00
{formatBytes(capacity.available)}
</p>
</div>
)}
<div>
<p className="text-sm text-muted-foreground">Source</p>
<p className="font-medium text-xs">{sourceLabel}</p>
</div>
</div>
{capacity?.error && (
<p className="mt-3 text-[11px] text-muted-foreground italic">
Capacity unavailable: {capacity.error}
</p>
)}
{!capacity && (
<p className="mt-3 text-[11px] text-muted-foreground italic flex items-center gap-1.5">
<Loader2 className="h-3 w-3 animate-spin" /> Probing capacity
</p>
)}
2026-07-08 12:18:52 +02:00
{/* 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. */}
2026-07-14 19:22:33 +02:00
{item.kind === "pbs" && <PbsKeyfileActions pbsRepository={item.repository} />}
2026-06-19 23:38:57 +02:00
</div>
)
}
// Single-button wizard that replaces the old per-backend Add buttons.
// Step 1 lets the operator pick the backend; Step 2 hands off to the
// existing AddDestinationDialog with that backend pre-selected, so we
// don't duplicate the per-backend forms.
function ConfigureDestinationWizard({
open,
onClose,
onSaved,
}: {
open: boolean
onClose: () => void
onSaved: () => void
}) {
const [picked, setPicked] = useState<"pbs" | "local" | "borg" | null>(null)
useEffect(() => {
if (!open) setPicked(null)
}, [open])
if (picked) {
// Hand off to the existing form. Closing or saving collapses the
// whole wizard so the operator lands back in the destinations list.
return (
<AddDestinationDialog
type={picked}
onClose={() => { setPicked(null); onClose() }}
onSaved={() => { setPicked(null); onSaved() }}
/>
)
}
return (
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose() }}>
<DialogContent className="max-w-2xl bg-card border-border">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Plus className="h-5 w-5 text-blue-500" />
Configure destination
</DialogTitle>
<DialogDescription className="text-xs">
Pick the backend you want to add. The next step has the credentials and path fields for that backend only.
</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
{([
{ type: "pbs", label: "PBS", accent: "border-purple-500/40 hover:border-purple-400 text-purple-400", blurb: "Proxmox Backup Server. Incremental, encrypted, dedup." },
{ type: "local", label: "Local", accent: "border-blue-500/40 hover:border-blue-400 text-blue-400", blurb: "tar.zst archive into a local directory or mounted disk." },
{ type: "borg", label: "Borg", accent: "border-fuchsia-500/40 hover:border-fuchsia-400 text-fuchsia-400", blurb: "Borg repo over SSH or on a local / USB disk." },
] as const).map((opt) => (
<button
key={opt.type}
type="button"
onClick={() => setPicked(opt.type)}
className={`text-left rounded-lg border-2 p-4 transition-colors bg-background/40 hover:bg-white/5 ${opt.accent}`}
>
<div className="flex items-center gap-2 mb-1">
{opt.type === "pbs" ? <Server className="h-4 w-4" /> :
opt.type === "local" ? <HardDrive className="h-4 w-4" /> :
<Archive className="h-4 w-4" />}
<span className="font-semibold uppercase">{opt.label}</span>
</div>
<p className="text-xs text-muted-foreground">{opt.blurb}</p>
</button>
))}
</div>
<div className="flex justify-end">
<Button variant="ghost" onClick={onClose}>Cancel</Button>
</div>
</DialogContent>
</Dialog>
)
}
// One dialog handles all three Add flows — the form is small enough
// that branching by type keeps it cleaner than three separate components.
2026-06-20 21:59:19 +02:00
// 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
}
2026-06-19 23:38:57 +02:00
function AddDestinationDialog({
type,
2026-06-20 21:59:19 +02:00
editing,
2026-06-19 23:38:57 +02:00
onClose,
onSaved,
}: {
type: "pbs" | "borg" | "local" | null
2026-06-20 21:59:19 +02:00
editing?: EditingDest | null
2026-06-19 23:38:57 +02:00
onClose: () => void
// The repository string of the just-saved destination, so callers
// can auto-select it in a dropdown (the wizard does this when an
// operator adds a PBS / Borg repo inline). Undefined for `local`
// and for the legacy callers that don't care.
onSaved: (repository?: string) => void
}) {
const [name, setName] = useState("")
// PBS fields
const [server, setServer] = useState("")
const [datastore, setDatastore] = useState("")
const [username, setUsername] = useState("root@pam")
const [password, setPassword] = useState("")
const [fingerprint, setFingerprint] = useState("")
// Borg fields (local mode)
const [borgRepo, setBorgRepo] = useState("")
// Borg SSH mode
const [borgMode, setBorgMode] = useState<"local" | "ssh">("local")
const [borgSshUser, setBorgSshUser] = useState("borg")
const [borgSshHost, setBorgSshHost] = useState("")
const [borgSshRemotePath, setBorgSshRemotePath] = useState("")
const [borgSshKeyPath, setBorgSshKeyPath] = useState("/root/.ssh/proxmenux_borg")
const [generatedKey, setGeneratedKey] = useState<{ public_key: string; authorized_keys_line: string } | null>(null)
const [generatingKey, setGeneratingKey] = useState(false)
2026-06-20 21:59:19 +02:00
// 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("")
2026-06-19 23:38:57 +02:00
// Local field
const [localPath, setLocalPath] = useState("")
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
2026-06-20 21:59:19 +02:00
// 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<DestinationsResp>(
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)
2026-06-19 23:38:57 +02:00
}
2026-06-20 21:59:19 +02:00
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
})()
2026-06-19 23:38:57 +02:00
2026-06-20 21:59:19 +02:00
// 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
2026-06-19 23:38:57 +02:00
const nameValid = /^[a-zA-Z0-9_-]+$/.test(name)
2026-06-20 21:59:19 +02:00
const borgPathValid =
2026-06-19 23:38:57 +02:00
borgMode === "local"
? !!borgRepo.trim()
: !!(borgSshUser.trim() && borgSshHost.trim() && borgSshRemotePath.trim())
2026-06-20 21:59:19 +02:00
// 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
2026-06-19 23:38:57 +02:00
const canSubmit =
type === "pbs"
2026-06-20 21:59:19 +02:00
? nameValid && server.trim() && datastore.trim() &&
(!!password || (isEditing && !!editing?.has_password))
2026-06-19 23:38:57 +02:00
: 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") {
2026-06-20 21:59:19 +02:00
const body: Record<string, unknown> = {
name,
mode: borgMode,
encrypt_mode: borgEncryptionEnabled ? "repokey" : "none",
}
if (borgEncryptionEnabled) {
body.passphrase = borgPassphrase
}
2026-06-19 23:38:57 +02:00
if (borgMode === "local") {
body.repo = borgRepo.trim()
} else {
body.ssh_user = borgSshUser.trim()
body.ssh_host = borgSshHost.trim()
body.ssh_remote_path = borgSshRemotePath.trim()
if (borgSshKeyPath.trim()) body.ssh_key_path = borgSshKeyPath.trim()
}
const resp = await fetchApi<{ repo?: string }>("/api/host-backups/destinations/borg", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
savedRepo = resp?.repo
} else if (type === "local") {
await fetchApi("/api/host-backups/destinations/local", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: localPath.trim() }),
})
}
onSaved(savedRepo)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setSubmitting(false)
}
}
return (
<Dialog open={type !== null} onOpenChange={(v) => { if (!v) onClose() }}>
<DialogContent className="max-w-lg max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
2026-06-20 21:59:19 +02:00
{isEditing ? (
<Pencil className="h-5 w-5 text-emerald-400" />
) : (
<Plus className="h-5 w-5 text-blue-500" />
)}
{isEditing ? "Edit" : "Add"} {type === "pbs" ? "PBS" : type === "borg" ? "Borg" : "local"} destination
2026-06-19 23:38:57 +02:00
</DialogTitle>
</DialogHeader>
<div className="overflow-y-auto -mr-2 pr-2 space-y-3">
{type === "pbs" && (
<>
<div>
<Label htmlFor="pbsName">Name</Label>
2026-06-20 21:59:19 +02:00
<Input id="pbsName" value={name} onChange={(e) => setName(e.target.value)} className="font-mono mt-1" placeholder="my-pbs" disabled={isEditing} />
<p className="text-xs text-muted-foreground mt-1">
{isEditing ? "Name is the key for saved credentials and can't be changed." : "A short identifier. Letters, digits, _ or -."}
</p>
2026-06-19 23:38:57 +02:00
</div>
<div>
<Label htmlFor="pbsServer">Server (host or IP)</Label>
<Input id="pbsServer" value={server} onChange={(e) => setServer(e.target.value)} className="font-mono mt-1" placeholder="192.168.1.10" />
</div>
<div>
<Label htmlFor="pbsDatastore">Datastore</Label>
<Input id="pbsDatastore" value={datastore} onChange={(e) => setDatastore(e.target.value)} className="font-mono mt-1" placeholder="pbs" />
</div>
<div>
<Label htmlFor="pbsUser">Username</Label>
<Input id="pbsUser" value={username} onChange={(e) => setUsername(e.target.value)} className="font-mono mt-1" />
<p className="text-xs text-muted-foreground mt-1">User that runs <code className="font-mono">proxmox-backup-client</code>. Default <code className="font-mono">root@pam</code>.</p>
</div>
<div>
<Label htmlFor="pbsPass">Password</Label>
2026-06-20 21:59:19 +02:00
<Input
id="pbsPass"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="font-mono mt-1"
placeholder={isEditing && editing?.has_password ? "(unchanged — type to replace)" : ""}
/>
{isEditing && editing?.has_password && (
<p className="text-xs text-muted-foreground mt-1">Leave blank to keep the saved password.</p>
)}
2026-06-19 23:38:57 +02:00
</div>
<div>
<Label htmlFor="pbsFp">Fingerprint (optional)</Label>
<Input id="pbsFp" value={fingerprint} onChange={(e) => setFingerprint(e.target.value)} className="font-mono mt-1" placeholder="aa:bb:cc:…" />
<p className="text-xs text-muted-foreground mt-1">Required only if the PBS uses a self-signed certificate.</p>
</div>
</>
)}
{type === "borg" && (
<>
<div>
<Label htmlFor="borgName">Name</Label>
2026-06-20 21:59:19 +02:00
<Input id="borgName" value={name} onChange={(e) => setName(e.target.value)} className="font-mono mt-1" placeholder="usb-borg or remote-borg" disabled={isEditing} />
{isEditing && (
<p className="text-xs text-muted-foreground mt-1">Name is the key for the saved passphrase and can't be changed.</p>
)}
2026-06-19 23:38:57 +02:00
</div>
<div>
<Label>Where is the Borg repository?</Label>
<div className="grid grid-cols-2 gap-2 mt-1">
<button
type="button"
onClick={() => setBorgMode("local")}
className={`text-left p-2.5 rounded-md border text-sm ${borgMode === "local" ? "border-blue-500 bg-blue-500/5" : "border-border bg-background/40"} hover:bg-white/5 transition-colors`}
>
<div className="font-medium flex items-center gap-1"><HardDrive className="h-3.5 w-3.5" /> Local / USB</div>
<div className="text-[11px] text-muted-foreground mt-0.5">Path on this host (mounted USB, local dir).</div>
</button>
<button
type="button"
onClick={() => setBorgMode("ssh")}
className={`text-left p-2.5 rounded-md border text-sm ${borgMode === "ssh" ? "border-blue-500 bg-blue-500/5" : "border-border bg-background/40"} hover:bg-white/5 transition-colors`}
>
<div className="font-medium flex items-center gap-1"><Server className="h-3.5 w-3.5" /> Remote (SSH)</div>
<div className="text-[11px] text-muted-foreground mt-0.5">Borg repo on another host over SSH.</div>
</button>
</div>
</div>
{borgMode === "local" ? (
<div className="space-y-3">
<div>
<Label htmlFor="borgRepoIn">Repository path</Label>
<Input id="borgRepoIn" value={borgRepo} onChange={(e) => setBorgRepo(e.target.value)} className="font-mono mt-1" placeholder="/mnt/usb-disk/borgbackup" />
<p className="text-xs text-muted-foreground mt-1">
Absolute path on this host. Pick a USB drive below to auto-fill, or type the path manually.
</p>
</div>
2026-06-20 21:59:19 +02:00
<UsbPicker onPick={(p) => setBorgRepo(p)} excludePaths={usedPaths} />
2026-06-19 23:38:57 +02:00
</div>
) : (
<>
<div>
<Label htmlFor="borgSshUser">SSH user</Label>
<Input id="borgSshUser" value={borgSshUser} onChange={(e) => setBorgSshUser(e.target.value)} className="font-mono mt-1" placeholder="borg" />
<p className="text-xs text-muted-foreground mt-1">
User on the remote host that runs <code className="font-mono">borg serve</code>. Conventionally <code className="font-mono">borg</code>, not <code className="font-mono">root</code>.
</p>
</div>
<div>
<Label htmlFor="borgSshHost">SSH host or IP</Label>
<Input id="borgSshHost" value={borgSshHost} onChange={(e) => setBorgSshHost(e.target.value)} className="font-mono mt-1" placeholder="backup.example.com" />
</div>
<div>
<Label htmlFor="borgSshPath">Remote repository path</Label>
<Input id="borgSshPath" value={borgSshRemotePath} onChange={(e) => setBorgSshRemotePath(e.target.value)} className="font-mono mt-1" placeholder="/backup/borgbackup" />
</div>
<div>
<Label htmlFor="borgKeyPath">SSH key path (on this host)</Label>
<Input id="borgKeyPath" value={borgSshKeyPath} onChange={(e) => setBorgSshKeyPath(e.target.value)} className="font-mono mt-1" />
<p className="text-xs text-muted-foreground mt-1">
If you already have an SSH key registered with the server, point to it here.
Otherwise click <span className="font-medium text-foreground">Generate key</span> below and paste the line shown into the server's authorized_keys.
</p>
</div>
<div className="rounded-md border border-border bg-background/40 p-3 space-y-2">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium">Generate a new SSH key</span>
<Button
type="button"
size="sm"
variant="outline"
className="h-7"
disabled={generatingKey || !borgSshKeyPath.trim()}
onClick={generateBorgKey}
>
{generatingKey ? <Loader2 className="h-3.5 w-3.5 animate-spin mr-1" /> : <Plus className="h-3.5 w-3.5 mr-1" />}
{generatedKey ? "Regenerate" : "Generate key"}
</Button>
</div>
{generatedKey ? (
<>
<p className="text-[11px] text-muted-foreground">
On the Borg server, append this single line to <code className="font-mono">~{borgSshUser}/.ssh/authorized_keys</code>:
</p>
<textarea
readOnly
value={generatedKey.authorized_keys_line}
className="w-full text-[11px] font-mono p-2 rounded border border-border bg-background h-24 resize-none"
onFocus={(e) => e.currentTarget.select()}
/>
</>
) : (
<p className="text-[11px] text-muted-foreground">
Creates an ed25519 key (no passphrase) at the path above if one isn't there already. The public half — restricted to <code className="font-mono">borg serve</code> for the remote path — will appear here for you to copy.
</p>
)}
</div>
</>
)}
2026-06-20 21:59:19 +02:00
{/* Encryption + passphrase live on the destination, not on
each job. Two-mode UI: encrypted (repokey, the borg
recommended default) or none. The other borg modes
(keyfile, authenticated) are still honored by the
backend for legacy shell-created configs but not
exposed here. */}
<div className="space-y-2 pt-2 border-t border-border">
<Label>Encryption</Label>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => setBorgEncryptionEnabled(true)}
className={`text-left p-2.5 rounded-md border text-sm transition-colors ${borgEncryptionEnabled ? "border-fuchsia-500 bg-fuchsia-500/5" : "border-border bg-background/40 hover:bg-white/5"}`}
>
<div className="font-medium">Encrypted (repokey)</div>
<div className="text-[11px] text-muted-foreground">AES-256 + HMAC. Passphrase required to read the repo.</div>
</button>
<button
type="button"
onClick={() => setBorgEncryptionEnabled(false)}
className={`text-left p-2.5 rounded-md border text-sm transition-colors ${!borgEncryptionEnabled ? "border-amber-500 bg-amber-500/5" : "border-border bg-background/40 hover:bg-white/5"}`}
>
<div className="font-medium">No encryption</div>
<div className="text-[11px] text-muted-foreground">Faster but data and checksums are in plain.</div>
</button>
</div>
{borgEncryptionEnabled && (
<div className="space-y-2 pt-1">
<div>
<Label htmlFor="borgPass">Passphrase</Label>
<Input
id="borgPass"
type="password"
value={borgPassphrase}
onChange={(e) => setBorgPassphraseLocal(e.target.value)}
placeholder={isEditing && editing?.has_passphrase
? "(unchanged — type to replace)"
: "Long random string — store it somewhere safe"}
className="font-mono mt-1"
/>
{isEditing && editing?.has_passphrase && (
<p className="text-[11px] text-muted-foreground mt-1">Leave blank to keep the saved passphrase.</p>
)}
</div>
<div>
<Label htmlFor="borgPass2">Confirm passphrase</Label>
<Input
id="borgPass2"
type="password"
value={borgPassphrase2}
onChange={(e) => setBorgPassphrase2(e.target.value)}
placeholder="Type it again"
className="font-mono mt-1"
/>
{borgPassphrase && borgPassphrase2 && borgPassphrase !== borgPassphrase2 && (
<p className="text-[11px] text-red-400 mt-1">Passphrases don't match.</p>
)}
</div>
2026-07-02 20:07:05 +02:00
<div className="mt-1 rounded-md border border-red-500/40 bg-red-500/10 p-2.5">
<div className="flex items-start gap-1.5 text-[11px] text-red-300">
<AlertTriangle className="h-3.5 w-3.5 shrink-0 mt-0.5" />
<div className="space-y-1">
<p>
<span className="font-medium">This passphrase is the ONLY way to access encrypted Borg backups.</span> It's saved server-side at <code className="font-mono">borg-pass-{name || "&lt;name&gt;"}.txt</code> (chmod 0600) so jobs can use it transparently.
</p>
<p>
If you lose or reinstall this host without a copy of the passphrase somewhere else (password manager, offline note, another host, USB stick...), every encrypted archive in this repository becomes <span className="font-semibold">UNRECOVERABLE</span>.
</p>
<p className="font-medium">Save the passphrase somewhere safe NOW, before continuing.</p>
</div>
</div>
</div>
2026-06-20 21:59:19 +02:00
</div>
)}
</div>
2026-06-19 23:38:57 +02:00
</>
)}
{type === "local" && (
<div className="space-y-3">
<div>
<Label htmlFor="localPath">Directory path</Label>
<Input id="localPath" value={localPath} onChange={(e) => setLocalPath(e.target.value)} className="font-mono mt-1" placeholder="/var/lib/vz/dump" />
<p className="text-xs text-muted-foreground mt-1">
Where scheduled / manual local backups write the <code className="font-mono">tar.zst</code> archive. Pick a USB drive below to auto-fill, or type the path manually.
</p>
</div>
2026-06-20 21:59:19 +02:00
<UsbPicker onPick={(p) => setLocalPath(p)} excludePaths={usedPaths} />
2026-06-19 23:38:57 +02:00
</div>
)}
{error && (
<div className="text-xs text-red-500 px-3 py-2 rounded-md border border-red-500/30 bg-red-500/10 whitespace-pre-wrap break-words">
2026-06-19 23:38:57 +02:00
{error}
</div>
)}
</div>
<div className="flex justify-end gap-2 pt-3 border-t border-border">
<Button variant="ghost" onClick={onClose} disabled={submitting}>Cancel</Button>
2026-06-22 11:38:06 +02:00
<Button
onClick={handleSave}
disabled={!canSubmit || submitting}
className="bg-blue-600 hover:bg-blue-700 text-white"
>
{submitting ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <Save className="h-4 w-4 mr-2" />}
2026-06-19 23:38:57 +02:00
Save
</Button>
</div>
</DialogContent>
</Dialog>
)
}
// ──────────────────────────────────────────────────────────────
// UsbPicker — inline picker for mounted / unmounted / empty USB
// drives. Used inside AddDestinationDialog for Local and Borg-local
// so the operator can mount + format USB media without leaving the
// "configure destination" flow.
// ──────────────────────────────────────────────────────────────
2026-06-20 21:59:19 +02:00
function UsbPicker({
onPick,
excludePaths = [],
}: {
onPick: (path: string) => void
// Hide a USB drive ONLY when an existing destination already targets
// its mountpoint root verbatim. Subdirectory uses don't claim the
// whole disk — both Local and Borg can share the same USB by
// pointing at different subdirs (`/mnt/usbX/borgbackup` for Borg,
// `/mnt/usbX/local-dump` for Local). The previous prefix-based
// filter was too greedy and made it impossible to add a second
// destination type once any one of them landed on the disk.
excludePaths?: string[]
}) {
2026-06-19 23:38:57 +02:00
const { data, mutate, isLoading } = useSWR<{ drives: UsbDrive[] }>(
"/api/host-backups/usb-drives",
fetcher,
{ refreshInterval: 0 },
)
2026-06-20 21:59:19 +02:00
const normalized = new Set((excludePaths || []).map((p) => p.replace(/\/+$/, "")))
const drives = (data?.drives ?? []).filter((d) => {
const mp = (d.path_or_device || "").replace(/\/+$/, "")
return !mp || !normalized.has(mp)
})
2026-06-19 23:38:57 +02:00
const [busyKey, setBusyKey] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [formatTarget, setFormatTarget] = useState<UsbDrive | null>(null)
const [formatTyped, setFormatTyped] = useState("")
async function mountAndUse(d: UsbDrive) {
setBusyKey(d.path_or_device)
setError(null)
try {
const resp = await fetchApi<{ mountpoint: string }>(
"/api/host-backups/usb-drives/mount",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
device: d.path_or_device,
label: d.label,
uuid: d.uuid,
}),
},
)
mutate()
if (resp?.mountpoint) onPick(resp.mountpoint)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setBusyKey(null)
}
}
async function doFormat() {
if (!formatTarget) return
if (formatTyped !== formatTarget.path_or_device) return
setBusyKey(formatTarget.path_or_device)
setError(null)
try {
const resp = await fetchApi<{ mountpoint?: string }>(
"/api/host-backups/usb-drives/format",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
device: formatTarget.path_or_device,
confirm_device: formatTyped,
}),
},
)
mutate()
setFormatTarget(null)
setFormatTyped("")
if (resp?.mountpoint) onPick(resp.mountpoint)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setBusyKey(null)
}
}
return (
<>
<div className="rounded-md border border-border bg-background/40 p-3 space-y-2">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 text-xs font-medium">
<HardDrive className="h-3.5 w-3.5 text-orange-400" />
USB drives detected
{drives.length > 0 && (
<Badge variant="outline" className="text-[10px]">{drives.length}</Badge>
)}
</div>
<Button
type="button"
size="sm"
variant="ghost"
className="h-7 text-xs"
onClick={() => mutate()}
disabled={isLoading}
>
{isLoading ? <Loader2 className="h-3 w-3 animate-spin" /> : <RefreshCw className="h-3 w-3" />}
<span className="ml-1">Refresh</span>
</Button>
</div>
{error && (
<div className="text-xs text-red-500 px-2 py-1 rounded border border-red-500/30 bg-red-500/10">
{error}
</div>
)}
{isLoading ? (
<div className="flex items-center gap-2 py-2 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" /> Scanning
</div>
) : drives.length === 0 ? (
<p className="text-[11px] text-muted-foreground italic">
No USB drives detected. Plug a USB stick and click Refresh.
</p>
) : (
<div className="space-y-1.5">
{drives.map((d) => {
const busy = busyKey === d.path_or_device
const stateBadge =
d.state === "mounted"
? { label: "mounted", cls: "border-green-500/40 text-green-400 bg-green-500/5" }
: d.state === "unmounted"
? { label: "unmounted", cls: "border-amber-500/40 text-amber-400 bg-amber-500/5" }
: { label: "unformatted", cls: "border-red-500/40 text-red-400 bg-red-500/5" }
return (
<div
key={d.uuid || d.path_or_device}
className="flex flex-col sm:flex-row sm:items-center justify-between gap-2 p-2 rounded border border-border bg-background"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<code className="font-mono text-xs text-foreground">{d.path_or_device}</code>
<Badge variant="outline" className={`text-[10px] uppercase tracking-wide ${stateBadge.cls}`}>
{stateBadge.label}
</Badge>
{d.size && (
<span className="text-[10px] text-muted-foreground">{d.size}</span>
)}
{d.fstype && (
<span className="text-[10px] text-muted-foreground font-mono">{d.fstype}</span>
)}
{d.label && (
<span className="text-[10px] text-muted-foreground italic">"{d.label}"</span>
)}
</div>
</div>
<div className="shrink-0 flex items-center gap-1">
{d.state === "mounted" && (
<Button
type="button"
size="sm"
variant="outline"
className="h-7 text-xs bg-blue-500/10 border-blue-500/40 !text-blue-400 hover:bg-blue-500/20 hover:!text-blue-300"
onClick={() => onPick(d.path_or_device)}
>
Use
</Button>
)}
{d.state === "unmounted" && (
<Button
type="button"
size="sm"
variant="outline"
className="h-7 text-xs bg-blue-500/10 border-blue-500/40 !text-blue-400 hover:bg-blue-500/20 hover:!text-blue-300"
disabled={busy}
onClick={() => mountAndUse(d)}
>
{busy ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : null}
Mount and use
</Button>
)}
{d.state === "empty" && (
<Button
type="button"
size="sm"
variant="outline"
className="h-7 text-xs bg-red-500/10 border-red-500/40 !text-red-400 hover:bg-red-500/20 hover:!text-red-300"
disabled={busy}
onClick={() => { setFormatTarget(d); setFormatTyped("") }}
>
Format & use
</Button>
)}
</div>
</div>
)
})}
</div>
)}
</div>
{/* Type-to-confirm format dialog — destructive op. */}
<Dialog open={formatTarget !== null} onOpenChange={(v) => { if (!v) { setFormatTarget(null); setFormatTyped("") } }}>
<DialogContent className="max-w-md bg-card border-border">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-base">
<AlertTriangle className="h-5 w-5 text-red-500" />
Format USB drive
</DialogTitle>
<DialogDescription className="text-xs">
This will partition the disk (GPT + a single ext4), then mount it. Everything on this device will be permanently lost.
</DialogDescription>
</DialogHeader>
{formatTarget && (
<div className="space-y-2">
<div className="text-xs font-mono px-3 py-2 rounded-md border border-border bg-background/40 break-all">
{formatTarget.path_or_device}
{formatTarget.size && <span className="text-muted-foreground"> · {formatTarget.size}</span>}
</div>
<Label htmlFor="formatConfirmInput" className="text-xs">
Type the device path exactly to confirm:
</Label>
<Input
id="formatConfirmInput"
value={formatTyped}
onChange={(e) => setFormatTyped(e.target.value)}
placeholder={formatTarget.path_or_device}
className="font-mono"
/>
</div>
)}
<div className="flex justify-end gap-2">
<Button
variant="ghost"
onClick={() => { setFormatTarget(null); setFormatTyped("") }}
disabled={busyKey === formatTarget?.path_or_device}
>
Cancel
</Button>
<Button
variant="destructive"
disabled={formatTyped !== formatTarget?.path_or_device || busyKey === formatTarget?.path_or_device}
onClick={doFormat}
>
{busyKey === formatTarget?.path_or_device ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<AlertTriangle className="h-4 w-4 mr-2" />
)}
Wipe and format
</Button>
</div>
</DialogContent>
</Dialog>
</>
)
}
// ──────────────────────────────────────────────────────────────
// Custom backup paths (Sprint C).
//
// Persisted at $HB_STATE_DIR/backup-extra-paths.txt (one path per line).
// The runner merges this list into every backup — manual or scheduled,
// default or custom profile.
// ──────────────────────────────────────────────────────────────
interface ExtraPathEntry {
path: string
exists: boolean
}
function ExtraPathsSection() {
const { data, mutate } = useSWR<{ paths: ExtraPathEntry[] }>(
"/api/host-backups/extra-paths",
fetcher,
{ refreshInterval: 60000 },
)
const [newPath, setNewPath] = useState("")
const [busyPath, setBusyPath] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
const paths = data?.paths ?? []
async function addPath() {
if (!newPath.trim()) return
setSubmitting(true)
setError(null)
try {
await fetchApi("/api/host-backups/extra-paths", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: newPath.trim() }),
})
setNewPath("")
mutate()
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setSubmitting(false)
}
}
async function removePath(p: string) {
setBusyPath(p)
setError(null)
try {
await fetchApi(`/api/host-backups/extra-paths?path=${encodeURIComponent(p)}`, { method: "DELETE" })
mutate()
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setBusyPath(null)
}
}
return (
<div className="space-y-3">
<p className="text-xs text-muted-foreground">
Paths added here are included in <span className="font-medium text-foreground">every backup</span> (manual or scheduled, default or custom profile) on top of the default profile list. Useful for application data, custom config dirs, etc. ({paths.length} configured)
</p>
<div className="flex flex-col sm:flex-row gap-2">
<Input
value={newPath}
onChange={(e) => setNewPath(e.target.value)}
placeholder="/root/my-folder, /srv/myapp/data, /etc/cron.d ..."
className="font-mono"
onKeyDown={(e) => { if (e.key === "Enter" && newPath.trim()) addPath() }}
/>
<Button
onClick={addPath}
disabled={!newPath.trim() || submitting}
className="shrink-0 bg-blue-500 hover:bg-blue-600 text-white"
>
{submitting ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <Plus className="h-4 w-4 mr-2" />}
Add path
</Button>
</div>
<p className="text-[11px] text-muted-foreground">
Use the full absolute path. <code className="font-mono">/hola</code> is not the same as <code className="font-mono">/root/hola</code> the path has to exist on this host exactly as you type it.
</p>
{error && (
<div className="text-xs text-red-500 px-3 py-2 rounded-md border border-red-500/30 bg-red-500/10 whitespace-pre-wrap break-words">
2026-06-19 23:38:57 +02:00
{error}
</div>
)}
{paths.length === 0 ? (
<div className="text-xs text-muted-foreground py-3 text-center">
No custom paths yet backups use the default profile only.
</div>
) : (
<div className="space-y-1">
{paths.map((p) => (
<div
key={p.path}
className="flex items-center justify-between gap-3 p-2 rounded-md border border-border bg-background/40"
>
<div className="min-w-0 flex-1 flex items-center gap-2">
<span className="font-mono text-xs truncate" title={p.path}>{p.path}</span>
{!p.exists && (
<Badge variant="outline" className="text-[10px] text-amber-500 border-amber-500/40">
missing on disk
</Badge>
)}
</div>
2026-06-20 21:59:19 +02:00
<button
type="button"
2026-06-19 23:38:57 +02:00
disabled={busyPath === p.path}
onClick={() => removePath(p.path)}
title="Remove this custom path"
2026-06-20 21:59:19 +02:00
className="shrink-0 inline-flex items-center justify-center h-7 w-7 rounded-md border border-red-500/40 bg-red-500/10 text-red-400 hover:bg-red-500/20 hover:text-red-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
2026-06-19 23:38:57 +02:00
>
{busyPath === p.path ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Trash2 className="h-3.5 w-3.5" />}
2026-06-20 21:59:19 +02:00
</button>
2026-06-19 23:38:57 +02:00
</div>
))}
</div>
)}
</div>
)
}
// ──────────────────────────────────────────────────────────────
// Backup configuration wrapper (Sprint D.2.a).
//
// Single card hosting Destinations + Custom paths via top-level tabs.
// Future tabs (USB drives, ...) plug in here too.
// ──────────────────────────────────────────────────────────────
function BackupConfigurationCard({
destinations,
onDestChanged,
}: {
destinations?: DestinationsResp
onDestChanged: () => void
}) {
return (
<Card className="bg-card border-border">
<CardHeader className="space-y-0 pb-3">
<div className="flex items-center gap-2">
<Server className="h-5 w-5 text-blue-500" />
<CardTitle className="text-base font-semibold">Backup configuration</CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-6">
{/* Destinations — list + add/remove of PBS / Borg / Local targets.
USB drives are no longer a separate section: they surface in
this list when mounted, and the add-destination wizard can
mount / format them inline. */}
<section className="space-y-2">
<h3 className="text-sm font-medium">Destinations</h3>
<DestinationsSection destinations={destinations} onChanged={onDestChanged} />
</section>
<div className="h-px bg-border" />
{/* Custom paths — extra absolute paths included in every backup */}
<section className="space-y-2">
<h3 className="text-sm font-medium">Custom paths</h3>
<ExtraPathsSection />
</section>
</CardContent>
</Card>
)
}
// ──────────────────────────────────────────────────────────────
// USB drives section (Sprint D.2.b).
//
// Thin Monitor UI around the shell helpers hb_list_usb_partitions,
// hb_mount_usb_partition, hb_format_usb_disk. Mount / unmount are
// safe-ish; format is destructive and gated by a typed-confirmation
// dialog (the operator must re-type the device path) so a misclick
// can't wipe an attached disk.
// ──────────────────────────────────────────────────────────────
interface UsbDrive {
state: "mounted" | "unmounted" | "empty"
path_or_device: string
label: string
size: string
fstype: string
uuid: string
}
function UsbDrivesSection() {
const { data, mutate, isLoading } = useSWR<{ drives: UsbDrive[] }>(
"/api/host-backups/usb-drives",
fetcher,
{ refreshInterval: 30000 },
)
const drives = data?.drives ?? []
const [busyKey, setBusyKey] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [formatTarget, setFormatTarget] = useState<UsbDrive | null>(null)
const [formatTyped, setFormatTyped] = useState("")
async function mountDrive(d: UsbDrive) {
setBusyKey(d.path_or_device)
setError(null)
try {
await fetchApi("/api/host-backups/usb-drives/mount", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
device: d.path_or_device,
label: d.label,
uuid: d.uuid,
}),
})
mutate()
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setBusyKey(null)
}
}
async function unmountDrive(d: UsbDrive) {
if (!confirm(`Unmount ${d.path_or_device}? Any backup job pointing at this path will fail until you remount it.`)) return
setBusyKey(d.path_or_device)
setError(null)
try {
await fetchApi("/api/host-backups/usb-drives/unmount", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mountpoint: d.path_or_device }),
})
mutate()
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setBusyKey(null)
}
}
async function doFormat() {
if (!formatTarget) return
if (formatTyped !== formatTarget.path_or_device) return
setBusyKey(formatTarget.path_or_device)
setError(null)
try {
await fetchApi("/api/host-backups/usb-drives/format", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
device: formatTarget.path_or_device,
confirm_device: formatTyped,
}),
})
mutate()
setFormatTarget(null)
setFormatTyped("")
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setBusyKey(null)
}
}
return (
<div className="space-y-3">
<p className="text-xs text-muted-foreground">
Mount USB drives so they can be picked as a local or Borg target. Drives that already had a filesystem can be remounted as-is; raw drives (no partition table) can be wiped and formatted to <code className="font-mono">ext4</code>.
</p>
{error && (
<div className="text-xs text-red-500 px-3 py-2 rounded-md border border-red-500/30 bg-red-500/10 whitespace-pre-wrap break-words">
2026-06-19 23:38:57 +02:00
{error}
</div>
)}
{isLoading ? (
<div className="text-xs text-muted-foreground py-3 text-center">
<Loader2 className="h-4 w-4 animate-spin inline mr-2" />
Scanning USB drives
</div>
) : drives.length === 0 ? (
<div className="text-xs text-muted-foreground py-3 text-center">
No USB drives detected. Plug one in and refresh Monitor scans for them automatically every 30s.
</div>
) : (
<div className="space-y-2">
{drives.map((d) => {
const isBusy = busyKey === d.path_or_device
const stateBadge =
d.state === "mounted"
? { label: "mounted", cls: "text-emerald-400 border-emerald-400/40" }
: d.state === "unmounted"
? { label: "not mounted", cls: "text-amber-500 border-amber-500/40" }
: { label: "no filesystem", cls: "text-red-400 border-red-400/40" }
return (
<div
key={`${d.state}-${d.path_or_device}-${d.uuid}`}
className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 p-3 rounded-md border border-border bg-background/40"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-mono text-sm font-medium">{d.path_or_device}</span>
<Badge variant="outline" className={`text-[10px] uppercase tracking-wide ${stateBadge.cls}`}>
{stateBadge.label}
</Badge>
</div>
<div className="mt-1 text-[11px] text-muted-foreground flex items-center gap-x-3 gap-y-1 flex-wrap">
{d.label && <span><span className="text-muted-foreground/70">label:</span> <code className="font-mono">{d.label}</code></span>}
{d.size && <span><span className="text-muted-foreground/70">size:</span> <code className="font-mono">{d.size}</code></span>}
{d.fstype && <span><span className="text-muted-foreground/70">fs:</span> <code className="font-mono">{d.fstype}</code></span>}
{d.uuid && <span className="hidden sm:inline" title={`uuid: ${d.uuid}`}><span className="text-muted-foreground/70">uuid:</span> <code className="font-mono">{d.uuid.substring(0, 8)}</code></span>}
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
{d.state === "mounted" && (
<Button
size="sm"
variant="ghost"
className="h-8 px-2"
disabled={isBusy}
onClick={() => unmountDrive(d)}
>
{isBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <XCircle className="h-3.5 w-3.5" />}
<span className="ml-1 text-xs">Unmount</span>
</Button>
)}
{d.state === "unmounted" && (
<Button
size="sm"
variant="ghost"
className="h-8 px-2"
disabled={isBusy}
onClick={() => mountDrive(d)}
>
{isBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <CheckCircle2 className="h-3.5 w-3.5" />}
<span className="ml-1 text-xs">Mount</span>
</Button>
)}
{d.state === "empty" && (
<Button
size="sm"
variant="ghost"
className="h-8 px-2 text-red-500 hover:text-red-400 hover:bg-red-500/10"
disabled={isBusy}
onClick={() => { setFormatTarget(d); setFormatTyped("") }}
title="Wipe + format this disk as ext4"
>
<AlertTriangle className="h-3.5 w-3.5" />
<span className="ml-1 text-xs">Format</span>
</Button>
)}
</div>
</div>
)
})}
</div>
)}
{/* Format confirmation dialog */}
<Dialog open={formatTarget !== null} onOpenChange={(v) => { if (!v) { setFormatTarget(null); setFormatTyped("") } }}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-red-500" />
Wipe and format this disk?
</DialogTitle>
<DialogDescription>
Everything on the disk will be destroyed. This cannot be undone.
</DialogDescription>
</DialogHeader>
{formatTarget && (
<div className="space-y-3 text-sm">
<div className="rounded-md border border-red-500/40 bg-red-500/5 p-3 space-y-1">
<div className="text-[10px] uppercase tracking-wider text-red-400">Target</div>
<div className="font-mono text-base">{formatTarget.path_or_device}</div>
<div className="text-xs text-muted-foreground">Size: {formatTarget.size || "?"}</div>
</div>
<div>
<Label htmlFor="formatConfirm">Type the device path EXACTLY to confirm:</Label>
<Input
id="formatConfirm"
value={formatTyped}
onChange={(e) => setFormatTyped(e.target.value)}
className="font-mono mt-1"
placeholder={formatTarget.path_or_device}
/>
</div>
<p className="text-xs text-muted-foreground">
The disk will be re-partitioned (GPT + single ext4 partition) and mounted automatically.
</p>
</div>
)}
<div className="flex justify-end gap-2 mt-2">
<Button variant="ghost" onClick={() => { setFormatTarget(null); setFormatTyped("") }} disabled={busyKey === formatTarget?.path_or_device}>
Cancel
</Button>
<Button
variant="destructive"
onClick={doFormat}
disabled={formatTyped !== formatTarget?.path_or_device || busyKey === formatTarget?.path_or_device}
>
{busyKey === formatTarget?.path_or_device ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : (
<AlertTriangle className="h-4 w-4 mr-2" />
)}
Wipe and format
</Button>
</div>
</DialogContent>
</Dialog>
</div>
)
}
// ──────────────────────────────────────────────────────────────────────
// JobDetailModal
// ──────────────────────────────────────────────────────────────────────
// Opens when the user clicks a job row. Everything about the job lives
// inside this modal: schedule, retention, profile, destination, the tail
// of the most recent log, and the action buttons (Run / Enable·Disable /
// Edit / Delete). Run polls the job detail every 2s so the user sees the
// result of the manual trigger without having to close-reopen.
// ──────────────────────────────────────────────────────────────────────
function JobDetailModal({
jobId,
onClose,
onEdit,
onRequestDelete,
onChanged,
}: {
jobId: string | null
onClose: () => void
onEdit: (id: string) => void
onRequestDelete: (id: string) => void
onChanged: () => void
}) {
const open = jobId !== null
const [running, setRunning] = useState(false)
const [runBaseline, setRunBaseline] = useState<string | null>(null)
const [runBaselineLogPath, setRunBaselineLogPath] = useState<string | null>(null)
const [actionError, setActionError] = useState<string | null>(null)
const [busy, setBusy] = useState<"" | "toggle">("")
const [showDisableConfirm, setShowDisableConfirm] = useState(false)
const { data: detail, mutate: refetch, isLoading } = useSWR<JobDetail>(
open ? `/api/host-backups/jobs/${encodeURIComponent(jobId!)}` : null,
fetcher,
{ refreshInterval: running ? 2000 : 0 },
)
// While the job is actively running, poll the full log every 1.5 s so
// the modal shows the runner's output in real time — same UX the
// "Run manual backup" dialog provides. SWR is disabled when not
// running, so this only fires during an active trigger.
const { data: liveLog } = useSWR<{ content: string; log_path: string | null; size: number }>(
open && running ? `/api/host-backups/jobs/${encodeURIComponent(jobId!)}/log` : null,
fetcher,
{ refreshInterval: 1500 },
)
// Auto-scroll the live log <pre> to the bottom as new lines arrive,
// otherwise the operator has to manually scroll on every poll cycle.
const liveLogRef = useRef<HTMLPreElement | null>(null)
useEffect(() => {
if (running && liveLogRef.current) {
liveLogRef.current.scrollTop = liveLogRef.current.scrollHeight
}
}, [liveLog, running])
// The runner truncates the .status file at start (writing RUN_AT
// only) and appends RESULT= at finish. So during a run we'll see:
//
// last_run_at = NEW timestamp (advanced past baseline)
// last_result = null
//
// and only when it really finishes:
//
// last_run_at = NEW timestamp
// last_result = "ok" | "failed" | ...
//
// Requiring BOTH conditions keeps the live log streaming visible
// for the whole duration of the run — the previous version exited
// streaming mode as soon as the runner wrote the new RUN_AT.
useEffect(() => {
if (!running || !detail) return
if (
detail.last_run_at &&
detail.last_run_at !== runBaseline &&
detail.last_result
) {
setRunning(false)
setRunBaseline(null)
setRunBaselineLogPath(null)
onChanged()
}
}, [detail, running, runBaseline, onChanged])
2026-06-20 21:59:19 +02:00
// When the modal opens onto a job that's already running (last_run_at
// set but last_result still null), jump straight into streaming mode
// so the operator can resume watching from anywhere — including the
// "Manual backup in progress" banner on the Manual backups card and
// a click on a scheduled job row that's currently mid-run.
useEffect(() => {
if (!open || running || !detail) return
if (detail.last_run_at && !detail.last_result) {
// Baseline = empty so the exit condition still fires (any
// non-empty last_run_at + a non-null last_result will trip it).
setRunBaseline("")
setRunBaselineLogPath(detail.last_log_path ?? null)
setRunning(true)
}
}, [open, running, detail])
2026-06-19 23:38:57 +02:00
// Reset on close.
useEffect(() => {
if (!open) {
setRunning(false)
setRunBaseline(null)
setRunBaselineLogPath(null)
setActionError(null)
setBusy("")
setShowDisableConfirm(false)
}
}, [open])
async function handleRun() {
if (!detail) return
setActionError(null)
setRunBaseline(detail.last_run_at ?? "")
// Remember the previous run's log path so the streaming view can
// tell "the runner hasn't created its log yet" (we'd see the OLD
// path) from "the runner is writing now" (path advanced).
setRunBaselineLogPath(detail.last_log_path ?? null)
setRunning(true)
try {
await fetchApi(`/api/host-backups/jobs/${encodeURIComponent(detail.id)}/run`, { method: "POST" })
setTimeout(() => refetch(), 800)
} catch (e) {
setRunning(false)
setRunBaseline(null)
setRunBaselineLogPath(null)
setActionError(`Failed to run: ${e instanceof Error ? e.message : String(e)}`)
}
}
async function handleToggle() {
if (!detail) return
setActionError(null)
setBusy("toggle")
setShowDisableConfirm(false)
try {
await fetchApi(`/api/host-backups/jobs/${encodeURIComponent(detail.id)}/toggle`, { method: "POST" })
await refetch()
onChanged()
} catch (e) {
setActionError(`Failed to toggle: ${e instanceof Error ? e.message : String(e)}`)
} finally {
setBusy("")
}
}
const lastResult = detail?.last_result ?? null
const lastRunWhen = formatRunAt(detail?.last_run_at ?? null)
const resultBadge = running
? { label: "running", cls: "bg-blue-500/10 border-blue-500/40 text-blue-300" }
: lastResult === "ok"
? { label: "ok", cls: "bg-emerald-500/10 border-emerald-500/40 text-emerald-300" }
: lastResult
? { label: lastResult, cls: "bg-red-500/10 border-red-500/40 text-red-300" }
: null
return (
<>
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose() }}>
{/* Sized down to 3xl now that the log tail lives in the
archive's inspect modal — the job dialog only needs to fit
the schedule, retention chips, paths grid and destination.
overflow-hidden + the inner ScrollArea keep any long repo
or path string from forcing a horizontal scrollbar. */}
<DialogContent className="max-w-3xl bg-card border-border overflow-hidden">
<DialogHeader>
2026-06-22 11:38:06 +02:00
<DialogTitle className="flex items-center gap-2 flex-wrap text-base pr-8">
2026-06-19 23:38:57 +02:00
<DatabaseBackup className="h-5 w-5 text-blue-500" />
<span className="font-mono break-all">{detail?.id ?? jobId}</span>
{detail && (
<>
<Badge variant="outline" className={`text-[10px] uppercase tracking-wide ${methodBadgeCls(detail.method)}`}>
{detail.method}
</Badge>
2026-06-22 11:38:06 +02:00
{/* Encryption badge — mirrors the InspectModal one
so the operator sees the lock in both the summary
list and the job detail view. */}
{(detail.pbs_encrypt || (detail.borg_encrypt_mode && detail.borg_encrypt_mode !== "none")) && (
<Badge
variant="outline"
className="text-emerald-400 border-emerald-500/40 bg-emerald-500/10"
title="Encrypted"
>
<Lock className="h-3.5 w-3.5" />
</Badge>
)}
2026-06-19 23:38:57 +02:00
{detail.attached && (
<Badge variant="outline" className="text-[10px] text-blue-400 border-blue-400/40 bg-blue-500/5">
attached
</Badge>
)}
{!detail.enabled && (
<Badge variant="outline" className="text-[10px] text-amber-500 border-amber-500/40 bg-amber-500/5">
disabled
</Badge>
)}
</>
)}
</DialogTitle>
<DialogDescription className="text-xs">
Schedule, profile, destination and last run for this job.
</DialogDescription>
</DialogHeader>
{actionError && (
<div className="text-xs text-red-500 px-3 py-2 rounded-md border border-red-500/30 bg-red-500/10 whitespace-pre-wrap break-words">
2026-06-19 23:38:57 +02:00
{actionError}
</div>
)}
{isLoading || !detail ? (
<div className="flex items-center gap-2 py-6 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Loading job
</div>
) : (
<ScrollArea className="max-h-[60vh] pr-2">
{(() => {
// One accent color per section. The destination color is
// bound to the job's backend (purple/blue/fuchsia) so the
// section feels like a continuation of the method badge in
// the title; the other sections use neutral accent colors
// that match their semantic (time → amber, content → emerald).
const destAccent =
detail.method === "pbs"
? "text-purple-400"
: detail.method === "borg"
? "text-fuchsia-400"
: "text-blue-400"
return (
<div className="space-y-4 text-sm">
{/* ─── Last run ─────────────────────────── */}
{/* The job modal shows only the verdict + timestamp.
The actual log lives in the archive's inspect
modal where it belongs (one log per .tar.zst). */}
<section className="space-y-2">
<h4 className="text-xs font-semibold uppercase tracking-wide flex items-center gap-1.5 text-green-500">
<Clock className="h-3.5 w-3.5" /> Last run
</h4>
<div className="flex items-center gap-2 flex-wrap text-xs">
{resultBadge ? (
<span className={`px-2 py-0.5 rounded text-[10px] uppercase tracking-wide border inline-flex items-center gap-1 ${resultBadge.cls}`}>
{running && <Loader2 className="h-3 w-3 animate-spin" />}
{resultBadge.label}
</span>
) : (
<span className="text-muted-foreground">never run</span>
)}
{lastRunWhen && <span className="text-muted-foreground">{lastRunWhen}</span>}
</div>
{running && (
// Live mode — poll /log every 1.5 s and auto-scroll
// so the operator watches the runner output as it
// happens. The archive isn't on disk yet, so this
// is the only place to surface the running log.
// Suppressed once the run finishes.
(() => {
const sameOldLog =
!!liveLog?.log_path &&
!!runBaselineLogPath &&
liveLog.log_path === runBaselineLogPath
const showContent = liveLog?.content && !sameOldLog
return (
<div className="rounded-md border border-blue-500/40 bg-blue-500/5 p-2">
<pre
ref={liveLogRef}
className="text-[11px] font-mono whitespace-pre-wrap break-all max-h-64 overflow-auto text-foreground/90"
>
{showContent ? liveLog!.content : "Waiting for runner to start…"}
</pre>
<div className="flex items-center justify-between mt-2">
<span className="text-[10px] text-blue-300 inline-flex items-center gap-1.5">
<Loader2 className="h-3 w-3 animate-spin" />
{showContent
? `live · ${formatBytes(liveLog?.size ?? 0)}`
: "starting…"}
</span>
{showContent && liveLog?.log_path && (
<span className="text-[10px] text-muted-foreground font-mono break-all">
{liveLog.log_path}
</span>
)}
</div>
</div>
)
})()
)}
</section>
{/* ─── Schedule + retention ─────────────── */}
<section className="space-y-2">
<h4 className="text-xs font-semibold uppercase tracking-wide flex items-center gap-1.5 text-green-500">
<Calendar className="h-3.5 w-3.5" /> Schedule
</h4>
<Field
icon={<Calendar className="h-3 w-3 text-green-500/80" />}
label="when"
value={humanizeOnCalendar(detail.on_calendar)}
labelClassName="text-green-500/90"
/>
{detail.next_run && (
<Field
icon={<Clock className="h-3 w-3 text-green-500/80" />}
label="next run"
value={formatNext(detail.next_run)}
labelClassName="text-green-500/90"
/>
)}
<RetentionDisplay retention={detail.retention || {}} />
</section>
{/* ─── Profile + paths ──────────────────── */}
<section className="space-y-2">
<h4 className="text-xs font-semibold uppercase tracking-wide flex items-center gap-1.5 text-green-500">
<FileSearch className="h-3.5 w-3.5" /> Profile
</h4>
<Field
icon={<Server className="h-3 w-3 text-green-500/80" />}
label="mode"
value={detail.profile_mode || "—"}
mono
labelClassName="text-green-500/90"
/>
{detail.paths && detail.paths.length > 0 && (
<PathsDisplay paths={detail.paths} />
)}
</section>
{/* ─── Destination ──────────────────────── */}
<section className="space-y-1">
<h4 className={`text-xs font-semibold uppercase tracking-wide flex items-center gap-1.5 ${destAccent}`}>
<HardDrive className="h-3.5 w-3.5" /> Destination
</h4>
<Field icon={<HardDrive className={`h-3 w-3 ${destAccent} opacity-80`} />} label="target" value={detail.destination || "—"} mono />
{detail.method === "pbs" && (
<>
{detail.pbs_repository && (
<Field icon={<Server className={`h-3 w-3 ${destAccent} opacity-80`} />} label="repository" value={detail.pbs_repository} mono />
)}
{detail.pbs_backup_id && (
<Field icon={<Archive className={`h-3 w-3 ${destAccent} opacity-80`} />} label="backup-id" value={detail.pbs_backup_id} mono />
)}
</>
)}
{detail.method === "borg" && (
<>
{detail.borg_repo && (
<Field icon={<Server className={`h-3 w-3 ${destAccent} opacity-80`} />} label="repo" value={detail.borg_repo} mono />
)}
<Field icon={<FileSearch className={`h-3 w-3 ${destAccent} opacity-80`} />} label="encryption" value={detail.borg_encrypt_mode} mono />
</>
)}
{detail.method === "local" && detail.local_dest_dir && (
<Field icon={<HardDrive className={`h-3 w-3 ${destAccent} opacity-80`} />} label="dir" value={detail.local_dest_dir} mono />
)}
{detail.pve_storage && (
<Field icon={<DatabaseBackup className={`h-3 w-3 ${destAccent} opacity-80`} />} label="pve storage" value={detail.pve_storage} mono />
)}
</section>
</div>
)
})()}
</ScrollArea>
)}
{detail && (
2026-06-22 11:38:06 +02:00
// Layout mirrors the InspectModal footer: primary action on
// the left in solid green (Run, matches Restore), secondary
// Edit next to it in blue outline, then state-changers on
// the right (Disable / Delete) — both outlined to read as
// less prominent than Run.
<div className="border-t border-border pt-3 flex items-center justify-between gap-2 flex-wrap">
<div className="flex items-center gap-2 flex-wrap">
<Button
size="sm"
onClick={handleRun}
disabled={running || busy !== ""}
className="bg-green-600 hover:bg-green-700 text-white"
title={detail.attached
? "Trigger an ad-hoc run now (the PVE timer keeps its own schedule)"
: "Trigger this job now"}
>
{running ? (
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
) : (
<PlayCircle className="h-3.5 w-3.5 mr-1" />
)}
{running ? "Running…" : "Run now"}
</Button>
<Button
size="sm"
variant="outline"
disabled={running || busy !== ""}
className="bg-blue-500/10 border-blue-500/40 !text-blue-400 hover:bg-blue-500/20 hover:!text-blue-300"
onClick={() => onEdit(detail.id)}
>
<Pencil className="h-3.5 w-3.5 mr-1" />
Edit
</Button>
</div>
<div className="flex items-center gap-2 flex-wrap">
<Button
size="sm"
variant="outline"
disabled={running || busy !== ""}
className={detail.enabled
? "bg-amber-500/10 border-amber-500/40 !text-amber-400 hover:bg-amber-500/20 hover:!text-amber-300"
: "bg-emerald-500/10 border-emerald-500/40 !text-emerald-400 hover:bg-emerald-500/20 hover:!text-emerald-300"}
onClick={() => {
if (detail.enabled) setShowDisableConfirm(true)
else handleToggle()
}}
>
{busy === "toggle" ? (
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
) : detail.enabled ? (
<Power className="h-3.5 w-3.5 mr-1" />
) : (
<CheckCircle2 className="h-3.5 w-3.5 mr-1" />
)}
{detail.enabled ? "Disable" : "Enable"}
</Button>
<Button
size="sm"
variant="outline"
disabled={running || busy !== ""}
className="bg-red-500/10 border-red-500/40 !text-red-400 hover:bg-red-500/20 hover:!text-red-300"
onClick={() => onRequestDelete(detail.id)}
>
<Trash2 className="h-3.5 w-3.5 mr-1" />
Delete
</Button>
</div>
2026-06-19 23:38:57 +02:00
</div>
)}
</DialogContent>
</Dialog>
{/* ─── Confirm Disable ──────────────────────────────────── */}
<Dialog open={showDisableConfirm} onOpenChange={setShowDisableConfirm}>
<DialogContent className="max-w-md bg-card border-border">
<DialogHeader>
<DialogTitle className="text-base flex items-center gap-2">
<Power className="h-5 w-5 text-amber-500" />
Disable job
</DialogTitle>
<DialogDescription className="text-xs">
The systemd timer will be stopped no further automatic runs. You can re-enable it later from this same dialog.
</DialogDescription>
</DialogHeader>
<div className="text-sm font-mono px-3 py-2 rounded-md border border-border bg-background/40 break-all">
{detail?.id}
</div>
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={() => setShowDisableConfirm(false)}>
Cancel
</Button>
<Button
variant="outline"
className="bg-amber-500/10 border-amber-500/40 !text-amber-400 hover:bg-amber-500/20 hover:!text-amber-300"
onClick={handleToggle}
>
<Power className="h-3.5 w-3.5 mr-1" />
Disable
</Button>
</div>
</DialogContent>
</Dialog>
</>
)
}
2026-06-20 21:59:19 +02:00
// ──────────────────────────────────────────────────────────────
// ManualJobWatchModal
// ──────────────────────────────────────────────────────────────
// Sibling of JobDetailModal but tailored to manual / one-shot jobs.
// A manual job has no schedule, no retention, no profile picker — and
// once it has run it can't be re-run or edited (it's a frozen
// snapshot of one trigger). So the modal collapses to:
// - the live log when the job is in flight
// - the destination details (only the fields that matter for the
// picked backend)
// - Close + Delete actions (Edit / Run / Disable don't apply)
// ──────────────────────────────────────────────────────────────
function ManualJobWatchModal({
jobId,
onClose,
onChanged,
}: {
jobId: string | null
onClose: () => void
onChanged: () => void
}) {
const open = jobId !== null
const [running, setRunning] = useState(false)
const [runBaselineLogPath, setRunBaselineLogPath] = useState<string | null>(null)
const [actionError, setActionError] = useState<string | null>(null)
const { data: detail, mutate: refetch, isLoading } = useSWR<JobDetail>(
open ? `/api/host-backups/jobs/${encodeURIComponent(jobId!)}` : null,
fetcher,
{ refreshInterval: running ? 2000 : 0 },
)
const { data: liveLog } = useSWR<{ content: string; log_path: string | null; size: number }>(
open && running ? `/api/host-backups/jobs/${encodeURIComponent(jobId!)}/log` : null,
fetcher,
{ refreshInterval: 1500 },
)
const liveLogRef = useRef<HTMLPreElement | null>(null)
useEffect(() => {
if (running && liveLogRef.current) {
liveLogRef.current.scrollTop = liveLogRef.current.scrollHeight
}
}, [liveLog, running])
// Auto-detect in-progress runs (RUN_AT set but RESULT not yet
// persisted) and enter streaming mode on open.
useEffect(() => {
if (!open || running || !detail) return
if (detail.last_run_at && !detail.last_result) {
setRunBaselineLogPath(detail.last_log_path ?? null)
setRunning(true)
}
}, [open, running, detail])
// Exit streaming when both RUN_AT and RESULT are set.
useEffect(() => {
if (!running || !detail) return
if (detail.last_run_at && detail.last_result) {
setRunning(false)
setRunBaselineLogPath(null)
onChanged()
}
}, [detail, running, onChanged])
useEffect(() => {
if (!open) {
setRunning(false)
setRunBaselineLogPath(null)
setActionError(null)
}
}, [open])
const resultBadge = running
? { label: "running", cls: "bg-blue-500/10 border-blue-500/40 text-blue-300" }
: detail?.last_result === "ok"
? { label: "ok", cls: "bg-emerald-500/10 border-emerald-500/40 text-emerald-300" }
: detail?.last_result
? { label: detail.last_result, cls: "bg-red-500/10 border-red-500/40 text-red-300" }
: null
const lastRunWhen = formatRunAt(detail?.last_run_at ?? null)
const destAccent =
detail?.method === "pbs" ? "text-purple-400"
: detail?.method === "borg" ? "text-fuchsia-400"
: "text-blue-400"
return (
<>
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose() }}>
<DialogContent className="max-w-3xl bg-card border-border overflow-hidden">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 flex-wrap text-base">
<PlayCircle className="h-5 w-5 text-purple-400" />
<span className="font-mono break-all">{detail?.id ?? jobId}</span>
{detail && (
<>
<Badge variant="outline" className={`text-[10px] uppercase tracking-wide ${methodBadgeCls(detail.method)}`}>
{detail.method}
</Badge>
<Badge variant="outline" className="text-[10px] text-purple-400 border-purple-400/40 bg-purple-500/5">
manual / one-shot
</Badge>
</>
)}
</DialogTitle>
<DialogDescription className="text-xs">
One-shot backup captured at the time of the trigger. Cannot be re-run or edited.
</DialogDescription>
</DialogHeader>
{actionError && (
<div className="text-xs text-red-500 px-3 py-2 rounded-md border border-red-500/30 bg-red-500/10 whitespace-pre-wrap break-words">
2026-06-20 21:59:19 +02:00
{actionError}
</div>
)}
{isLoading || !detail ? (
<div className="flex items-center gap-2 py-6 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Loading job
</div>
) : (
<ScrollArea className="max-h-[60vh] pr-2">
<div className="space-y-4 text-sm">
2026-06-21 00:35:22 +02:00
{/* Status + live log — manual one-shot has no concept
of "last run" (the modal IS the run), so we use a
neutral "Status" header here. */}
2026-06-20 21:59:19 +02:00
<section className="space-y-2">
<h4 className="text-xs font-semibold uppercase tracking-wide flex items-center gap-1.5 text-green-500">
2026-06-21 00:35:22 +02:00
<Clock className="h-3.5 w-3.5" /> Status
2026-06-20 21:59:19 +02:00
</h4>
<div className="flex items-center gap-2 flex-wrap text-xs">
{resultBadge ? (
<span className={`px-2 py-0.5 rounded text-[10px] uppercase tracking-wide border inline-flex items-center gap-1 ${resultBadge.cls}`}>
{running && <Loader2 className="h-3 w-3 animate-spin" />}
{resultBadge.label}
</span>
) : (
2026-06-21 00:35:22 +02:00
<span className="text-muted-foreground">starting</span>
2026-06-20 21:59:19 +02:00
)}
{lastRunWhen && <span className="text-muted-foreground">{lastRunWhen}</span>}
</div>
{running ? (
(() => {
const sameOldLog =
!!liveLog?.log_path &&
!!runBaselineLogPath &&
liveLog.log_path === runBaselineLogPath
const showContent = liveLog?.content && !sameOldLog
return (
<div className="rounded-md border border-blue-500/40 bg-blue-500/5 p-2">
<pre
ref={liveLogRef}
className="text-[11px] font-mono whitespace-pre-wrap break-all max-h-64 overflow-auto text-foreground/90"
>
{showContent ? liveLog!.content : "Waiting for runner to start…"}
</pre>
<div className="flex items-center justify-between mt-2">
<span className="text-[10px] text-blue-300 inline-flex items-center gap-1.5">
<Loader2 className="h-3 w-3 animate-spin" />
{showContent ? `live · ${formatBytes(liveLog?.size ?? 0)}` : "starting…"}
</span>
{showContent && liveLog?.log_path && (
<span className="text-[10px] text-muted-foreground font-mono break-all">
{liveLog.log_path}
</span>
)}
</div>
</div>
)
})()
) : detail.last_log_tail && detail.last_log_tail.length > 0 ? (
<div className="rounded-md border border-border bg-background/60 p-2">
<pre className="text-[11px] font-mono whitespace-pre-wrap break-all max-h-48 overflow-auto text-foreground/90">
{detail.last_log_tail.join("\n")}
</pre>
<div className="text-[10px] text-muted-foreground mt-2">
tail · {detail.last_log_size > 0 ? formatBytes(detail.last_log_size) : "—"}
</div>
</div>
) : null}
</section>
{/* Destination — only the fields that apply to the
picked backend. No Schedule/Retention/Profile here
because manual jobs don't carry those. */}
<section className="space-y-1">
<h4 className={`text-xs font-semibold uppercase tracking-wide flex items-center gap-1.5 ${destAccent}`}>
<HardDrive className="h-3.5 w-3.5" /> Destination
</h4>
{detail.method === "pbs" && (
<>
{detail.pbs_repository && (
<Field icon={<Server className={`h-3 w-3 ${destAccent} opacity-80`} />} label="repository" value={detail.pbs_repository} mono />
)}
{detail.pbs_backup_id && (
<Field icon={<Archive className={`h-3 w-3 ${destAccent} opacity-80`} />} label="backup-id" value={detail.pbs_backup_id} mono />
)}
</>
)}
{detail.method === "borg" && (
<>
{detail.borg_repo && (
<Field icon={<Server className={`h-3 w-3 ${destAccent} opacity-80`} />} label="repo" value={detail.borg_repo} mono />
)}
<Field icon={<FileSearch className={`h-3 w-3 ${destAccent} opacity-80`} />} label="encryption" value={detail.borg_encrypt_mode} mono />
</>
)}
{detail.method === "local" && detail.local_dest_dir && (
<Field icon={<HardDrive className={`h-3 w-3 ${destAccent} opacity-80`} />} label="dir" value={detail.local_dest_dir} mono />
)}
</section>
</div>
</ScrollArea>
)}
</DialogContent>
</Dialog>
</>
)
}
// ──────────────────────────────────────────────────────────────
// PbsKeyfileRecoveryDialog
// ──────────────────────────────────────────────────────────────
// Drops the missing PBS keyfile back onto disk by pulling the escrow
2026-07-04 21:37:39 +02:00
// blob from a keyrecovery backup and decrypting it with the operator's
// recovery passphrase. Mirrors the shell's hb_pbs_try_keyfile_recovery
// flow. Triggered from the banner in DestinationsSection when the
// local keyfile is missing but at least one escrow backup is present
// on a configured PBS. The backend endpoint surfaces both the current
// `hostcfg-<host>-keyrecovery` and the legacy
// `proxmenux-keyrecovery-<host>` naming, so pre-1.2.2.2 escrow blobs
// stay recoverable without manual migration.
2026-06-20 21:59:19 +02:00
// ──────────────────────────────────────────────────────────────
function PbsKeyfileRecoveryDialog({
open,
snapshots,
onClose,
onRecovered,
}: {
open: boolean
snapshots: Array<{ repo_name: string; repo_repository: string; backup_id: string; source_host: string; backup_time: number; snapshot: string }>
onClose: () => void
onRecovered: () => void
}) {
const [selectedKey, setSelectedKey] = useState<string>("")
const [passphrase, setPassphrase] = useState<string>("")
const [busy, setBusy] = useState<boolean>(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!open) {
setSelectedKey("")
setPassphrase("")
setBusy(false)
setError(null)
return
}
if (snapshots.length >= 1 && !selectedKey) {
setSelectedKey(`${snapshots[0].repo_name}::${snapshots[0].snapshot}`)
}
}, [open, snapshots])
const selected = snapshots.find((s) => `${s.repo_name}::${s.snapshot}` === selectedKey) || null
async function handleRecover() {
if (!selected || !passphrase) return
setBusy(true)
setError(null)
try {
await fetchApi("/api/host-backups/pbs-recovery/restore", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
repo_name: selected.repo_name,
snapshot: selected.snapshot,
passphrase,
}),
})
onRecovered()
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setBusy(false)
}
}
return (
<Dialog open={open} onOpenChange={(v) => { if (!v && !busy) onClose() }}>
<DialogContent className="max-w-lg bg-card border-border">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-base">
<Lock className="h-5 w-5 text-emerald-400" />
Recover PBS keyfile
</DialogTitle>
<DialogDescription className="text-xs">
The encrypted keyfile blob is downloaded from PBS and decrypted with your recovery passphrase. The resulting key is written to <code className="font-mono">/usr/local/share/proxmenux/pbs-key.conf</code>.
</DialogDescription>
</DialogHeader>
{snapshots.length > 1 && (
<div className="space-y-1">
<Label className="text-xs">Recovery snapshot</Label>
<Select value={selectedKey} onValueChange={setSelectedKey}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{snapshots.map((s) => {
let when: string
try { when = new Date(s.backup_time * 1000).toLocaleString() }
catch { when = String(s.backup_time) }
return (
<SelectItem key={`${s.repo_name}::${s.snapshot}`} value={`${s.repo_name}::${s.snapshot}`}>
<span className="font-mono">{s.source_host}</span>
<span className="text-muted-foreground ml-2"> {s.repo_name} · {when}</span>
</SelectItem>
)
})}
</SelectContent>
</Select>
<p className="text-[11px] text-muted-foreground">
Pick the source host whose passphrase you remember each install uploads its own escrow.
</p>
</div>
)}
{selected && (
<div className="text-xs space-y-1 px-3 py-2 rounded-md border border-border bg-background/40">
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Source host:</span>
<span className="font-mono">{selected.source_host}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">PBS:</span>
<span className="font-mono break-all">{selected.repo_repository}</span>
</div>
</div>
)}
<div>
<Label htmlFor="recoveryPass" className="text-xs">Recovery passphrase</Label>
<Input
id="recoveryPass"
type="password"
value={passphrase}
onChange={(e) => setPassphrase(e.target.value)}
placeholder="The passphrase set when the keyfile was created"
className="font-mono mt-1"
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter" && passphrase && !busy) handleRecover()
}}
/>
</div>
{error && (
<div className="text-xs text-red-500 px-3 py-2 rounded-md border border-red-500/30 bg-red-500/10 whitespace-pre-wrap break-words">
2026-06-20 21:59:19 +02:00
{error}
</div>
)}
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={onClose} disabled={busy}>
Cancel
</Button>
<Button
2026-06-21 22:41:54 +02:00
className="bg-green-600 hover:bg-green-700 text-white"
2026-06-20 21:59:19 +02:00
disabled={!selected || !passphrase || busy}
onClick={handleRecover}
>
{busy ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Lock className="h-4 w-4 mr-2" />
)}
Recover keyfile
</Button>
</div>
</DialogContent>
</Dialog>
)
}
2026-06-21 00:35:22 +02:00
// ──────────────────────────────────────────────────────────────
// ArchiveContentsModal
// ──────────────────────────────────────────────────────────────
// "View contents" of any backup snapshot. Calls /inspect-archive
// (extract to staging + parse_manifest + run_restore dry-run +
// walk rootfs + cleanup) and renders the JSON as a HTML report.
// Same shape regardless of backend so the operator gets a
// consistent view across PBS, Borg and local.
// ──────────────────────────────────────────────────────────────
interface InspectResponse {
manifest?: any
manifest_error?: string
manifest_missing?: boolean
plan?: any
plan_error?: string
plan_raw_stderr?: string
files?: Array<{ path: string; size: number }>
files_truncated?: boolean
files_total_count?: number
metadata_files?: Record<string, string>
2026-06-21 22:41:54 +02:00
rollback_plan?: RollbackPlan
}
interface RollbackPlan {
backup_time?: string
vms_in_backup: number[]
vms_in_host: number[]
vms_to_remove: number[]
vms_to_restore: number[]
lxcs_in_backup: number[]
lxcs_in_host: number[]
lxcs_to_remove: number[]
lxcs_to_restore: number[]
components_to_uninstall: string[]
components_to_reinstall: string[]
2026-06-21 00:35:22 +02:00
}
function ArchiveContentsModal({
open,
onClose,
source,
repo_name,
snapshot,
path,
display_id,
}: {
open: boolean
onClose: () => void
source: "pbs" | "borg" | "local" | null
repo_name?: string
snapshot?: string
path?: string
display_id?: string
}) {
const [data, setData] = useState<InspectResponse | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!open || !source) {
setData(null)
setError(null)
setLoading(false)
return
}
const body: Record<string, string> = { source }
if (source === "local") {
if (!path) { setError("Local archive path missing"); return }
body.path = path
} else {
if (!repo_name || !snapshot) { setError("repo_name and snapshot required"); return }
body.repo_name = repo_name
body.snapshot = snapshot
}
setLoading(true)
setError(null)
fetchApi("/api/host-backups/inspect-archive", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
.then((r: any) => setData(r))
.catch((e) => setError(e instanceof Error ? e.message : String(e)))
.finally(() => setLoading(false))
}, [open, source, repo_name, snapshot, path])
const manifest = data?.manifest
const plan = data?.plan
const files = data?.files
return (
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose() }}>
<DialogContent className="max-w-4xl bg-card border-border h-[85vh] flex flex-col p-0">
<DialogHeader className="px-6 pt-6 pb-3 shrink-0">
<DialogTitle className="flex items-center gap-2 text-base">
<Eye className="h-5 w-5 text-blue-400" />
Backup contents
</DialogTitle>
<DialogDescription className="text-xs font-mono break-all">
{display_id}
</DialogDescription>
</DialogHeader>
{loading && (
<div className="flex items-center gap-3 py-12 justify-center text-sm text-muted-foreground px-6">
<Loader2 className="h-5 w-5 animate-spin" />
Extracting snapshot and analyzing this may take a minute on large backups
</div>
)}
2026-07-08 19:17:13 +02:00
{error && !loading && (() => {
const kf = parseKeyfileError(error)
return kf ? (
<div className="px-6"><KeyfileErrorBlock err={kf} /></div>
) : (
<div className="text-sm text-red-400 px-3 py-3 mx-6 rounded-md border border-red-500/30 bg-red-500/10">
{error}
</div>
)
})()}
2026-06-21 00:35:22 +02:00
{data && !loading && (
<ScrollArea className="flex-1 min-h-0 px-6">
<div className="space-y-4 pr-2">
<ContentsSection icon={Info} title="Manifest" iconColor="text-blue-400">
{manifest ? (
<ManifestGrid manifest={manifest} />
) : data.manifest_missing ? (
<div className="text-xs text-muted-foreground italic">
This backup doesn't include a manifest.json. Manifest is generated by the collectors pipeline (not yet wired into the backup runner). The Metadata files below carry the equivalent info: <code className="font-mono">run_info.env</code>, <code className="font-mono">pveversion.txt</code>, <code className="font-mono">selected_paths.txt</code>.
</div>
) : data.manifest_error ? (
<div className="text-xs text-amber-400">{data.manifest_error}</div>
) : (
<div className="text-xs text-muted-foreground italic">No manifest in this archive.</div>
)}
</ContentsSection>
{(() => {
// Only render plan/storage/network/drivers sections
// when they actually carry information — otherwise
// they read as "Unknown / No entries reported / No
// changes planned" walls of noise.
const blockers = plan?.preflight?.blockers || plan?.blockers || []
const warnings = plan?.preflight?.warnings || plan?.warnings || []
const planStatus = plan?.status || plan?.preflight?.status
const planHasInfo = blockers.length > 0 || warnings.length > 0
|| (planStatus && planStatus !== "unknown")
const storageEntries = (plan?.storage?.entries || plan?.storage?.storages || [])
const networkIfaces = (plan?.network?.interfaces || plan?.network?.remap || [])
const driverList = (plan?.drivers?.plan || plan?.drivers?.components || plan?.drivers?.entries || [])
return (
<>
{planHasInfo && (
<ContentsSection icon={ListTree} title="Restore plan" iconColor="text-emerald-400">
<PlanSummary plan={plan} />
</ContentsSection>
)}
{storageEntries.length > 0 && (
<ContentsSection icon={HardDrive} title="Storage" iconColor="text-amber-400">
<StorageSection storage={plan.storage} />
</ContentsSection>
)}
{networkIfaces.length > 0 && (
<ContentsSection icon={Network} title="Network" iconColor="text-purple-400">
<NetworkSection network={plan.network} />
</ContentsSection>
)}
{driverList.length > 0 && (
<ContentsSection icon={Cpu} title="Drivers to reinstall" iconColor="text-cyan-400">
<DriversSection drivers={plan.drivers} />
</ContentsSection>
)}
</>
)
})()}
2026-06-21 22:41:54 +02:00
{data.rollback_plan && (
2026-06-22 11:38:06 +02:00
<ContentsSection icon={History} title="Rollback plan" iconColor="text-blue-400">
2026-06-21 22:41:54 +02:00
<RollbackPlanView plan={data.rollback_plan} />
</ContentsSection>
)}
2026-06-21 00:35:22 +02:00
{files && files.length > 0 && (
<ContentsSection
icon={Package}
title={`Files (${data.files_total_count ?? files.length}${data.files_truncated ? "+" : ""})`}
iconColor="text-fuchsia-400"
>
<FilesTree files={files} truncated={data.files_truncated} />
</ContentsSection>
)}
{data.metadata_files && Object.keys(data.metadata_files).length > 0 && (
<ContentsSection icon={FileText} title="Metadata files" iconColor="text-muted-foreground">
<div className="space-y-3">
{Object.entries(data.metadata_files).map(([fname, content]) => (
<div key={fname}>
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1 font-mono">{fname}</div>
<pre className="text-[11px] font-mono whitespace-pre-wrap break-all max-h-40 overflow-auto rounded border border-border bg-background/40 p-2 text-foreground/80">
{content}
</pre>
</div>
))}
</div>
</ContentsSection>
)}
{data.plan_error && !data.manifest_missing && (
<div className="text-[11px] text-amber-400 italic">
Restore plan unavailable: {data.plan_error}
</div>
)}
</div>
</ScrollArea>
)}
</DialogContent>
</Dialog>
)
}
function ContentsSection({
icon: Icon,
iconColor,
title,
children,
}: {
icon: any
iconColor?: string
title: string
children: React.ReactNode
}) {
return (
<section className="rounded-md border border-border bg-background/40 p-3 space-y-2">
<h3 className="text-xs font-semibold uppercase tracking-wider flex items-center gap-1.5">
<Icon className={`h-3.5 w-3.5 ${iconColor || "text-muted-foreground"}`} />
{title}
</h3>
{children}
</section>
)
}
function ManifestGrid({ manifest }: { manifest: any }) {
// The manifest follows the proxmenux_backup_manifest schema:
// {source_host: {hostname, pve_version, kernel, boot_mode, cpu_model, memory_kb, roles},
// hardware_inventory: {gpu, tpu, nic, wireless},
// storage_inventory: {zfs_pools, lvm, physical_disks, pve_storage_cfg, mounts},
// proxmenux_installed_components: [...],
// kernel_params: {...},
// vms_lxcs_at_backup: {vms, lxcs},
// backup_metadata: {paths_archived, encrypted, compression, ...},
// created_at, created_by}
// So .source_host is an OBJECT — passing it raw to String() gave
// the dreaded "[object Object]".
const src = manifest.source_host || {}
const meta = manifest.backup_metadata || {}
const hw = manifest.hardware_inventory || {}
const guests = manifest.vms_lxcs_at_backup || {}
const rows: Array<[string, any]> = []
const push = (k: string, v: any) => {
if (v === undefined || v === null || v === "") return
rows.push([k, v])
}
push("Source host", src.hostname || manifest.hostname)
push("Created at", manifest.created_at || meta.created_at)
push("Kernel", src.kernel || manifest.kernel)
push("Proxmox version", src.pve_version || manifest.pve_version)
push("Boot mode", src.boot_mode)
push("CPU", src.cpu_model)
push("Memory", src.memory_kb ? `${(src.memory_kb / 1024 / 1024).toFixed(1)} GB` : undefined)
push("Roles", Array.isArray(src.roles) && src.roles.length ? src.roles.join(", ") : undefined)
push("GPUs", Array.isArray(hw.gpu) && hw.gpu.length ? `${hw.gpu.length} device(s)` : undefined)
push("NICs", Array.isArray(hw.nic) && hw.nic.length ? `${hw.nic.length} interface(s)` : undefined)
push("VMs at backup", Array.isArray(guests.vms) ? guests.vms.length : undefined)
push("LXCs at backup", Array.isArray(guests.lxcs) ? guests.lxcs.length : undefined)
push("Compression", meta.compression)
push("Encrypted", meta.encrypted === true ? "yes" : meta.encrypted === false ? "no" : undefined)
push("Paths archived", Array.isArray(meta.paths_archived) ? `${meta.paths_archived.length} paths` : undefined)
push("Built by", manifest.created_by)
return (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-1 text-xs">
{rows.map(([k, v]) => (
<div key={k}>
<span className="text-muted-foreground">{k}:</span>{" "}
<code className="font-mono text-foreground/90 break-all">{String(v)}</code>
</div>
))}
</div>
)
}
function PlanSummary({ plan }: { plan: any }) {
const status = plan.status || plan.preflight?.status || "unknown"
const blockers: any[] = plan.preflight?.blockers || plan.blockers || []
const warnings: any[] = plan.preflight?.warnings || plan.warnings || []
const StatusIcon = status === "ok" ? CheckCircle2 : status === "warn" ? AlertTriangle : XCircle
const statusColor = status === "ok" ? "text-emerald-400" : status === "warn" ? "text-amber-400" : "text-red-400"
return (
<div className="space-y-2 text-xs">
<div className="flex items-center gap-2">
<StatusIcon className={`h-4 w-4 ${statusColor}`} />
<span className="font-medium capitalize">{status}</span>
</div>
{blockers.length > 0 && (
<div>
<div className="text-[10px] uppercase tracking-wider text-red-400 mb-1">Blockers</div>
<ul className="space-y-0.5 ml-4 list-disc">
{blockers.map((b, i) => (
<li key={i} className="text-red-400">{typeof b === "string" ? b : (b.message || JSON.stringify(b))}</li>
))}
</ul>
</div>
)}
{warnings.length > 0 && (
<div>
<div className="text-[10px] uppercase tracking-wider text-amber-400 mb-1">Warnings</div>
<ul className="space-y-0.5 ml-4 list-disc">
{warnings.map((w, i) => (
<li key={i} className="text-amber-400">{typeof w === "string" ? w : (w.message || JSON.stringify(w))}</li>
))}
</ul>
</div>
)}
{blockers.length === 0 && warnings.length === 0 && (
<div className="text-muted-foreground italic">No blockers or warnings detected.</div>
)}
</div>
)
}
function StorageSection({ storage }: { storage: any }) {
const entries: any[] = storage.entries || storage.storages || []
if (entries.length === 0) {
return <div className="text-xs text-muted-foreground italic">No storage entries reported.</div>
}
return (
<ul className="space-y-1 text-xs">
{entries.map((e: any, i: number) => {
const ok = e.status === "ok" || e.matches
const Icon = ok ? CheckCircle2 : AlertTriangle
const color = ok ? "text-emerald-400" : "text-amber-400"
return (
<li key={i} className="flex items-start gap-2">
<Icon className={`h-3.5 w-3.5 mt-0.5 shrink-0 ${color}`} />
<div className="min-w-0 flex-1">
<code className="font-mono">{e.name || e.storage || `entry ${i}`}</code>
{e.type && <span className="text-muted-foreground ml-2">{e.type}</span>}
{e.message && <div className="text-muted-foreground text-[11px]">{e.message}</div>}
</div>
</li>
)
})}
</ul>
)
}
function NetworkSection({ network }: { network: any }) {
const ifaces: any[] = network.interfaces || network.remap || []
if (ifaces.length === 0) {
return <div className="text-xs text-muted-foreground italic">No interface changes planned.</div>
}
return (
<ul className="space-y-1 text-xs">
{ifaces.map((nic: any, i: number) => (
<li key={i} className="flex items-start gap-2">
<Network className="h-3.5 w-3.5 mt-0.5 shrink-0 text-purple-400" />
<div className="min-w-0 flex-1">
<code className="font-mono">{nic.source || nic.from || "?"}</code>
<span className="text-muted-foreground mx-2">→</span>
<code className="font-mono">{nic.target || nic.to || "?"}</code>
{nic.action && <span className="text-[10px] text-muted-foreground ml-2">{nic.action}</span>}
</div>
</li>
))}
</ul>
)
}
function DriversSection({ drivers }: { drivers: any }) {
const list: any[] = drivers.plan || drivers.components || drivers.entries || []
if (list.length === 0) {
return <div className="text-xs text-muted-foreground italic">No drivers to reinstall.</div>
}
return (
<ul className="space-y-1 text-xs">
{list.map((d: any, i: number) => (
<li key={i} className="flex items-start gap-2">
<Cpu className="h-3.5 w-3.5 mt-0.5 shrink-0 text-cyan-400" />
<div className="min-w-0 flex-1">
<code className="font-mono">{d.name || d.id || `driver ${i}`}</code>
{d.action && <span className="text-muted-foreground ml-2">{d.action}</span>}
{d.detail && <div className="text-muted-foreground text-[11px]">{d.detail}</div>}
</div>
</li>
))}
</ul>
)
}
function FilesTree({ files, truncated }: { files: Array<{ path: string; size: number }>; truncated?: boolean }) {
const [query, setQuery] = useState("")
const filtered = query
? files.filter((f) => f.path.toLowerCase().includes(query.toLowerCase()))
: files
return (
<div className="space-y-2">
<Input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Filter paths…"
className="h-8 text-xs"
/>
<div className="max-h-72 overflow-auto rounded border border-border bg-background/40">
<ul className="text-[11px] font-mono divide-y divide-border/30">
{filtered.slice(0, 2000).map((f) => (
<li key={f.path} className="flex items-center justify-between gap-3 px-2 py-1 hover:bg-white/5">
<span className="truncate" title={f.path}>{f.path}</span>
<span className="text-muted-foreground shrink-0">{formatBytes(f.size)}</span>
</li>
))}
</ul>
</div>
<div className="text-[10px] text-muted-foreground flex items-center gap-2">
Showing {Math.min(filtered.length, 2000)} of {filtered.length}{query ? " filtered" : ""}{filtered.length !== files.length ? ` (total ${files.length})` : ""}
{truncated && <span className="text-amber-400">· list truncated at 5000 — open the snapshot manually to see the rest</span>}
</div>
</div>
)
}
2026-06-21 22:41:54 +02:00
// ──────────────────────────────────────────────────────────────
// RollbackPlanView
// ──────────────────────────────────────────────────────────────
// Visualizes what a "restore to backup state" would do — surfaces
// the irreversible parts (VMs/LXCs to delete, components to
// uninstall) in red, the safe re-apply parts in green. Drives the
// operator confirmation in the Complete restore flow.
// ──────────────────────────────────────────────────────────────
function RollbackPlanView({ plan }: { plan: RollbackPlan }) {
2026-06-24 21:58:21 +02:00
// The destructive "Not in backup — will be deleted on rollback"
// block was removed: operators read it as an action ProxMenux
// would take silently. We only show the "Configurations included
// in backup" section now, which is purely informative.
2026-06-21 22:41:54 +02:00
const hasRollback = (plan.vms_to_restore?.length || 0) > 0
|| (plan.lxcs_to_restore?.length || 0) > 0
|| (plan.components_to_reinstall?.length || 0) > 0
2026-06-24 21:58:21 +02:00
if (!hasRollback) {
2026-06-21 22:41:54 +02:00
return (
<div className="text-xs text-muted-foreground italic">
No host-state differences detected — the backup state matches the current host (or the backup didn't include /etc/pve).
</div>
)
}
2026-06-24 21:58:21 +02:00
// Pill renders inline with proper flex wrapping so dozens of VM
// IDs don't overflow the modal on mobile (previous version used
// raw spaces between pills and any value past the viewport got
// clipped).
const Pill = ({ children }: { children: any }) => (
<code className="font-mono px-1.5 py-0.5 rounded border bg-emerald-500/10 border-emerald-500/30 text-emerald-300 text-[11px]">
{children}
</code>
)
2026-06-24 23:40:22 +02:00
// Each pill is a direct flex child of the row (no inner wrapper)
// so flex-wrap can break a long list of IDs mid-row on mobile.
// The previous nested `<div className="flex flex-wrap">` wouldn't
// wrap because its width was capped by the parent's flex layout,
// and the whole block would overflow the modal instead.
2026-06-24 21:58:21 +02:00
const Row = ({ label, items }: { label: string; items: (string | number)[] }) => (
2026-06-24 23:40:22 +02:00
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1.5">
2026-06-24 21:58:21 +02:00
<span className="text-muted-foreground shrink-0">{label}</span>
2026-06-24 23:40:22 +02:00
{items.map((id) => <Pill key={id}>{id}</Pill>)}
2026-06-24 21:58:21 +02:00
</div>
2026-06-21 22:41:54 +02:00
)
return (
2026-06-24 21:58:21 +02:00
<div className="rounded-md border border-emerald-500/40 bg-emerald-500/5 p-3 space-y-2 text-xs">
<div className="text-[11px] font-semibold uppercase tracking-wider text-emerald-400">
Configurations included in backup
</div>
{plan.vms_to_restore.length > 0 && (
<Row label="VM configs:" items={plan.vms_to_restore} />
2026-06-21 22:41:54 +02:00
)}
2026-06-24 21:58:21 +02:00
{plan.lxcs_to_restore.length > 0 && (
<Row label="LXC configs:" items={plan.lxcs_to_restore} />
2026-06-21 22:41:54 +02:00
)}
2026-06-24 21:58:21 +02:00
{plan.components_to_reinstall.length > 0 && (
<Row label="Components:" items={plan.components_to_reinstall} />
)}
<div className="text-[10px] text-muted-foreground pt-1">
Only the /etc/pve config is restored disk images stay where they live.
</div>
2026-06-21 22:41:54 +02:00
</div>
)
}
// ──────────────────────────────────────────────────────────────
// RestoreOptionsModal
// ──────────────────────────────────────────────────────────────
// Two-step UI for restoring a snapshot:
// Step 1 — pick Complete restore vs Custom by paths.
// Step 2 — if Custom, tick the paths to restore.
// `pathsAvailable` is what the backend's list_paths.sh detected
// in this specific backup (default profile + operator extras).
// Perfect parity with the TUI's _rs_select_component_paths after
// the paths-not-components refactor.
// ──────────────────────────────────────────────────────────────
function RestoreOptionsModal({
open,
onClose,
stagingPath,
pathsAvailable,
rollbackPlan,
2026-07-03 18:59:45 +02:00
crossKernel,
hydration,
2026-06-21 22:41:54 +02:00
display_id,
onLaunch,
}: {
open: boolean
onClose: () => void
stagingPath: string
pathsAvailable: string[]
rollbackPlan?: RollbackPlan
2026-07-03 18:59:45 +02:00
crossKernel?: {
direction: "same" | "bk_older" | "bk_newer"
backupKernel: string
targetKernel: string
blockedPaths: string[]
}
hydration?: {
applies: boolean
actions: string[]
}
2026-06-21 22:41:54 +02:00
display_id?: string
2026-06-21 23:49:41 +02:00
onLaunch: (mode: "full" | "custom", paths: string[], rollbackExecute?: boolean) => void
2026-06-21 22:41:54 +02:00
}) {
2026-07-03 18:59:45 +02:00
// When direction === "bk_older" the CLI-side filter (RS_SKIP_PATHS)
// will drop these prefixes from any restore. We mirror that
// behavior in the picker so the operator can see what will be
// skipped rather than being surprised at run time.
const isBkOlder = crossKernel?.direction === "bk_older"
const blockedPrefixes = isBkOlder ? (crossKernel?.blockedPaths ?? []) : []
const isPathBlocked = (p: string) =>
blockedPrefixes.some((b) => p === b || p.startsWith(b.endsWith("/") ? b : `${b}/`))
2026-06-21 22:41:54 +02:00
const [step, setStep] = useState<"choose" | "custom">("choose")
const [selected, setSelected] = useState<Set<string>>(new Set())
const [error, setError] = useState<string | null>(null)
const [filter, setFilter] = useState<string>("")
const [destructiveAck, setDestructiveAck] = useState<boolean>(false)
// A Complete restore on a host that has VMs/LXCs/components NOT
// present in the backup is destructive (those entries get removed
// at next boot by apply_cluster_postboot.sh's rollback step). We
// force the operator to tick an explicit "I understand" checkbox
// before enabling the launch button.
const hasDestructive = !!rollbackPlan && (
(rollbackPlan.vms_to_remove?.length || 0) > 0
|| (rollbackPlan.lxcs_to_remove?.length || 0) > 0
|| (rollbackPlan.components_to_uninstall?.length || 0) > 0
)
useEffect(() => {
if (!open) {
setStep("choose")
setSelected(new Set())
setError(null)
setFilter("")
setDestructiveAck(false)
}
}, [open])
const togglePath = (p: string) => {
2026-07-03 18:59:45 +02:00
if (isPathBlocked(p)) return
2026-06-21 22:41:54 +02:00
setSelected((prev) => {
const next = new Set(prev)
if (next.has(p)) next.delete(p)
else next.add(p)
return next
})
}
const toggleAll = () => {
2026-07-03 18:59:45 +02:00
const selectable = filteredPaths.filter((p) => !isPathBlocked(p))
if (selected.size === selectable.length) {
2026-06-21 22:41:54 +02:00
setSelected(new Set())
} else {
2026-07-03 18:59:45 +02:00
setSelected(new Set(selectable))
2026-06-21 22:41:54 +02:00
}
}
const filteredPaths = filter
? pathsAvailable.filter((p) => p.toLowerCase().includes(filter.toLowerCase()))
: pathsAvailable
2026-07-03 18:59:45 +02:00
const selectableCount = filteredPaths.filter((p) => !isPathBlocked(p)).length
2026-06-21 22:41:54 +02:00
const launch = (mode: "full" | "custom") => {
if (mode === "custom" && selected.size === 0) {
setError("Pick at least one path to continue.")
return
}
setError(null)
2026-06-21 23:49:41 +02:00
onLaunch(mode, Array.from(selected), mode === "full" && hasDestructive && destructiveAck)
2026-06-21 22:41:54 +02:00
}
return (
<Dialog open={open} onOpenChange={(v) => { if (!v && step !== "preparing") onClose() }}>
<DialogContent className="max-w-2xl bg-card border-border">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-base">
<DatabaseBackup className="h-5 w-5 text-emerald-400" />
Restore choose mode
</DialogTitle>
<DialogDescription className="text-xs font-mono break-all">
{display_id}
</DialogDescription>
</DialogHeader>
{step === "choose" && (
<div className="space-y-3">
2026-07-05 16:50:06 +02:00
{/* Post-restore Monitor hint — the Backups tab renders a live
progress card driven by /var/lib/proxmenux/restore-state.json,
so the operator has a place to watch component reinstalls,
sanity warnings, and the rollback delta after the reboot. */}
<div className="rounded-md border border-blue-500/40 bg-blue-500/5 p-3 text-[11px] text-muted-foreground flex items-start gap-2">
<History className="h-3.5 w-3.5 text-blue-400 shrink-0 mt-0.5" />
<div>
2026-07-05 16:58:27 +02:00
After the reboot, this Backups tab will show a live post-restore progress card with estimated time, per-component status, log tail and rollback delta. If Telegram/Discord/ntfy notifications are configured, you'll also receive the "Host restore finished" event.
2026-07-05 16:50:06 +02:00
</div>
</div>
2026-07-03 18:59:45 +02:00
{isBkOlder && (
<div className="rounded-md border border-amber-500/50 bg-amber-500/5 p-3">
<div className="text-[11px] font-semibold uppercase tracking-wider text-amber-400 flex items-center gap-1.5">
<AlertTriangle className="h-3.5 w-3.5" />
Cross-kernel restore — safe subset only
</div>
<div className="text-[11px] text-muted-foreground mt-2 space-y-1.5">
<div>
Backup kernel <code className="font-mono">{crossKernel?.backupKernel}</code> is older than target kernel <code className="font-mono">{crossKernel?.targetKernel}</code>.
</div>
<div>
Both Complete and Custom restore will automatically skip kernel/boot-tied paths to avoid a kernel panic on next boot. Everything else (VMs, LXCs, network, components, custom paths) restores normally.
</div>
{blockedPrefixes.length > 0 && (
<details className="mt-1.5">
<summary className="cursor-pointer text-amber-400/90 hover:text-amber-300">
Paths that will be skipped ({blockedPrefixes.length})
</summary>
<ul className="mt-1.5 pl-3 space-y-0.5 max-h-40 overflow-auto">
{blockedPrefixes.map((p) => (
<li key={p} className="font-mono text-[10.5px] text-muted-foreground/80 break-all">{p}</li>
))}
</ul>
</details>
)}
</div>
</div>
)}
{isBkOlder && hydration?.applies && hydration.actions.length > 0 && (
<div className="rounded-md border border-emerald-500/50 bg-emerald-500/5 p-3">
<div className="text-[11px] font-semibold uppercase tracking-wider text-emerald-400 flex items-center gap-1.5">
<CheckCircle2 className="h-3.5 w-3.5" />
Operator config re-applied via kernel-agnostic merge
</div>
<div className="text-[11px] text-muted-foreground mt-2 space-y-1.5">
<div>
The following operator-authored settings will be merged into the target automatically, without touching kernel-tied defaults. Runs on both Complete and Custom restore.
</div>
<ul className="mt-1.5 pl-3 space-y-0.5 max-h-40 overflow-auto">
{hydration.actions.map((a, i) => (
<li key={`${a}-${i}`} className="text-[10.5px] text-emerald-300/90 break-all">
<span className="text-emerald-500 mr-1">•</span>{a}
</li>
))}
</ul>
</div>
</div>
)}
2026-06-21 22:41:54 +02:00
{/* Surface destructive deltas BEFORE the operator picks
Complete. Custom-by-paths can't trigger these
(paths-only restore doesn't touch the guest list or
components_status.json), so the warning lives here. */}
{rollbackPlan && hasDestructive && (
<div className="rounded-md border border-red-500/50 bg-red-500/5 p-3 space-y-3">
<div className="text-[11px] font-semibold uppercase tracking-wider text-red-400 flex items-center gap-1.5">
<AlertTriangle className="h-3.5 w-3.5" />
Complete restore will REMOVE the following (created after the backup)
</div>
<RollbackPlanView plan={rollbackPlan} />
<label className="flex items-start gap-2 cursor-pointer text-xs">
<Checkbox
checked={destructiveAck}
onCheckedChange={(v) => setDestructiveAck(!!v)}
className="mt-0.5"
/>
<span className="text-foreground">
I understand that Complete restore will <strong className="text-red-400">permanently delete</strong> the VMs, LXCs and components listed above. This is irreversible.
</span>
</label>
</div>
)}
<button
type="button"
onClick={() => launch("full")}
disabled={hasDestructive && !destructiveAck}
className="w-full text-left p-4 rounded-md border border-emerald-500/40 bg-emerald-500/5 hover:bg-emerald-500/10 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<div className="flex items-start gap-3">
<CheckCircle2 className="h-5 w-5 text-emerald-400 shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium">Complete restore</div>
<div className="text-[11px] text-muted-foreground mt-1">
Replays everything in the backup. Equivalent to picking <em>"Complete restore"</em> in the shell TUI menu. The terminal that opens next will still ask you to confirm safe-only vs safe+reboot vs all-at-once before touching the host.
{hasDestructive && !destructiveAck && (
<span className="block mt-1 text-red-400">Tick the acknowledgement above to enable.</span>
)}
</div>
</div>
</div>
</button>
<button
type="button"
onClick={() => setStep("custom")}
disabled={pathsAvailable.length === 0}
className="w-full text-left p-4 rounded-md border border-blue-500/40 bg-blue-500/5 hover:bg-blue-500/10 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<div className="flex items-start gap-3">
<ListTree className="h-5 w-5 text-blue-400 shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium">
Custom restore by paths
{pathsAvailable.length > 0 && (
<span className="ml-2 text-[10px] text-muted-foreground font-normal">
({pathsAvailable.length} paths in backup)
</span>
)}
</div>
<div className="text-[11px] text-muted-foreground mt-1">
{pathsAvailable.length === 0
? "This backup carries no restorable paths — only Complete restore is meaningful here."
: "Pick exactly which paths to restore. Lists what's actually in this backup (default profile + your custom paths)."}
</div>
</div>
</div>
</button>
{error && (
<div className="text-xs text-red-400 px-3 py-2 rounded-md border border-red-500/30 bg-red-500/10">
{error}
</div>
)}
</div>
)}
{step === "custom" && (
<div className="space-y-2">
<p className="text-[11px] text-muted-foreground">
Pick the paths to restore. Your selection feeds <code className="font-mono">_rs_run_custom_restore</code> via the <code className="font-mono">HB_PRESELECTED_PATHS</code> env var — same downstream code as the TUI. Reboot-required paths (kernel, modules, fstab, …) will be detected by the bash flow and you can schedule them for next boot from there.
</p>
<div className="flex items-center gap-2">
<Input
type="search"
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="Filter paths"
className="h-8 text-xs"
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={toggleAll}
className="h-8 text-xs whitespace-nowrap"
>
2026-07-03 18:59:45 +02:00
{selected.size === selectableCount && selectableCount > 0 ? "Clear" : "All"}
2026-06-21 22:41:54 +02:00
</Button>
</div>
2026-07-03 18:59:45 +02:00
{isBkOlder && (
<div className="text-[10.5px] text-amber-400/90 px-2 py-1.5 rounded border border-amber-500/30 bg-amber-500/5">
Kernel/boot-tied paths are grayed out and cannot be selected — target runs a newer kernel.
</div>
)}
2026-06-21 22:41:54 +02:00
<div className="rounded-md border border-border bg-background/40 p-1 max-h-72 overflow-auto">
<ul className="divide-y divide-border/40">
2026-07-03 18:59:45 +02:00
{filteredPaths.map((p) => {
const blocked = isPathBlocked(p)
return (
<li key={p}>
<label
className={`flex items-center gap-2 px-2 py-1 ${blocked ? "opacity-40 cursor-not-allowed" : "hover:bg-white/5 cursor-pointer"}`}
title={blocked ? "Skipped: kernel-tied path, target runs a newer kernel" : undefined}
>
<Checkbox
checked={!blocked && selected.has(p)}
disabled={blocked}
onCheckedChange={() => togglePath(p)}
/>
<code className={`font-mono text-xs break-all flex-1 ${blocked ? "line-through" : ""}`}>{p}</code>
{blocked && (
<span className="text-[10px] text-amber-400/80 font-normal shrink-0">skipped</span>
)}
</label>
</li>
)
})}
2026-06-21 22:41:54 +02:00
{filteredPaths.length === 0 && (
<li className="text-[11px] text-muted-foreground italic px-2 py-2">No paths match the filter.</li>
)}
</ul>
</div>
<div className="text-[11px] text-muted-foreground">
2026-07-03 18:59:45 +02:00
Selected: {selected.size} / {isBkOlder ? selectableCount : pathsAvailable.length}{filter && filteredPaths.length !== pathsAvailable.length ? ` (${filteredPaths.length} shown)` : ""}
2026-06-21 22:41:54 +02:00
</div>
{error && (
<div className="text-xs text-red-400 px-3 py-2 rounded-md border border-red-500/30 bg-red-500/10">
{error}
</div>
)}
</div>
)}
<div className="flex justify-end gap-2 pt-2 border-t border-border">
{step === "custom" && (
<Button variant="ghost" onClick={() => setStep("choose")}>
Back
</Button>
)}
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
{step === "custom" && (
<Button
onClick={() => launch("custom")}
disabled={selected.size === 0}
className="bg-green-600 hover:bg-green-700 text-white"
>
<DatabaseBackup className="h-4 w-4 mr-2" />
Restore selected
</Button>
)}
</div>
</DialogContent>
</Dialog>
)
}