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-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 )
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-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-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-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" >
{ /* ── 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 && (
2026-06-19 23:38:57 +02:00
< div className = "text-xs text-red-500 px-3 py-2 rounded-md border border-red-500/30 bg-red-500/10" >
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 )
// Refresh BOTH archive lists a few seconds after launch so
// the new backup shows up regardless of backend: local
// archives are on disk locally, PBS / Borg snapshots are
// pulled from `remote-archives`. Without the second mutate
// a Borg manual stayed invisible until the next 60s tick.
setTimeout (() => {
mutateArchives ()
mutateRemoteArchives ()
}, 5000 )
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 )
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 >
)}
{ error && (
< div className = "text-xs text-red-500 px-3 py-2 rounded-md border border-red-500/30 bg-red-500/10" >
{ error }
</ div >
)}
</ 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 }
disabled = { restorePreparing }
className = "bg-green-600 hover:bg-green-700 text-white"
title = "Restore this snapshot to the current host (Complete or Custom by paths)"
>
{ 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 }
disabled = { downloading }
2026-06-21 22:41:54 +02:00
className = "bg-blue-500/10 border border-blue-500/40 text-blue-400 hover:bg-blue-500/20 hover:text-blue-300"
variant = "outline"
title = "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-06-21 22:41:54 +02:00
className = "bg-blue-500/10 border border-blue-500/40 text-blue-400 hover:bg-blue-500/20 hover:text-blue-300"
2026-06-19 23:38:57 +02:00
variant = "outline"
2026-06-21 22:41:54 +02:00
title = "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) ─────────────────── */ }
{ restoreError && (
< 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" />
Restore preparation failed
</ DialogTitle >
< DialogDescription className = "text-xs text-red-400 break-all" >
{ restoreError }
</ DialogDescription >
</ DialogHeader >
< div className = "flex justify-end" >
< Button variant = "ghost" onClick = {() => setRestoreError ( null )}> Close </ Button >
</ div >
</ DialogContent >
</ Dialog >
)}
{ /* ── 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-06-20 21:59:19 +02:00
pbs_encrypt : boolean
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-06-20 21:59:19 +02:00
// Client-side PBS encryption — sent as `pbs_encrypt` in the payload.
// Backend resolves the shared keyfile + injects PBS_KEYFILE into
// the job .env so the runner adds `--keyfile` to the backup call.
const [ pbsEncrypt , setPbsEncrypt ] = useState < boolean >( false )
// Optional recovery passphrase. When set, the backend encrypts the
// keyfile with openssl and the runner uploads that blob to PBS with
// every backup so the keyfile can be rebuilt on a fresh host with
// only the passphrase. Mirrors hb_pbs_setup_recovery from the shell.
const [ pbsRecoveryPass , setPbsRecoveryPass ] = useState < 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-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 < {
has_keyfile : boolean ; has_recovery : boolean
} > (
open && backend === "pbs" ? "/api/host-backups/pbs-recovery/status" : null ,
fetcher ,
)
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-06-20 21:59:19 +02:00
setPbsEncrypt ( !! jobDetail . pbs_encrypt )
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-06-20 21:59:19 +02:00
setPbsEncrypt ( false )
setPbsRecoveryPass ( "" )
setPbsRecoveryPass2 ( "" )
2026-06-21 00:35:22 +02:00
setPbsRecoveryChange ( 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-02 20:07:05 +02:00
const pbsRecoveryOk = ! ( backend === "pbs" && pbsEncrypt ) ||
( pbsRecoveryStatus ? . has_recovery && ! pbsRecoveryChange ) ||
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-06-20 21:59:19 +02:00
// Configure the PBS recovery escrow BEFORE creating the job so the
// very first backup the runner triggers can already upload the
// blob. Only fires when the operator typed a passphrase pair.
if ( backend === "pbs" && pbsEncrypt && pbsRecoveryPass ) {
if ( pbsRecoveryPass !== pbsRecoveryPass2 ) {
setError ( "Recovery passphrases don't match." )
setSubmitting ( false )
return
}
try {
await fetchApi ( "/api/host-backups/pbs-recovery/setup" , {
method : "POST" ,
headers : { "Content-Type" : "application/json" },
body : JSON.stringify ({ passphrase : pbsRecoveryPass }),
})
mutatePbsRecovery ()
} catch ( e ) {
setError ( `Recovery setup failed: ${ e instanceof Error ? e.message : String ( e ) } ` )
setSubmitting ( false )
return
}
}
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-06-20 21:59:19 +02:00
body . pbs_encrypt = pbsEncrypt
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 ( 0 – 59 )</ 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 ( 1 – 31 )</ 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-06-20 21:59:19 +02:00
{ /* PBS client-side encryption — same flow as the
shell wizard. A shared keyfile under /usr/local/
share/proxmenux/pbs-key.conf is generated on
first use and reused by every encrypted job. */ }
< div className = "pt-2 border-t border-border space-y-3" >
< Label className = "flex items-center gap-2 cursor-pointer" >
< Checkbox
checked = { pbsEncrypt }
onCheckedChange = {( v ) => setPbsEncrypt ( !! v )}
/>
< span className = "inline-flex items-center gap-1.5" >
< Lock className = "h-3.5 w-3.5 text-emerald-400" />
Encrypt backups ( client - side keyfile )
</ span >
</ Label >
< p className = "text-[11px] text-muted-foreground pl-7" >
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 the PBS server already ciphered . The keyfile lives at < code className = "font-mono" > / usr / local / share / proxmenux / pbs - key . conf </ code > ( chmod 0600 ) and is shared across every encrypted PBS job on this host .
</ p >
{ pbsEncrypt && (
< div className = "pl-7 space-y-2 rounded-md border border-blue-500/30 bg-blue-500/5 p-3" >
< div className = "text-[11px] font-medium text-foreground flex items-center gap-1.5" >
< AlertTriangle className = "h-3.5 w-3.5 text-blue-400" />
Recovery passphrase ( strongly recommended )
</ div >
2026-06-21 00:35:22 +02:00
{ pbsRecoveryStatus ? . has_recovery && ! pbsRecoveryChange ? (
/* Escrow already saved — mirror the shell: skip
the prompt unless the operator opts to change. */
< 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" >
< div className = "text-foreground" > Recovery escrow already configured for this host .</ div >
< div className = "text-muted-foreground mt-0.5" > Backups created by this job will reuse the existing passphrase . No need to type it again .</ div >
</ div >
< button
type = "button"
onClick = {() => setPbsRecoveryChange ( true )}
className = "text-blue-400 hover:text-blue-300 underline underline-offset-2 shrink-0"
>
Change
</ button >
</ div >
) : (
<>
< p className = "text-[11px] text-muted-foreground" >
With a recovery passphrase , an encrypted copy of the keyfile is uploaded to PBS with every backup . If you lose this host , you can recover the keyfile on a fresh install with just the passphrase . Without it , losing the local keyfile means the encrypted backups become unrecoverable forever .
</ p >
< div >
< Label htmlFor = "pbsRecPass" className = "text-[11px]" > 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>
{pbsRecoveryStatus?.has_recovery && pbsRecoveryChange && (
<button
type="button"
onClick={() => { setPbsRecoveryChange(false); setPbsRecoveryPass(""); setPbsRecoveryPass2("") }}
className="text-[11px] text-muted-foreground hover:text-foreground underline underline-offset-2"
>
Cancel change — keep the saved passphrase
</button>
)}
</>
)}
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" >
{ 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-06-20 21:59:19 +02:00
const [ pbsEncrypt , setPbsEncrypt ] = useState < boolean >( false )
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-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 < {
has_keyfile : boolean ; has_recovery : boolean
} > (
open && backend === "pbs" ? "/api/host-backups/pbs-recovery/status" : null ,
fetcher ,
)
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-06-20 21:59:19 +02:00
setPbsEncrypt ( false )
setPbsRecoveryPass ( "" )
setPbsRecoveryPass2 ( "" )
2026-06-21 00:35:22 +02:00
setPbsRecoveryChange ( 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-02 20:07:05 +02:00
const pbsRecoveryOk = ! ( backend === "pbs" && pbsEncrypt ) ||
( pbsRecoveryStatus ? . has_recovery && ! pbsRecoveryChange ) ||
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-06-20 21:59:19 +02:00
// Same pre-step as CreateJob: configure the PBS recovery escrow
// before the run so the runner's post-backup upload of the blob
// has something to push.
if ( backend === "pbs" && pbsEncrypt && pbsRecoveryPass ) {
if ( pbsRecoveryPass !== pbsRecoveryPass2 ) {
setError ( "Recovery passphrases don't match." )
setSubmitting ( false )
return
}
try {
await fetchApi ( "/api/host-backups/pbs-recovery/setup" , {
method : "POST" ,
headers : { "Content-Type" : "application/json" },
body : JSON.stringify ({ passphrase : pbsRecoveryPass }),
})
mutatePbsRecovery ()
} catch ( e ) {
setError ( `Recovery setup failed: ${ e instanceof Error ? e.message : String ( e ) } ` )
setSubmitting ( false )
return
}
}
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-06-20 21:59:19 +02:00
body . pbs_encrypt = pbsEncrypt
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" >
< Label className = "flex items-center gap-2 cursor-pointer" >
< Checkbox
checked = { pbsEncrypt }
onCheckedChange = {( v ) => setPbsEncrypt ( !! v )}
/>
< span className = "inline-flex items-center gap-1.5" >
< Lock className = "h-3.5 w-3.5 text-emerald-400" />
Encrypt this backup ( client - side keyfile )
</ span >
</ Label >
< p className = "text-[11px] text-muted-foreground pl-7" >
Uses the shared keyfile at < code className = "font-mono" > / usr / local / share / proxmenux / pbs - key . conf </ code > ( auto - generated on first use ).
</ p >
{ pbsEncrypt && (
< div className = "pl-7 space-y-2 rounded-md border border-blue-500/30 bg-blue-500/5 p-3" >
< div className = "text-[11px] font-medium text-foreground flex items-center gap-1.5" >
< AlertTriangle className = "h-3.5 w-3.5 text-blue-400" />
Recovery passphrase ( strongly recommended )
</ div >
2026-06-21 00:35:22 +02:00
{ pbsRecoveryStatus ? . has_recovery && ! pbsRecoveryChange ? (
< 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" >
< div className = "text-foreground" > Recovery escrow already configured for this host .</ div >
< div className = "text-muted-foreground mt-0.5" > This backup will reuse the existing passphrase . No need to type it again .</ div >
</ div >
< button
type = "button"
onClick = {() => setPbsRecoveryChange ( true )}
className = "text-blue-400 hover:text-blue-300 underline underline-offset-2 shrink-0"
>
Change
</ button >
</ div >
) : (
<>
< p className = "text-[11px] text-muted-foreground" >
With a recovery passphrase an encrypted copy of the keyfile is uploaded to PBS on every backup . Lets you recover it on a fresh host with just the passphrase .
</ p >
< div >
< Label htmlFor = "manualPbsRecPass" className = "text-[11px]" > 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>
{pbsRecoveryStatus?.has_recovery && pbsRecoveryChange && (
<button
type="button"
onClick={() => { setPbsRecoveryChange(false); setPbsRecoveryPass(""); setPbsRecoveryPass2("") }}
className="text-[11px] text-muted-foreground hover:text-foreground underline underline-offset-2"
>
Cancel change — keep the saved passphrase
</button>
)}
</>
)}
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">
{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
// exists. Drives the "Recover keyfile" banner.
const hasPbs = ( destinations ? . pbs ? . length ?? 0 ) > 0
const { data : pbsRecoveryStatus , mutate : mutatePbsRecovery } = useSWR < {
has_keyfile : boolean ; has_recovery : boolean
} > ( hasPbs ? "/api/host-backups/pbs-recovery/status" : null , fetcher )
const { data : pbsRecoveryAvailable } = useSWR < {
snapshots : Array < { repo_name : string ; repo_repository : string ; backup_id : string ; source_host : string ; backup_time : number ; snapshot : string } >
errors : Array < { repo_name : string ; error : string } >
} > (
hasPbs && pbsRecoveryStatus && ! pbsRecoveryStatus . has_keyfile
? "/api/host-backups/pbs-recovery/available"
: null ,
fetcher ,
)
const [ recoveringKeyfile , setRecoveringKeyfile ] = useState < 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" >
{ 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" >
An encrypted recovery copy of the keyfile is available ({ pbsRecoveryAvailable ! . snapshots . length } snapshot { pbsRecoveryAvailable ! . snapshots . length === 1 ? "" : "s" }). Click to rebuild the keyfile using your recovery passphrase .
</ div >
</ div >
< span className = "text-xs text-emerald-300 shrink-0 self-center" > Recover → </ span >
</ button >
)}
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 >
)}
</ 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 || "<name>"}.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">
{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" >
{ 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" >
{ 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 30 s .
</ 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" >
{ 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" >
{ 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
// blob from a `proxmenux-keyrecovery-<host>` snapshot 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 snapshot is present on a configured PBS.
// ──────────────────────────────────────────────────────────────
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" >
{ 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 >
)}
{ error && ! loading && (
< 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 >
)}
{ 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-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 >
)
}