2026-06-09 00:13:24 +02:00
"use client"
2026-06-19 23:38:57 +02:00
import { useState , useEffect , useRef } from "react"
2026-06-09 00:13:24 +02:00
import useSWR from "swr"
import { Card , CardContent , CardHeader , CardTitle } from "./ui/card"
import { Button } from "./ui/button"
import { Badge } from "./ui/badge"
2026-07-02 23:52:04 +02:00
import { Dialog , DialogContent , DialogHeader , DialogTitle , DialogDescription , DialogFooter } from "./ui/dialog"
2026-06-09 00:13:24 +02:00
import { Select , SelectContent , SelectItem , SelectTrigger , SelectValue } from "./ui/select"
2026-06-19 23:38:57 +02:00
import { Input } from "./ui/input"
import { Label } from "./ui/label"
import { Checkbox } from "./ui/checkbox"
import { ScrollArea } from "./ui/scroll-area"
import { Tabs , TabsContent , TabsList , TabsTrigger } from "./ui/tabs"
2026-06-09 00:13:24 +02:00
import {
DatabaseBackup ,
Clock ,
HardDrive ,
Server ,
CheckCircle2 ,
AlertTriangle ,
XCircle ,
Loader2 ,
PlayCircle ,
Archive ,
FileSearch ,
Calendar ,
2026-06-19 23:38:57 +02:00
Trash2 ,
Plus ,
ChevronRight ,
ChevronLeft ,
Pencil ,
Save ,
Download ,
FileText ,
Power ,
RefreshCw ,
2026-06-20 21:59:19 +02:00
Lock ,
2026-06-21 00:35:22 +02:00
Eye ,
Network ,
Cpu ,
Package ,
ShieldCheck ,
Info ,
ListTree ,
2026-06-22 11:38:06 +02:00
History ,
2026-06-09 00:13:24 +02:00
} from "lucide-react"
2026-06-21 22:41:54 +02:00
import { ScriptTerminalModal } from "./script-terminal-modal"
2026-07-05 16:50:06 +02:00
import { RestoreProgressCard } from "./restore-progress-card"
2026-06-19 23:38:57 +02:00
import { fetchApi , getApiUrl } from "../lib/api-config"
2026-06-21 22:41:54 +02:00
import { fetchTerminalTicket } from "../lib/terminal-ws"
2026-06-24 23:40:22 +02:00
import { formatStorage , formatBytes } from "../lib/utils"
2026-06-25 00:00:12 +02:00
import { getStorageUsageColor } from "../lib/storage-usage-color"
2026-06-09 00:13:24 +02:00
// ── Shape contracts with the backend (flask_server.py: api_host_backups_*) ──
interface BackupJob {
id : string
destination : string
2026-06-13 19:05:00 +02:00
method : string // "pbs" | "borg" | "local" | "unknown"
on_calendar : string // "OnCalendar=..." for timer jobs, "attached → storage:X" for attached
retention : string // "last=5, daily=7, ..."
timer_enabled : boolean // legacy — only meaningful for non-attached jobs
enabled : boolean // unified state (timer for non-attached, ENABLED= for attached)
attached : boolean // PVE vzdump-attached: no own timer, trigger from hook
pve_storage : string | null // storage id the attached job listens for
profile_mode : string // "default" | "custom"
2026-06-19 23:38:57 +02:00
manual : boolean // MANUAL_RUN=1 — one-shot, no schedule, won't re-fire
2026-06-09 00:13:24 +02:00
last_status : string | null
next_run : string | null
2026-06-20 21:59:19 +02:00
// True when this job ships encrypted backups — PBS_KEYFILE set for
// PBS, or BORG_ENCRYPT_MODE != "none" for Borg. Drives the lock
// badge in the row + the toggle's initial state in Edit.
encrypted? : boolean
2026-06-09 00:13:24 +02:00
}
2026-06-19 23:38:57 +02:00
// Sprint H — remote backups (PBS + Borg). Listing is cheap (metadata
// only); the heavy lifting happens on demand when the operator clicks
// Download. Same shape for both backends so the UI renders them
// uniformly — `backend` is the only switch.
interface RemoteSnapshot {
backend : "pbs" | "borg"
repo_name : string
repo_repository : string
snapshot : string // canonical id used to extract / restore
backup_type : string
backup_id : string
backup_time : number // unix seconds
size_bytes : number // 0 for Borg (omitted by `borg list`)
owner : string | null
protected : boolean
files : Array < { filename : string ; size : number } >
fingerprint : string | null
2026-06-20 21:59:19 +02:00
// Whether THIS snapshot is encrypted. For PBS it's derived from
// any file having crypt-mode != "none"; for Borg it follows the
// target's encrypt_mode.
encrypted? : boolean
2026-06-19 23:38:57 +02:00
borg_id? : string // Borg-only fields when available
borg_start_iso? : string
}
interface RemoteArchivesResp {
snapshots : RemoteSnapshot [ ]
errors : Array < { backend : string ; repo_name : string ; error : string } >
}
interface ExportTask {
task_id : string
backend : string
repo_name : string
snapshot : string
state : "queued" | "restoring" | "packing" | "completed" | "failed"
message : string
size_bytes : number
output_path : string | null
error : string | null
}
// Unified archive descriptor — local .tar.zst files and remote PBS /
// Borg snapshots share the same row layout; `source` decides which
// code paths apply (inline download vs export-then-download, manifest
// available vs not, etc.).
interface UnifiedArchive {
source : "local" | "pbs" | "borg"
display_id : string // what shows in mono in the row
size_bytes : number
created_at : number // unix seconds
source_label : string // "/var/lib/vz/dump" or "PBS my-pbs" or "Borg my-borg"
// One of the two is populated depending on `source`.
local? : BackupArchive
remote? : RemoteSnapshot // for both pbs and borg
}
2026-06-09 00:13:24 +02:00
interface BackupArchive {
id : string // basename of the .tar file (also the URL slug)
path : string // absolute path on host
size_bytes : number
mtime : number // unix seconds
// From the backend identifier — see _identify_host_backup() in flask_server.py.
// kind: "manual" / "scheduled" when we know; "legacy" when only the in-tar
// marker confirmed it's a ProxMenux backup (no sidecar, no name match).
job_id : string | null
kind : "manual" | "scheduled" | "legacy"
profile : string | null
source_hostname : string | null
// Which detection path identified this archive. Surfaced as a small tooltip
// hint so the operator knows whether the metadata is authoritative
// (sidecar) or inferred (filename / tar-peek).
detected_via : "sidecar" | "job_id_match" | "hostcfg_prefix" | "tar_peek"
}
interface ManifestSourceHost {
hostname : string
pve_version : string | null
roles : string [ ]
kernel : string
boot_mode : string
cpu_model : string
memory_kb : number
}
interface PreflightCheck {
id : string
severity : "pass" | "warn" | "fail"
message : string
details : Record < string , unknown > | null
}
interface PreflightReport {
source_host_at_backup : ManifestSourceHost
selected_mode : {
mode : string
paths_include : string [ ]
paths_exclude : string [ ]
components_include : string [ ]
storage_apply : boolean
network_apply : boolean
}
preflight : {
checks : PreflightCheck [ ]
summary : { pass : number ; warn : number ; fail : number }
}
storage : {
zfs : Array < { name : string ; action : string ; present : string [ ] ; missing : string [ ] } >
lvm : Array < { name : string ; action : string } >
pve_storage : Array < { id : string ; type : string ; action : string ; note : string | null } >
in_selected_mode : boolean
}
network : {
keep : Array < { ifname : string ; mac : string } >
remap : Array < { source_ifname : string ; destination_ifname : string ; mac : string } >
orphan : Array < { source_ifname : string ; source_mac : string } >
new : Array < { ifname : string ; mac : string } >
in_selected_mode : boolean
}
driver_reinstall : {
plan : Array < {
component_id : string
type : string
version : string
installer : string | null
action : string
reason : string
} >
}
abort_reason : string | null
}
const fetcher = async ( url : string ) = > fetchApi ( url )
const formatMtime = ( mtime : number ) = >
new Date ( mtime * 1000 ) . toLocaleString ( undefined , {
year : "numeric" ,
month : "short" ,
day : "numeric" ,
hour : "2-digit" ,
minute : "2-digit" ,
} )
const formatNext = ( iso : string | null ) = > {
if ( ! iso ) return "—"
try {
return new Date ( iso ) . toLocaleString ( )
} catch {
return iso
}
}
2026-06-19 23:38:57 +02:00
// Best-effort human form of systemd OnCalendar expressions. Whatever
// we don't recognise falls back to "<raw> (systemd OnCalendar)" so the
// operator at least knows what kind of string they're looking at.
// Handles the patterns the host-backup wizard can emit ("hourly",
// "daily", "weekly", "monthly", "*-*-* HH:MM:SS", "Mon..Sun *-*-* …").
const humanizeOnCalendar = ( raw : string | null | undefined ) : string = > {
if ( ! raw ) return "—"
const s = raw . trim ( )
if ( ! s ) return "—"
const lower = s . toLowerCase ( )
if ( lower === "hourly" ) return "Every hour (at minute 0)"
if ( lower === "daily" ) return "Every day at 00:00"
if ( lower === "weekly" ) return "Every Monday at 00:00"
if ( lower === "monthly" ) return "On the 1st of every month at 00:00"
if ( lower === "yearly" || lower === "annually" ) return "On Jan 1st at 00:00"
if ( lower === "minutely" ) return "Every minute"
// *-*-* HH:MM[:SS] → "Every day at HH:MM"
let m = s . match ( /^\*-\*-\*\s+(\d{1,2}):(\d{2})(?::(\d{2}))?$/ )
if ( m ) return ` Every day at ${ m [ 1 ] . padStart ( 2 , "0" ) } : ${ m [ 2 ] } `
// *-*-* *:MM:SS → "Every hour at minute MM"
m = s . match ( /^\*-\*-\*\s+\*:(\d{2})(?::(\d{2}))?$/ )
if ( m ) return ` Every hour at minute ${ m [ 1 ] } `
// Mon,Tue *-*-* HH:MM:SS → "<weekdays> at HH:MM"
m = s . match ( /^([A-Za-z,.\s]+)\s+\*-\*-\*\s+(\d{1,2}):(\d{2})(?::(\d{2}))?$/ )
if ( m ) {
const expandWeekdays = ( chunk : string ) : string = > {
const days : Record < string , string > = {
mon : "Monday" , tue : "Tuesday" , wed : "Wednesday" ,
thu : "Thursday" , fri : "Friday" , sat : "Saturday" , sun : "Sunday" ,
}
const rangeMatch = chunk . match ( /^([A-Za-z]+)\.\.([A-Za-z]+)$/ )
if ( rangeMatch ) {
const order = [ "mon" , "tue" , "wed" , "thu" , "fri" , "sat" , "sun" ]
const start = order . indexOf ( rangeMatch [ 1 ] . slice ( 0 , 3 ) . toLowerCase ( ) )
const end = order . indexOf ( rangeMatch [ 2 ] . slice ( 0 , 3 ) . toLowerCase ( ) )
if ( start >= 0 && end >= 0 && start <= end ) {
return order . slice ( start , end + 1 ) . map ( ( d ) = > days [ d ] ) . join ( ", " )
}
}
return chunk
. split ( "," )
. map ( ( d ) = > days [ d . trim ( ) . slice ( 0 , 3 ) . toLowerCase ( ) ] || d . trim ( ) )
. join ( ", " )
}
return ` ${ expandWeekdays ( m [ 1 ] ) } at ${ m [ 2 ] . padStart ( 2 , "0" ) } : ${ m [ 3 ] } `
}
return ` ${ s } (systemd OnCalendar) `
}
2026-06-20 21:59:19 +02:00
// A job is "running" when its .status file has RUN_AT (runner started)
// but no RESULT yet (runner hasn't written the verdict). Lets the UI
// flag in-progress runs both inline (badge in the row) and from the
// Manual backups card so an operator who closes the dialog can re-open
// the live stream.
function isJobRunning ( j : BackupJob | null | undefined ) : boolean {
if ( ! j ? . last_status ) return false
const s = parseJobStatus ( j . last_status )
return ! ! ( s ? . runAt && ! s ? . result )
}
2026-06-19 23:38:57 +02:00
// last_status is the raw RUN_AT=..., RESULT=..., LOG_FILE=... blob the
// scheduler runner persists. We only care about RESULT + RUN_AT for the
// UI; everything else is noise the operator doesn't need on a list view.
function parseJobStatus ( raw : string | null ) : { result : string | null ; runAt : string | null ; logFile : string | null } | null {
if ( ! raw ) return null
const map : Record < string , string > = { }
for ( const line of raw . split ( "\n" ) ) {
const m = line . match ( /^([A-Z_]+)=(.*)$/ )
if ( m ) map [ m [ 1 ] ] = m [ 2 ]
}
return {
result : map [ "RESULT" ] || null ,
runAt : map [ "RUN_AT" ] || null ,
logFile : map [ "LOG_FILE" ] || null ,
}
}
// Backend-method color scheme, mirrored exactly from storage-overview.tsx
// (line 414 et al.) so a PBS job badge here looks identical to a PBS
// snapshot badge on the Storage page.
const methodBadgeCls = ( m : string | undefined ) : string = > {
switch ( ( m || "" ) . toLowerCase ( ) ) {
case "pbs" :
return "bg-purple-500/10 text-purple-400 border-purple-500/20"
case "borg" :
return "bg-fuchsia-500/10 text-fuchsia-400 border-fuchsia-500/20"
case "local" :
default :
return "bg-blue-500/10 text-blue-400 border-blue-500/20"
}
}
const formatRunAt = ( iso : string | null ) = > {
if ( ! iso ) return null
try {
return new Date ( iso ) . toLocaleString ( undefined , {
day : "2-digit" ,
month : "short" ,
year : "numeric" ,
hour : "2-digit" ,
minute : "2-digit" ,
} )
} catch {
return iso
}
}
2026-07-06 00:30:47 +02:00
// ── PBS keyfile management (shared between CreateJob + ManualBackup) ──
//
// Rendered inside the encryption section when a keyfile is already
// installed. Replaces the older static info box that pointed to a
// non-existent "Settings → Host backup" panel: the operator now has
// three concrete actions (change stored keyfile passphrase, replace
// keyfile, remove keyfile) with destructive-confirmation modals that
// call the /api/host-backups/pbs-encryption/* endpoints and mutate
// pbsRecoveryStatus so the parent dialog re-renders on success.
//
// `onReplaced` is called after a successful remove/replace-remove so
// the parent can reset its local pbsEncryptMode from "existing" back
// to "new" and let the Generate/Import radio unfold naturally.
function KeyfileManageInline ( {
reuseText ,
onReplaced ,
mutateStatus ,
} : {
reuseText : string
onReplaced : ( ) = > void
mutateStatus : ( ) = > Promise < unknown >
} ) {
const [ action , setAction ] = useState < null | "passphrase" | "replace" | "remove" > ( null )
const [ busy , setBusy ] = useState ( false )
const [ err , setErr ] = useState < string | null > ( null )
const [ pass1 , setPass1 ] = useState ( "" )
const [ pass2 , setPass2 ] = useState ( "" )
const [ backupFirst , setBackupFirst ] = useState ( true )
const close = ( ) = > {
setAction ( null )
setBusy ( false )
setErr ( null )
setPass1 ( "" )
setPass2 ( "" )
setBackupFirst ( true )
}
const runPassphrase = async ( ) = > {
if ( pass1 !== pass2 ) {
setErr ( "Passphrases do not match." )
return
}
setBusy ( true )
setErr ( null )
try {
await fetchApi ( "/api/host-backups/pbs-encryption/passphrase" , {
method : "POST" ,
headers : { "Content-Type" : "application/json" } ,
body : JSON.stringify ( { new_passphrase : pass1 } ) ,
} )
await mutateStatus ( )
close ( )
} catch ( e ) {
setErr ( e instanceof Error ? e.message : String ( e ) )
setBusy ( false )
}
}
const runRemove = async ( mode : "remove" | "replace" ) = > {
setBusy ( true )
setErr ( null )
try {
await fetchApi ( "/api/host-backups/pbs-encryption/remove" , {
method : "POST" ,
headers : { "Content-Type" : "application/json" } ,
body : JSON.stringify ( { backup_before_remove : backupFirst } ) ,
} )
await mutateStatus ( )
if ( mode === "replace" ) onReplaced ( )
close ( )
} catch ( e ) {
setErr ( e instanceof Error ? e.message : String ( e ) )
setBusy ( false )
}
}
return (
< div className = "space-y-2" >
< div className = "rounded-md border border-emerald-500/30 bg-emerald-500/5 p-3 text-[11px] text-muted-foreground flex items-start gap-2" >
< CheckCircle2 className = "h-3.5 w-3.5 text-emerald-400 mt-0.5 shrink-0" / >
< div > { reuseText } < / div >
< / div >
< div className = "flex flex-wrap gap-1.5" >
< Button
type = "button"
size = "sm"
variant = "outline"
className = "h-7 text-[11px]"
onClick = { ( ) = > setAction ( "passphrase" ) }
>
Update stored passphrase
< / Button >
< Button
type = "button"
size = "sm"
variant = "outline"
className = "h-7 text-[11px]"
onClick = { ( ) = > setAction ( "replace" ) }
>
Replace keyfile
< / Button >
< Button
type = "button"
size = "sm"
variant = "outline"
className = "h-7 text-[11px] !text-red-400 border-red-500/40 hover:bg-red-500/10"
onClick = { ( ) = > setAction ( "remove" ) }
>
Remove keyfile
< / Button >
< / div >
< Dialog open = { action === "passphrase" } onOpenChange = { ( v ) = > ! v && close ( ) } >
< DialogContent >
< DialogHeader >
< DialogTitle > Update stored keyfile passphrase < / DialogTitle >
< DialogDescription >
This only rotates the passphrase ProxMenux uses to unlock the keyfile at backup time ( stored in < code className = "font-mono" > / usr / local / share / proxmenux / pbs - key . pass < / code > ) . It does < strong > not < / strong > re - encrypt the keyfile itself .
< / DialogDescription >
< / DialogHeader >
< div className = "space-y-3" >
< div >
< Label htmlFor = "mpass1" className = "text-xs" > New passphrase < / Label >
< Input id = "mpass1" type = "password" value = { pass1 } onChange = { ( e ) = > setPass1 ( e . target . value ) } placeholder = "Leave blank to remove the stored passphrase" className = "font-mono h-9" / >
< / div >
{ pass1 && (
< div >
< Label htmlFor = "mpass2" className = "text-xs" > Confirm new passphrase < / Label >
< Input id = "mpass2" type = "password" value = { pass2 } onChange = { ( e ) = > setPass2 ( e . target . value ) } className = "font-mono h-9" / >
< / div >
) }
{ err && (
< div className = "text-xs text-red-500 px-3 py-2 rounded-md border border-red-500/30 bg-red-500/10 whitespace-pre-wrap break-words" > { err } < / div >
) }
< / div >
< DialogFooter >
< Button variant = "outline" onClick = { close } > Cancel < / Button >
< Button onClick = { runPassphrase } disabled = { busy || ( ! ! pass1 && pass1 !== pass2 ) } >
{ busy ? < Loader2 className = "h-4 w-4 animate-spin" / > : "Save" }
< / Button >
< / DialogFooter >
< / DialogContent >
< / Dialog >
< Dialog open = { action === "replace" || action === "remove" } onOpenChange = { ( v ) = > ! v && close ( ) } >
< DialogContent >
< DialogHeader >
< DialogTitle className = "flex items-center gap-2" >
< AlertTriangle className = "h-5 w-5 text-red-400" / >
{ action === "replace" ? "Replace keyfile" : "Remove keyfile" }
< / DialogTitle >
< DialogDescription >
Backups already stored on PBS were encrypted with the current keyfile . After this action :
< / DialogDescription >
< / DialogHeader >
< ul className = "text-xs text-muted-foreground list-disc list-inside space-y-1 pl-2" >
< li > New backups will use { action === "replace" ? "the newly generated / imported keyfile" : "no encryption on this host until a new keyfile is set up" } . < / li >
< li > Downloading pre - existing encrypted backups from this host < strong className = "text-red-400" > will fail < / strong > unless you keep a copy of the current key . < / li >
< li > Existing recovery blobs on PBS stay intact — they still recover the old key with its original passphrase . < / li >
< / ul >
< label className = "flex items-start gap-2 cursor-pointer text-xs" >
< Checkbox checked = { backupFirst } onCheckedChange = { ( v ) = > setBackupFirst ( ! ! v ) } className = "mt-0.5" / >
< span > Save a copy of the current keyfile + passphrase + recovery blob under < code className = "font-mono" > / root / pbs - key . old - & lt ; timestamp & gt ; . * < / code > before { action === "replace" ? "replacing" : "removing" } . < / span >
< / label >
{ err && (
< div className = "text-xs text-red-500 px-3 py-2 rounded-md border border-red-500/30 bg-red-500/10 whitespace-pre-wrap break-words" > { err } < / div >
) }
< DialogFooter >
< Button variant = "outline" onClick = { close } disabled = { busy } > Cancel < / Button >
< Button
onClick = { ( ) = > runRemove ( action === "replace" ? "replace" : "remove" ) }
disabled = { busy }
className = "!bg-red-500 hover:!bg-red-600 !text-white"
>
{ busy ? < Loader2 className = "h-4 w-4 animate-spin" / > : ( action === "replace" ? "Remove current — set up new" : "Remove" ) }
< / Button >
< / DialogFooter >
< / DialogContent >
< / Dialog >
< / div >
)
}
2026-06-09 00:13:24 +02:00
export function HostBackup() {
2026-06-13 19:05:00 +02:00
const { data : jobsResp , error : jobsErr , mutate : mutateJobs } = useSWR < { jobs : BackupJob [ ] } > (
2026-06-09 00:13:24 +02:00
"/api/host-backups/jobs" ,
fetcher ,
{ refreshInterval : 30000 } ,
)
2026-06-13 19:05:00 +02:00
const [ busyJobId , setBusyJobId ] = useState < string | null > ( null )
const [ actionError , setActionError ] = useState < string | null > ( null )
2026-06-19 23:38:57 +02:00
const [ jobToDelete , setJobToDelete ] = useState < BackupJob | null > ( null )
const [ creatingJob , setCreatingJob ] = useState < boolean > ( false )
const [ editingJobId , setEditingJobId ] = useState < string | null > ( null )
const [ viewingJobId , setViewingJobId ] = useState < string | null > ( null )
2026-06-20 21:59:19 +02:00
const [ watchingManualId , setWatchingManualId ] = useState < string | null > ( null )
2026-06-19 23:38:57 +02:00
const [ runningManual , setRunningManual ] = useState < boolean > ( false )
2026-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" >
2026-07-05 16:50:06 +02:00
{ / * ─ ─ P o s t - r e s t o r e p r o g r e s s c a r d ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
Renders only while a restore is running or its
summary hasn ' t been acknowledged . Once dismissed ,
it collapses to a "Past restores" ghost button . * / }
< RestoreProgressCard / >
2026-06-09 00:13:24 +02:00
{ /* ── Scheduled jobs ───────────────────────────────── */ }
< Card className = "bg-card border-border" >
< CardHeader className = "flex flex-row items-center justify-between space-y-0 pb-3" >
< div className = "flex items-center gap-2" >
< Calendar className = "h-5 w-5 text-blue-500" / >
< CardTitle className = "text-base font-semibold" > Scheduled Backup Jobs < / CardTitle >
2026-06-19 23:38:57 +02:00
< Badge variant = "outline" className = "ml-1" >
{ jobsResp ? . jobs ? . filter ( ( j ) = > ! j . manual ) . length ? ? 0 }
< / Badge >
2026-06-09 00:13:24 +02:00
< / div >
2026-06-19 23:38:57 +02:00
< Button
size = "sm"
className = "h-8 px-3 bg-blue-500 hover:bg-blue-600 text-white"
onClick = { ( ) = > setCreatingJob ( true ) }
>
< Plus className = "h-4 w-4 mr-1" / >
Create job
< / Button >
2026-06-09 00:13:24 +02:00
< / CardHeader >
< CardContent >
{ jobsErr ? (
< div className = "text-sm text-red-500 py-4" > Failed to load jobs < / div >
) : ! jobsResp ? (
< div className = "flex items-center gap-2 py-4 text-sm text-muted-foreground" >
< Loader2 className = "h-4 w-4 animate-spin" / >
Loading . . .
< / div >
2026-06-19 23:38:57 +02:00
) : jobsResp . jobs . filter ( ( j ) = > ! j . manual ) . length === 0 ? null : (
< div className = "space-y-2" >
2026-06-13 19:05:00 +02:00
{ actionError && (
2026-07-05 23:14:52 +02:00
< div className = "text-xs text-red-500 px-3 py-2 rounded-md border border-red-500/30 bg-red-500/10 whitespace-pre-wrap break-words" >
2026-06-13 19:05:00 +02:00
{ actionError }
< / div >
) }
2026-06-19 23:38:57 +02:00
{ jobsResp . jobs . filter ( ( j ) = > ! j . manual ) . map ( ( j ) = > {
const status = parseJobStatus ( j . last_status )
2026-06-20 21:59:19 +02:00
const running = isJobRunning ( j )
// While running we override the ok/failed pill with a
// running spinner — same way the modal does. Clicking
// the row re-opens JobDetailModal which auto-detects
// the in-progress state and resumes streaming.
const statusBadge = running
? { label : "running" , cls : "bg-blue-500/10 border-blue-500/40 text-blue-300" }
: status ? . result === "ok"
? { label : "ok" , cls : "bg-emerald-500/10 border-emerald-500/40 text-emerald-400" }
: status ? . result
? { label : status.result , cls : "bg-red-500/10 border-red-500/40 text-red-400" }
: null
2026-06-19 23:38:57 +02:00
const lastRunWhen = formatRunAt ( status ? . runAt ? ? null )
return (
< button
key = { j . id }
type = "button"
onClick = { ( ) = > setViewingJobId ( j . id ) }
className = "w-full text-left flex items-start gap-3 p-3 rounded-md border border-border bg-card hover:bg-white/5 transition-colors group"
title = "Click to open this job"
>
< div className = "min-w-0 flex-1 w-full" >
{ /* Title row */ }
< div className = "flex items-center gap-2 flex-wrap" >
< span className = "font-semibold text-base truncate" title = { j . id } > { j . id } < / span >
< Badge variant = "outline" className = { ` text-[10px] uppercase tracking-wide ${ methodBadgeCls ( j . method ) } ` } >
{ j . method }
< / Badge >
{ j . manual && (
< Badge variant = "outline" className = "text-[10px] text-purple-400 border-purple-400/40 bg-purple-500/5" >
manual
< / Badge >
) }
2026-06-13 19:05:00 +02:00
{ j . attached && (
2026-06-19 23:38:57 +02:00
< Badge variant = "outline" className = "text-[10px] text-blue-400 border-blue-400/40 bg-blue-500/5" >
2026-06-13 19:05:00 +02:00
attached
< / Badge >
) }
2026-06-20 21:59:19 +02:00
{ j . encrypted && (
< Badge
variant = "outline"
className = "text-[10px] uppercase tracking-wide border-emerald-500/40 text-emerald-400 bg-emerald-500/5"
title = "Encrypted backups (client-side keyfile / borg repokey)"
>
< Lock className = "h-3.5 w-3.5" / >
< / Badge >
) }
2026-06-24 23:40:22 +02:00
{ / * S u r f a c e t h e p r o f i l e m o d e a t r o w l e v e l s o t h e
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 >
{ / * S t a t s r o w — i c o n s i n g r e e n , v a l u e s i n f o r e g r o u n d
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
{ / * I n - p r o g r e s s m a n u a l j o b s . I f t h e o p e r a t o r c l o s e d t h e
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"
>
< 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 }
2026-06-09 00:13:24 +02:00
< / 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 ) }
2026-06-09 00:13:24 +02:00
< / span >
2026-06-19 23:38:57 +02:00
< 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 >
2026-06-09 00:13:24 +02:00
< / div >
2026-06-19 23:38:57 +02:00
< 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
} }
/ >
{ /* ── 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 ( )
} }
/ >
{ / * M a n u a l j o b s u s e t h e i r o w n m o d a l — n o S c h e d u l e / R e t e n t i o n /
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-09 00:13:24 +02:00
/ >
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
{ / * B a c k u p i n f o — u n i f o r m g r i d , w i t h b a c k e n d - s p e c i f i c
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 >
{ /* 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" >
{ archiveLog . tail . join ( "\n" ) }
< / 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 >
2026-06-19 23:38:57 +02:00
< / div >
< / 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
{ /* 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 >
2026-06-19 23:38:57 +02:00
< / div >
2026-06-21 22:41:54 +02:00
{ 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 >
) }
2026-06-19 23:38:57 +02:00
< / div >
2026-06-21 22:41:54 +02:00
) }
2026-06-19 23:38:57 +02:00
2026-06-21 22:41:54 +02:00
{ error && (
2026-07-05 23:14:52 +02:00
< div className = "text-xs text-red-500 px-3 py-2 rounded-md border border-red-500/30 bg-red-500/10 whitespace-pre-wrap break-words" >
2026-06-21 22:41:54 +02:00
{ error }
< / div >
) }
< / div >
< / ScrollArea >
{ / * ─ ─ F o o t e r : s a m e 4 b u t t o n s f o r t h e 3 b a c k e n d s ─ ─ ─ ─ ─ ─ ─ ─ ─
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" >
2026-06-19 23:38:57 +02:00
< Button
2026-06-21 22:41:54 +02:00
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)"
2026-06-19 23:38:57 +02:00
>
2026-06-21 22:41:54 +02:00
{ restorePreparing ? (
< Loader2 className = "h-4 w-4 sm:mr-2 animate-spin" / >
2026-06-19 23:38:57 +02:00
) : (
2026-06-21 22:41:54 +02:00
< DatabaseBackup className = "h-4 w-4 sm:mr-2" / >
2026-06-19 23:38:57 +02:00
) }
2026-06-21 22:41:54 +02:00
< span > Restore < / span >
2026-06-19 23:38:57 +02:00
< / Button >
< Button
2026-06-21 22:41:54 +02:00
onClick = { downloadArchive }
disabled = { downloading }
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 = "Download the snapshot as a .tar.zst"
2026-06-19 23:38:57 +02:00
>
2026-06-21 22:41:54 +02:00
{ downloading ? (
< Loader2 className = "h-4 w-4 sm:mr-2 animate-spin" / >
2026-06-19 23:38:57 +02:00
) : (
2026-06-21 22:41:54 +02:00
< Download className = "h-4 w-4 sm:mr-2" / >
2026-06-19 23:38:57 +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 >
2026-06-21 00:35:22 +02:00
< Button
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-19 23:38:57 +02:00
< / Button >
2026-06-21 22:41:54 +02:00
< / 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 >
) }
{ / * ─ ─ R e s t o r e o p t i o n s m o d a l : o p e n s o n l y A F T E R t h e p r e p a r e s t e p ,
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
} }
/ >
{ / * ─ ─ R e s t o r e t e r m i n a l — u s e s t h e c a n o n i c a l S c r i p t T e r m i n a l M o d a l
( 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 >
< / div >
< / DialogContent >
< / Dialog >
{ /* ── 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 >
2026-06-09 00:13:24 +02:00
< / div >
< / DialogContent >
< / Dialog >
2026-06-19 23:38:57 +02:00
< / >
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 )
2026-06-09 00:13:24 +02:00
return (
2026-06-19 23:38:57 +02:00
< 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 >
)
}
// ── 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 } ` } >
2026-06-09 00:13:24 +02:00
< AlertTriangle className = "h-4 w-4" / >
{ summary . warn } warn
< / span >
< span className = { ` inline-flex items-center gap-1 ${ failColor } ` } >
< XCircle className = "h-4 w-4" / >
{ summary . fail } fail
< / span >
{ summary . fail > 0 && (
< span className = "ml-auto text-xs text-red-500" >
-- apply would be refused
< / span >
) }
< / div >
{ /* Per-check list */ }
< div className = "space-y-1.5" >
{ checks . map ( ( c ) = > {
const color =
c . severity === "pass" ? passColor :
c . severity === "warn" ? warnColor :
failColor
const Icon =
c . severity === "pass" ? CheckCircle2 :
c . severity === "warn" ? AlertTriangle :
XCircle
return (
< div key = { c . id } className = "flex items-start gap-2 text-xs" >
< Icon className = { ` h-3.5 w-3.5 ${ color } flex-shrink-0 mt-0.5 ` } / >
< div className = "flex-1 min-w-0" >
< span className = { ` font-mono ${ color } ` } > { c . id } < / span >
< span className = "text-muted-foreground ml-2" > { c . message } < / span >
< / div >
< / div >
)
} ) }
< / div >
{ /* Storage / network counts */ }
< div className = "grid grid-cols-2 gap-3 text-xs pt-2 border-t border-border/40" >
< div >
< div className = "text-muted-foreground mb-1" > Storage [ in mode : { String ( report . storage . in_selected_mode ) } ] < / div >
< div >
{ report . storage . zfs . length } ZFS pool ( s ) ·
{ " " } { report . storage . lvm . length } LVM VG ( s ) ·
{ " " } { report . storage . pve_storage . length } PVE storage ( s )
< / div >
< / div >
< div >
< div className = "text-muted-foreground mb-1" > Network [ in mode : { String ( report . network . in_selected_mode ) } ] < / div >
< div >
{ report . network . keep . length } keep ·
{ " " } { report . network . remap . length } remap ·
{ " " } { report . network . orphan . length } orphan ·
{ " " } { report . network . new . length } new
< / div >
< / div >
< / div >
{ /* Driver plan */ }
{ report . driver_reinstall . plan . length > 0 && (
< div className = "text-xs pt-2 border-t border-border/40" >
< div className = "text-muted-foreground mb-1.5" > Driver reinstall plan ( { report . driver_reinstall . plan . length } ) < / div >
< div className = "space-y-1" >
{ report . driver_reinstall . plan . map ( ( p ) = > (
< div key = { p . component_id } className = "flex items-center justify-between gap-2" >
< span className = "font-mono" > { p . component_id } < / span >
< Badge variant = "outline" className = "text-[10px]" > { p . action } < / Badge >
< / div >
) ) }
< / div >
< / div >
) }
{ /* Abort reason (if --apply would have been refused) */ }
{ report . abort_reason && (
< div className = "text-xs text-red-500 p-2 border border-red-500/30 rounded-md bg-red-500/10" >
{ report . abort_reason }
< / div >
) }
< / div >
)
}
2026-06-19 23:38:57 +02:00
// ──────────────────────────────────────────────────────────────
// Create job wizard (A3.1 — attached jobs only).
//
// Pre-loads three things in parallel so the form can render selects
// the moment the operator opens it:
// - PVE vzdump jobs (filtered by backend once they pick one)
// - Pre-configured destinations (PBS repos, Borg targets, local)
// - Default profile path list (used when profile_mode === "default"
// and also as the pool the custom checklist is populated from)
// ──────────────────────────────────────────────────────────────
interface PveVzdumpJob {
id : string
storage : string | null
storage_type : string | null
schedule : string | null
prune : string | null
enabled : boolean
}
2026-06-20 21:59:19 +02:00
// All Destination shapes carry `jobs_using`: list of job_ids that
// currently depend on the destination. Surfaced in the Delete-
// destination confirm flow so the operator sees the cascade impact
// before agreeing.
2026-06-19 23:38:57 +02:00
interface PbsRepo {
name : string
repository : string
fingerprint : string | null
source : "proxmox" | "manual"
2026-06-20 21:59:19 +02:00
jobs_using? : string [ ]
2026-06-19 23:38:57 +02:00
}
interface BorgRepo {
name : string
repository : string
ssh_key_path? : string
2026-06-20 21:59:19 +02:00
// Encryption + saved-passphrase metadata. Newer backends ship these;
// older deployments without the fields default to "repokey" (the
// shell installer's historical default) and unknown-passphrase.
encrypt_mode ? : "none" | "repokey" | "keyfile" | "authenticated"
has_passphrase? : boolean
jobs_using? : string [ ]
2026-06-19 23:38:57 +02:00
}
interface LocalTargetEntry {
path : string
source : "default" | "custom"
removable : boolean
2026-06-20 21:59:19 +02:00
jobs_using? : string [ ]
2026-06-19 23:38:57 +02:00
}
interface LocalTarget {
configured : string | null
default : string
effective : string
// Multi-path layout: the default plus any custom paths stacked on
// top. Present on every recent backend; older deployments without
// this field fall back to the single configured/effective accessors.
entries? : LocalTargetEntry [ ]
}
interface DestinationsResp {
pbs : PbsRepo [ ]
borg : BorgRepo [ ]
local : LocalTarget
}
interface JobDetail {
id : string
attached : boolean
enabled : boolean
manual : boolean
method : string
destination : string
on_calendar : string // human-formatted ("attached → storage:pbs" or "*-*-* 02:00")
next_run : string | null
pve_storage : string | null
pve_parent_job_id : string | null
profile_mode : string
paths : string [ ]
on_calendar_raw : string | null
retention : Record < string , string | undefined >
pbs_repository : string | null
pbs_backup_id : string | null
pbs_fingerprint : string | null
has_pbs_password : boolean
2026-07-05 09:30:13 +02:00
// Legacy — kept for backend responses that only track the on/off
// toggle. Newer builds also send pbs_encrypt_mode ("none" | "new"
// | "existing"); loadFromJobDetail prefers that when present.
2026-06-20 21:59:19 +02:00
pbs_encrypt : boolean
2026-07-05 09:30:13 +02:00
pbs_encrypt_mode ? : "none" | "new" | "existing"
2026-06-19 23:38:57 +02:00
local_dest_dir : string | null
local_archive_ext : string | null
borg_repo : string | null
borg_encrypt_mode : string
has_borg_passphrase : boolean
// Log fields from Sprint Pulido P1
last_result : string | null // "ok" / "failed" / etc.
last_run_at : string | null // ISO-8601
last_log_path : string | null
last_log_size : number
last_log_tail : string [ ]
}
function CreateJobDialog ( {
open ,
onClose ,
onCreated ,
editingJobId ,
} : {
open : boolean
onClose : ( ) = > void
onCreated : ( ) = > void
editingJobId? : string | null
} ) {
const isEdit = ! ! editingJobId
const [ step , setStep ] = useState < 1 | 2 | 3 | 4 | 5 > ( 1 )
const [ jobId , setJobId ] = useState ( "" )
const [ backend , setBackend ] = useState < "pbs" | "local" | "borg" > ( "pbs" )
const [ mode , setMode ] = useState < "new" | "attach" > ( "new" )
const [ pveJobId , setPveJobId ] = useState < string > ( "" )
const [ profileMode , setProfileMode ] = useState < "default" | "custom" > ( "default" )
const [ customPaths , setCustomPaths ] = useState < Set < string > > ( new Set ( ) )
// New (timer-based) mode fields — onCalendar is computed from the
// builder controls below; the raw expression is only directly edited
// when scheduleType === "advanced".
const [ scheduleType , setScheduleType ] = useState < "daily" | "hourly" | "weekly" | "monthly" | "advanced" > ( "daily" )
const [ scheduleTime , setScheduleTime ] = useState < string > ( "02:00" )
const [ scheduleMinute , setScheduleMinute ] = useState < string > ( "0" )
const [ scheduleWeekdays , setScheduleWeekdays ] = useState < Set < string > > ( new Set ( [ "Sun" ] ) )
const [ scheduleDayOfMonth , setScheduleDayOfMonth ] = useState < string > ( "1" )
const [ scheduleAdvanced , setScheduleAdvanced ] = useState < string > ( "daily" )
const [ keepLast , setKeepLast ] = useState < string > ( "7" )
const [ keepHourly , setKeepHourly ] = useState < string > ( "0" )
const [ keepDaily , setKeepDaily ] = useState < string > ( "7" )
const [ keepWeekly , setKeepWeekly ] = useState < string > ( "4" )
const [ keepMonthly , setKeepMonthly ] = useState < string > ( "3" )
const [ keepYearly , setKeepYearly ] = useState < string > ( "0" )
// Backend-specific fields
const [ pbsRepository , setPbsRepository ] = useState < string > ( "" )
const [ pbsBackupId , setPbsBackupId ] = useState < string > ( "" )
2026-07-05 09:30:13 +02:00
// Client-side PBS encryption mode. Three modes match the shell wizard:
// - "none" — plain backup, no --keyfile
// - "new" — backend generates a fresh per-host keyfile (default)
// - "existing" — operator uploads their own keyfile (shared across
// hosts). The upload happens via a dedicated endpoint
// BEFORE the job create call — see handleCreate below.
const [ pbsEncryptMode , setPbsEncryptMode ] = useState < "none" | "new" | "existing" > ( "none" )
// File picked in the "existing" mode. Kept in a ref-like state so a
// stale reference doesn't linger across modal opens.
const [ pbsImportFile , setPbsImportFile ] = useState < File | null > ( null )
2026-07-05 23:58:58 +02:00
const [ pbsImportKeyfilePass , setPbsImportKeyfilePass ] = useState < string > ( "" )
2026-07-05 09:30:13 +02:00
const [ pbsImportBusy , setPbsImportBusy ] = useState < boolean > ( false )
// Legacy alias for the many recovery / gating checks below — a truthy
// value means "the operator wants an encrypted backup" regardless of
// whether that keyfile came from a fresh generate or from an import.
const pbsEncrypt = pbsEncryptMode !== "none"
2026-06-20 21:59:19 +02:00
// Optional recovery passphrase. When set, the backend encrypts the
// keyfile with openssl and the runner uploads that blob to PBS with
// every backup so the keyfile can be rebuilt on a fresh host with
// only the passphrase. Mirrors hb_pbs_setup_recovery from the shell.
const [ pbsRecoveryPass , setPbsRecoveryPass ] = useState < string > ( "" )
const [ pbsRecoveryPass2 , setPbsRecoveryPass2 ] = useState < string > ( "" )
2026-06-21 00:35:22 +02:00
// Operator opted to replace an already-configured recovery escrow.
// Default behavior matches the shell: don't prompt for the
// passphrase if one is already saved — only show the inputs when
// there's no escrow yet OR the operator explicitly asks to change.
const [ pbsRecoveryChange , setPbsRecoveryChange ] = useState < boolean > ( false )
2026-06-20 21:59:19 +02:00
// Whether the host already has an escrow blob configured. When
// present, the passphrase input becomes optional ("leave blank to
// keep saved"). Refreshed after a successful setup call.
const { data : pbsRecoveryStatus , mutate : mutatePbsRecovery } = useSWR < {
2026-07-05 23:58:58 +02:00
has_keyfile : boolean ; has_recovery : boolean ; has_keyfile_passphrase? : boolean
2026-06-20 21:59:19 +02:00
} > (
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-07-05 09:30:13 +02:00
// Prefer the new pbs_encrypt_mode string when the backend sends it;
// fall back to the legacy bool for older `.env` layouts that only
// remember the on/off toggle.
const jd = jobDetail as unknown as { pbs_encrypt_mode? : string ; pbs_encrypt? : boolean }
if ( jd . pbs_encrypt_mode === "new" || jd . pbs_encrypt_mode === "existing" || jd . pbs_encrypt_mode === "none" ) {
setPbsEncryptMode ( jd . pbs_encrypt_mode )
} else {
setPbsEncryptMode ( jd . pbs_encrypt ? "new" : "none" )
}
2026-06-19 23:38:57 +02:00
if ( jobDetail . local_dest_dir ) setLocalDestDir ( jobDetail . local_dest_dir )
if ( jobDetail . borg_repo ) setBorgRepoSelected ( jobDetail . borg_repo )
if ( jobDetail . borg_encrypt_mode ) {
setBorgEncryptMode ( jobDetail . borg_encrypt_mode as "none" | "repokey" | "keyfile" | "authenticated" )
}
setEditPreloaded ( true )
} , [ isEdit , jobDetail , editPreloaded ] )
// Reset the preload flag whenever the dialog closes so the next open
// (potentially for a different job) re-populates fresh.
useEffect ( ( ) = > {
if ( ! open ) {
setEditPreloaded ( false )
setOriginalBackend ( null )
setOriginalMode ( null )
}
} , [ open ] )
const { data : pveJobsResp } = useSWR < { jobs : PveVzdumpJob [ ] } > (
open && mode === "attach" && backend !== "borg" ? ` /api/host-backups/pve-vzdump-jobs?backend= ${ backend } ` : null ,
fetcher ,
)
// Live calendar preview — only fetch when we're actually on Step 3 in
// "new" mode, otherwise the request is noise.
const calendarPreviewKey = open && mode === "new" && step === 3 && onCalendar . trim ( ) . length > 0
? ` calendar-preview:: ${ onCalendar } `
: null
const { data : calendarPreview } = useSWR < {
valid : boolean
error? : string
normalized? : string
next_elapse? : string
from_now? : string
} > (
calendarPreviewKey ,
( ) = >
fetchApi ( "/api/host-backups/calendar-preview" , {
method : "POST" ,
headers : { "Content-Type" : "application/json" } ,
body : JSON.stringify ( { expr : onCalendar } ) ,
} ) ,
{ dedupingInterval : 400 } ,
)
const { data : destResp , mutate : mutateDest } = useSWR < DestinationsResp > (
open ? "/api/host-backups/destinations" : null ,
fetcher ,
)
const { data : pathsResp } = useSWR < { paths : string [ ] } > (
open ? "/api/host-backups/default-paths" : null ,
fetcher ,
)
// The wizard can spawn AddDestinationDialog inline so the operator
// configures a PBS / Borg destination without leaving the flow.
// When AddDestinationDialog saves, we capture the new repository
// string and auto-select it on the next destResp refresh — saves the
// operator from picking what they just configured from the dropdown.
const [ addingDestType , setAddingDestType ] = useState < "pbs" | "borg" | "local" | null > ( null )
const [ pendingAutoSelectDest , setPendingAutoSelectDest ] = useState < { kind : "pbs" | "borg" ; repository : string } | null > ( null )
// When the destinations load, auto-pick the first PBS repo + fill in
// the fingerprint reference so the operator doesn't have to navigate
// away just to copy it. Same convenience for the backup-id.
const selectedPbs : PbsRepo | undefined =
destResp ? . pbs ? . find ( ( r ) = > r . repository === pbsRepository ) ? ? destResp ? . pbs ? . [ 0 ]
// Reset state on open / backend change
useEffect ( ( ) = > {
if ( ! open ) {
setStep ( 1 )
setJobId ( "" )
setBackend ( "pbs" )
setMode ( "new" )
setPveJobId ( "" )
setProfileMode ( "default" )
setCustomPaths ( new Set ( ) )
setScheduleType ( "daily" )
setScheduleTime ( "02:00" )
setScheduleMinute ( "0" )
setScheduleWeekdays ( new Set ( [ "Sun" ] ) )
setScheduleDayOfMonth ( "1" )
setScheduleAdvanced ( "daily" )
setKeepLast ( "7" )
setKeepHourly ( "0" )
setKeepDaily ( "7" )
setKeepWeekly ( "4" )
setKeepMonthly ( "3" )
setKeepYearly ( "0" )
setPbsRepository ( "" )
setPbsBackupId ( "" )
2026-07-05 09:30:13 +02:00
setPbsEncryptMode ( "none" )
setPbsImportFile ( null )
2026-07-05 23:58:58 +02:00
setPbsImportKeyfilePass ( "" )
2026-07-05 09:30:13 +02:00
setPbsImportBusy ( false )
2026-06-20 21:59:19 +02:00
setPbsRecoveryPass ( "" )
setPbsRecoveryPass2 ( "" )
2026-06-21 00:35:22 +02:00
setPbsRecoveryChange ( false )
2026-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-07-05 16:50:06 +02:00
// Encryption submit paths:
// • enabled + keyfile already on disk → mode = "existing", no upload
// (backend reuses the canonical file at _PBS_KEYFILE_PATH).
// • enabled + no keyfile + Generate → mode = "new", backend creates it.
// • enabled + no keyfile + Import → upload the file first, then
// mode = "existing" so the backend uses the freshly-installed key.
//
// Only the third branch actually needs to POST to
// /api/host-backups/pbs-encryption/import — the second is handled
// server-side, and the first has nothing to upload.
const needsImport =
backend === "pbs" &&
pbsEncryptMode === "existing" &&
! pbsRecoveryStatus ? . has_keyfile
if ( needsImport ) {
2026-07-05 09:30:13 +02:00
if ( ! pbsImportFile ) {
setError ( "Pick a keyfile to import." )
setSubmitting ( false )
return
}
setPbsImportBusy ( true )
try {
const buf = new Uint8Array ( await pbsImportFile . arrayBuffer ( ) )
// btoa on binary requires a String.fromCharCode round-trip.
// The keyfile is small (~200 bytes), so no perf concern.
let bin = ""
for ( let i = 0 ; i < buf . length ; i ++ ) bin += String . fromCharCode ( buf [ i ] )
const content_b64 = btoa ( bin )
await fetchApi ( "/api/host-backups/pbs-encryption/import" , {
method : "POST" ,
headers : { "Content-Type" : "application/json" } ,
2026-07-05 23:58:58 +02:00
body : JSON.stringify ( {
content_b64 ,
keyfile_passphrase : pbsImportKeyfilePass ,
} ) ,
2026-07-05 09:30:13 +02:00
} )
mutatePbsRecovery ( )
} catch ( e ) {
2026-07-05 23:14:52 +02:00
// The import endpoint returns `{error, tool_output, tool_exit_code}`
// on validation failure. Surface the tool output verbatim so the
// operator can tell a real "not a keyfile" from a "keyfile needs
// a passphrase / uses unsupported KDF" and act on it.
const err = e as Error & { body ? : { tool_output? : string ; tool_exit_code? : number } }
const detail = err . body ? . tool_output
? ` ${ err . message } \ n \ nproxmox-backup-client output: \ n ${ err . body . tool_output } `
: err . message
setError ( ` Keyfile import failed: ${ detail || String ( e ) } ` )
2026-07-05 09:30:13 +02:00
setPbsImportBusy ( false )
setSubmitting ( false )
return
}
setPbsImportBusy ( false )
}
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-07-05 09:30:13 +02:00
body . pbs_encrypt_mode = pbsEncryptMode
2026-06-19 23:38:57 +02:00
} else if ( backend === "local" ) {
if ( localDestDir . trim ( ) ) body . local_dest_dir = localDestDir . trim ( )
} else if ( backend === "borg" ) {
2026-06-20 21:59:19 +02:00
// Passphrase + encrypt_mode are inherited from the destination
// (the runner reads borg-pass-<name>.txt + the 4th field of
// borg-targets.txt). Only send overrides when the operator
// really typed a new one in an edit flow.
2026-06-19 23:38:57 +02:00
body . borg_repo = borgRepoSelected
2026-06-20 21:59:19 +02:00
if ( isEdit && borgPassphrase ) body . borg_passphrase = borgPassphrase
2026-06-19 23:38:57 +02:00
}
// fetchApi returns the parsed JSON and throws (with the backend's
// error message in `error.message` when the body was JSON) on any
// non-2xx status — no need to inspect .ok or call .json() ourselves.
const url = isEdit
? ` /api/host-backups/jobs/ ${ encodeURIComponent ( editingJobId || "" ) } `
: "/api/host-backups/jobs"
await fetchApi ( url , {
method : isEdit ? "PUT" : "POST" ,
headers : { "Content-Type" : "application/json" } ,
body : JSON.stringify ( body ) ,
} )
onCreated ( )
} catch ( e ) {
setError ( e instanceof Error ? e.message : String ( e ) )
} finally {
setSubmitting ( false )
}
}
const compatibleJobs = pveJobsResp ? . jobs ? ? [ ]
const defaultPaths = pathsResp ? . paths ? ? [ ]
return (
< Dialog open = { open } onOpenChange = { ( v ) = > { if ( ! v ) onClose ( ) } } >
< DialogContent className = "max-w-2xl max-h-[85vh] flex flex-col" >
< DialogHeader >
< DialogTitle className = "flex items-center gap-2" >
{ isEdit ? (
< Pencil className = "h-5 w-5 text-blue-500" / >
) : (
< Plus className = "h-5 w-5 text-blue-500" / >
) }
{ isEdit ? "Edit scheduled backup job" : "Create scheduled backup job" }
< / DialogTitle >
< DialogDescription >
Step { step } of 5 · { mode === "attach" ? "Attached to PVE vzdump" : "Standalone scheduled job" }
< / DialogDescription >
< / DialogHeader >
{ /* Step progress bar */ }
< div className = "flex items-center gap-1" >
{ [ 1 , 2 , 3 , 4 , 5 ] . map ( ( n ) = > (
< div
key = { n }
className = { ` h-1 flex-1 rounded-full ${ n <= step ? "bg-blue-500" : "bg-border" } ` }
/ >
) ) }
< / div >
< div className = "overflow-y-auto pr-2 -mr-2 space-y-4 flex-1" >
{ /* ── Step 1: ID + Backend ─────────────────────── */ }
{ step === 1 && (
< div className = "space-y-4" >
< div >
< Label htmlFor = "jobId" > Job name < / Label >
< Input
id = "jobId"
value = { jobId }
onChange = { ( e ) = > setJobId ( e . target . value ) }
disabled = { isEdit }
className = "font-mono mt-1"
placeholder = "my-host-backup"
/ >
< p className = "text-xs text-muted-foreground mt-1" >
{ isEdit
? "The job name can't be changed. Delete and recreate the job if you want to rename it."
: < > A short name to identify this job in the list , logs , and shell menu . Letters , digits , < code className = "font-mono" > _ < / code > and < code className = "font-mono" > - < / code > only ( no spaces or accents ) . < / > }
< / p >
{ ! idValid && jobId . length > 0 && ! isEdit && (
< p className = "text-xs text-red-500 mt-1" > Invalid characters . Use letters , digits , _ or - . < / p >
) }
< / div >
< div >
< Label > Backend < / Label >
{ isEdit && (
< p className = "text-xs text-muted-foreground mb-2" >
You can change where the backup is sent . The destination of the new option is set on Step 5 .
< / p >
) }
< div className = "grid grid-cols-1 sm:grid-cols-3 gap-2 mt-1" >
{ ( [ "pbs" , "local" , "borg" ] as const ) . map ( ( b ) = > {
const Icon = b === "pbs" ? Server : b === "local" ? HardDrive : Archive
const desc = b === "pbs"
? "Proxmox Backup Server. Incremental, encrypted, dedup."
: b === "local"
? "tar.zst archive into a local directory or mounted disk."
: "Borg repo over SSH or on a local/USB disk (timer only)."
return (
< button
key = { b }
type = "button"
onClick = { ( ) = > setBackend ( b ) }
className = { ` text-left p-3 rounded-md border transition-colors ${
backend === b ? "border-blue-500 bg-blue-500/5" : "border-border bg-background/40"
} hover :bg - white / 5 ` }
>
< div className = "flex items-center gap-2 font-medium text-sm" >
< Icon className = "h-4 w-4" / >
{ b . toUpperCase ( ) }
< / div >
< div className = "text-xs text-muted-foreground mt-1" > { desc } < / div >
< / button >
)
} ) }
< / div >
< / div >
< / div >
) }
{ /* ── Step 2: How to schedule ──────────────────── */ }
{ step === 2 && (
< div className = "space-y-3" >
< div >
< Label > How to schedule < / Label >
< p className = "text-xs text-muted-foreground mb-2" >
{ backend === "borg"
? "Borg backups only run on their own timer — they're not produced by PVE vzdump."
: "Either run on a schedule you define here, or hook into an existing PVE vzdump job and inherit its schedule + retention." }
< / p >
< / div >
< div className = "space-y-2" >
< button
type = "button"
onClick = { ( ) = > setMode ( "new" ) }
className = { ` w-full text-left p-3 rounded-md border transition-colors ${ mode === "new" ? "border-blue-500 bg-blue-500/5" : "border-border bg-background/40" } hover:bg-white/5 ` }
>
< div className = "text-sm font-medium flex items-center gap-2" >
< Calendar className = "h-4 w-4" / >
New scheduled job
< / div >
< div className = "text-xs text-muted-foreground mt-1" >
Own systemd timer with the OnCalendar and retention policy you pick on the next steps .
< / div >
< / button >
< button
type = "button"
onClick = { ( ) = > backend !== "borg" && setMode ( "attach" ) }
disabled = { backend === "borg" }
className = { ` w-full text-left p-3 rounded-md border transition-colors ${
mode === "attach" && backend !== "borg"
? "border-blue-500 bg-blue-500/5"
: "border-border bg-background/40"
} $ { backend === "borg" ? "opacity-60 cursor-not-allowed" : "hover:bg-white/5" } ` }
>
< div className = "text-sm font-medium flex items-center gap-2" >
< Server className = "h-4 w-4" / >
Attach to existing PVE vzdump job
{ backend === "borg" && (
< Badge variant = "outline" className = "text-[10px] text-muted-foreground" >
not available for borg
< / Badge >
) }
< / div >
< div className = "text-xs text-muted-foreground mt-1" >
Fires on every < code className = "font-mono" > job - end < / code > of a PVE vzdump job that writes to a < code className = "font-mono" > { backend } < / code > storage . Inherits schedule + retention .
< / div >
< / button >
< / div >
< / div >
) }
{ /* ── Step 3: branches by mode ─────────────────── */ }
{ step === 3 && mode === "attach" && (
< div className = "space-y-3" >
< div >
< Label > Pick the parent PVE vzdump job < / Label >
< p className = "text-xs text-muted-foreground mb-2" >
The host config backup will fire on every < code className = "font-mono" > job - end < / code > of this job .
< / p >
< / div >
{ compatibleJobs . length === 0 ? (
< div className = "p-4 rounded-md border border-amber-500/40 bg-amber-500/5 text-sm" >
< div className = "flex items-center gap-2 text-amber-500 font-medium" >
< AlertTriangle className = "h-4 w-4" / >
No compatible PVE vzdump job
< / div >
< p className = "text-xs text-muted-foreground mt-1" >
No PVE vzdump job currently uses a < span className = "font-mono" > { backend } < / span > storage . Create one in < span className = "font-medium" > Datacenter → Backup < / span > first , then come back here to attach .
< / p >
< / div >
) : (
< div className = "space-y-1" >
{ compatibleJobs . map ( ( j ) = > (
< button
key = { j . id }
type = "button"
onClick = { ( ) = > setPveJobId ( j . id ) }
className = { ` w-full text-left p-3 rounded-md border ${ pveJobId === j . id ? "border-blue-500 bg-blue-500/5" : "border-border bg-background/40" } hover:bg-white/5 transition-colors ` }
>
< div className = "flex items-center justify-between gap-2 flex-wrap" >
< span className = "font-mono text-xs" > { j . id } < / span >
{ ! j . enabled && (
< Badge variant = "outline" className = "text-[10px] text-amber-500 border-amber-500/40" >
disabled
< / Badge >
) }
< / div >
< div className = "mt-1 text-xs text-muted-foreground flex items-center gap-x-3 gap-y-1 flex-wrap" >
< span > storage : < code className = "font-mono" > { j . storage } < / code > < / span >
< span > schedule : < code className = "font-mono" > { j . schedule || "—" } < / code > < / span >
< span > retention : < code className = "font-mono" > { j . prune || "—" } < / code > < / span >
< / div >
< / button >
) ) }
< / div >
) }
< / div >
) }
{ step === 3 && mode === "new" && (
< div className = "space-y-4" >
< div >
< Label > Schedule < / Label >
< p className = "text-xs text-muted-foreground mb-2" >
Pick how often this backup runs . The expression is built and validated for you .
< / p >
< Select
value = { scheduleType }
onValueChange = { ( v ) = > setScheduleType ( v as typeof scheduleType ) }
>
< SelectTrigger >
< SelectValue / >
< / SelectTrigger >
< SelectContent >
< SelectItem value = "daily" > Daily — every day at a specific time < / SelectItem >
< SelectItem value = "hourly" > Hourly — every hour at a specific minute < / SelectItem >
< SelectItem value = "weekly" > Weekly — on specific weekdays at a time < / SelectItem >
< SelectItem value = "monthly" > Monthly — on a specific day of the month < / SelectItem >
< SelectItem value = "advanced" > Advanced — type the OnCalendar expression myself < / SelectItem >
< / SelectContent >
< / Select >
< / div >
{ scheduleType === "daily" && (
< div >
< Label htmlFor = "schedTime" > Time of day < / Label >
< Input
id = "schedTime"
type = "time"
value = { scheduleTime }
onChange = { ( e ) = > setScheduleTime ( e . target . value ) }
className = "font-mono mt-1 max-w-[140px]"
/ >
< / div >
) }
{ scheduleType === "hourly" && (
< div >
< Label htmlFor = "schedMinute" > Minute of the hour ( 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 ) : / e t c / p v e , / e t c / n e t w o r k , / r o o t , / u s r / l o c a l , e t c .
< / 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" >
{ / * M a n u a l r e n d e r : S e l e c t V a l u e ' s d e f a u l t m o d e
duplicates the item children and cuts
them in the middle on mobile . We render
"name — repository" ourselves with a
proper trailing ellipsis . * / }
{ ( ( ) = > {
const sel = destResp . pbs . find ( ( r ) = > r . repository === pbsRepository )
if ( ! sel ) {
return < span className = "truncate text-muted-foreground" > Pick a PBS repository < / span >
}
return (
< span className = "truncate font-mono text-left" >
{ sel . name }
< span className = "text-muted-foreground" > — { sel . repository } < / span >
< / span >
)
} ) ( ) }
2026-06-19 23:38:57 +02:00
< / SelectTrigger >
< SelectContent >
{ destResp . pbs . map ( ( r ) = > (
< SelectItem key = { ` ${ r . name } - ${ r . repository } ` } value = { r . repository } >
< span className = "font-mono" > { r . name } < / span >
< span className = "text-muted-foreground ml-2" > — { r . repository } < / span >
< / SelectItem >
) ) }
< / SelectContent >
< / Select >
< Button
type = "button"
size = "sm"
variant = "outline"
className = "shrink-0 bg-blue-500/10 border-blue-500/40 !text-blue-400 hover:bg-blue-500/20 hover:!text-blue-300"
onClick = { ( ) = > setAddingDestType ( "pbs" ) }
title = "Save another PBS repository"
>
< Plus className = "h-3.5 w-3.5 mr-1" / >
Add another
< / Button >
< / div >
) : (
< div className = "mt-1 p-3 rounded-md border border-amber-500/40 bg-amber-500/5 text-xs space-y-2" >
< div > No PBS repository configured yet . You can add one without leaving this wizard . < / div >
< Button
type = "button"
size = "sm"
variant = "outline"
className = "bg-blue-500/10 border-blue-500/40 !text-blue-400 hover:bg-blue-500/20 hover:!text-blue-300"
onClick = { ( ) = > setAddingDestType ( "pbs" ) }
>
< Plus className = "h-3.5 w-3.5 mr-1" / >
Add PBS repository
< / Button >
< / div >
) }
< / div >
< div >
< Label htmlFor = "pbsBackupId" > Group name in PBS < / Label >
< Input
id = "pbsBackupId"
value = { pbsBackupId }
onChange = { ( e ) = > setPbsBackupId ( e . target . value ) }
placeholder = "Leave blank for the default"
className = "font-mono mt-1"
/ >
< p className = "text-xs text-muted-foreground mt-1" >
PBS organises backups into named groups , each with its own retention . Leave blank to use the automatic default for this host ( recommended ) .
< / p >
< / div >
2026-07-05 16:50:06 +02:00
{ / * P B S c l i e n t - s i d e e n c r y p t i o n — m i r r o r s t h e s h e l l w i z a r d :
step 1 is a plain yes / no , step 2 only appears when
no keyfile is installed yet . Once a keyfile exists ,
every future encrypted job silently reuses it . * / }
2026-06-20 21:59:19 +02:00
< div className = "pt-2 border-t border-border space-y-3" >
2026-07-05 16:50:06 +02:00
< label className = "flex items-start gap-2 cursor-pointer" >
< Checkbox
checked = { pbsEncrypt }
onCheckedChange = { ( v ) = > {
const checked = ! ! v
if ( ! checked ) {
setPbsEncryptMode ( "none" )
} else {
// Yes → if the host already has a keyfile, submit
// "existing" (reuse it silently). If not, default
// to "new" (Generate) — the operator can flip to
// "existing" (Import) via the radio below.
setPbsEncryptMode ( pbsRecoveryStatus ? . has_keyfile ? "existing" : "new" )
}
} }
className = "mt-0.5"
/ >
< div className = "flex-1" >
< div className = "inline-flex items-center gap-1.5 text-sm" >
< Lock className = "h-3.5 w-3.5 text-emerald-400" / >
Encrypt backups ( client - side keyfile )
< / div >
< p className = "text-[11px] text-muted-foreground mt-1" >
Adds < code className = "font-mono" > -- keyfile < / code > to < code className = "font-mono" > proxmox - backup - client backup < / code > . Encryption happens before upload so chunks land on PBS already ciphered . The keyfile is installed at < code className = "font-mono" > / usr / local / share / proxmenux / pbs - key . conf < / code > ( chmod 0600 ) and reused by every encrypted PBS job on this host .
2026-07-05 09:30:13 +02:00
< / p >
< / div >
2026-07-05 16:50:06 +02:00
< / label >
{ pbsEncrypt && pbsRecoveryStatus ? . has_keyfile && (
2026-07-06 00:30:47 +02:00
< KeyfileManageInline
reuseText = "An encryption key is already installed on this host — it will be reused for this job. Use the buttons below to change the stored passphrase, replace the key with a new one, or remove it entirely."
onReplaced = { ( ) = > setPbsEncryptMode ( "new" ) }
mutateStatus = { mutatePbsRecovery }
/ >
2026-07-05 09:30:13 +02:00
) }
2026-07-05 16:50:06 +02:00
{ pbsEncrypt && ! pbsRecoveryStatus ? . has_keyfile && (
< div className = "space-y-2 rounded-md border border-emerald-500/30 bg-emerald-500/5 p-3" >
< div className = "text-[11px] font-medium" >
No encryption key is stored on this host . Choose how to set one up :
< / div >
< label className = "flex items-start gap-2 cursor-pointer text-[11px]" >
< input
type = "radio"
name = "pbsEncryptSetupCreate"
checked = { pbsEncryptMode === "new" }
onChange = { ( ) = > setPbsEncryptMode ( "new" ) }
className = "mt-1"
/ >
< div className = "flex-1" >
< div className = "font-medium" > Generate a new keyfile ( per host — safest isolation ) < / div >
< div className = "text-muted-foreground" > Creates a fresh keyfile at < code className = "font-mono" > / usr / local / share / proxmenux / pbs - key . conf < / code > . Backup up the recovery passphrase ( below ) to survive host loss . < / div >
< / div >
< / label >
< label className = "flex items-start gap-2 cursor-pointer text-[11px]" >
< input
type = "radio"
name = "pbsEncryptSetupCreate"
checked = { pbsEncryptMode === "existing" }
onChange = { ( ) = > setPbsEncryptMode ( "existing" ) }
className = "mt-1"
/ >
< div className = "flex-1" >
< div className = "font-medium" > Import an existing keyfile ( shared across hosts ) < / div >
< div className = "text-muted-foreground" > Use the same keyfile another host already has — enables cross - host restore of encrypted backups . < / div >
< / div >
< / label >
{ pbsEncryptMode === "existing" && (
2026-07-05 23:58:58 +02:00
< div className = "space-y-2 pt-1" >
< div className = "space-y-1.5" >
< Label htmlFor = "pbsImportFile" className = "text-[11px] font-medium" >
Keyfile to import
< / Label >
< Input
id = "pbsImportFile"
type = "file"
accept = ".conf,.key,application/json,text/plain"
onChange = { ( e ) = > setPbsImportFile ( e . target . files ? . [ 0 ] ? ? null ) }
disabled = { pbsImportBusy }
className = "h-8 text-[11px]"
/ >
< p className = "text-[10px] text-muted-foreground" >
ProxMenux does not validate the file — any keyfile you accept as valid on your PBS is accepted here . If it later fails at backup time , the tool ' s error tells you why .
< / p >
< / div >
< div className = "space-y-1.5" >
< Label htmlFor = "pbsImportKeyfilePass" className = "text-[11px] font-medium" >
Keyfile passphrase ( optional )
< / Label >
< Input
id = "pbsImportKeyfilePass"
type = "password"
value = { pbsImportKeyfilePass }
onChange = { ( e ) = > setPbsImportKeyfilePass ( e . target . value ) }
disabled = { pbsImportBusy }
placeholder = "Leave blank if the keyfile has no passphrase"
className = "h-8 text-[11px] font-mono"
/ >
< p className = "text-[10px] text-muted-foreground" >
If the keyfile was generated with < code className = "font-mono" > proxmox - backup - client key create -- kdf scrypt < / code > ( the tool ' s default ) it needs the original passphrase to be unlocked at backup time . Leave blank for < code className = "font-mono" > -- kdf none < / code > keyfiles . Stored at < code className = "font-mono" > / usr / local / share / proxmenux / pbs - key . pass < / code > ( chmod 600 ) .
< / p >
< / div >
2026-07-05 16:50:06 +02:00
< / div >
) }
< / div >
) }
2026-06-20 21:59:19 +02:00
{ 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 ? (
/ * E s c r o w a l r e a d y s a v e d — m i r r o r t h e s h e l l : s k i p
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 / l i b / v z / d u m p < / c o d e > .
< / 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
{ / * E n c r y p t i o n + p a s s p h r a s e a r e n ' t a s k e d h e r e — t h e y
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
< / >
) }
{ / * S u m m a r y p r e v i e w — m i r r o r s t h e J o b D e t a i l M o d a l s t y l i n g s o
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 && (
2026-07-05 23:14:52 +02:00
< div className = "text-xs text-red-500 px-3 py-2 rounded-md border border-red-500/30 bg-red-500/10 whitespace-pre-wrap break-words" >
2026-06-19 23:38:57 +02:00
{ error }
< / div >
) }
< / div >
) }
< / div >
{ /* Footer navigation */ }
< div className = "flex items-center justify-between gap-2 pt-3 border-t border-border" >
< Button
variant = "ghost"
onClick = { onClose }
disabled = { submitting }
>
Cancel
< / Button >
< div className = "flex items-center gap-2" >
{ step > 1 && (
< Button
variant = "outline"
onClick = { ( ) = > setStep ( ( s ) = > ( s - 1 ) as 1 | 2 | 3 | 4 | 5 ) }
disabled = { submitting }
>
< ChevronLeft className = "h-4 w-4 mr-1" / >
Back
< / Button >
) }
{ step < 5 ? (
< Button
onClick = { ( ) = > setStep ( ( s ) = > ( s + 1 ) as 1 | 2 | 3 | 4 | 5 ) }
disabled = {
( step === 1 && ! canAdvanceFrom1 ) ||
( step === 2 && ! canAdvanceFrom2 ) ||
( step === 3 && ! canAdvanceFrom3 ) ||
( step === 4 && ! canAdvanceFrom4 )
}
>
Next
< ChevronRight className = "h-4 w-4 ml-1" / >
< / Button >
) : (
< Button
onClick = { handleCreate }
disabled = { ! canSubmit || submitting }
2026-06-22 11:38:06 +02:00
className = "bg-blue-600 hover:bg-blue-700 text-white"
2026-06-19 23:38:57 +02:00
>
{ submitting ? (
< Loader2 className = "h-4 w-4 animate-spin mr-2" / >
) : isEdit ? (
< Save className = "h-4 w-4 mr-2" / >
) : (
< Plus className = "h-4 w-4 mr-2" / >
) }
{ isEdit ? "Save changes" : "Create job" }
< / Button >
) }
< / div >
< / div >
< / DialogContent >
{ / * A d d D e s t i n a t i o n D i a l o g s p a w n e d f r o m i n s i d e t h e w i z a r d . W h e n t h e
operator saves a new PBS or Borg destination we refresh the
dest list and queue the freshly - saved repository so the next
destResp render auto - selects it in the dropdown . * / }
< AddDestinationDialog
type = { addingDestType }
onClose = { ( ) = > setAddingDestType ( null ) }
onSaved = { ( repo ) = > {
if ( repo && ( addingDestType === "pbs" || addingDestType === "borg" ) ) {
setPendingAutoSelectDest ( { kind : addingDestType , repository : repo } )
}
setAddingDestType ( null )
mutateDest ( )
} }
/ >
< / Dialog >
)
}
// ──────────────────────────────────────────────────────────────
// Manual backup dialog (Sprint B).
//
// One-shot version of CreateJobDialog: same backends + profile + paths
// + backend-specific config, but NO schedule, NO retention, NO timer.
// Writes a `manual-<ts>` .env with ENABLED=0 + MANUAL_RUN=1 and fires
// the runner in the background. The resulting job appears in the
// scheduler list with a "manual" badge and can be deleted when the
// operator wants to clean up.
// ──────────────────────────────────────────────────────────────
function ManualBackupDialog ( {
open ,
onClose ,
onLaunched ,
} : {
open : boolean
onClose : ( ) = > void
2026-06-20 21:59:19 +02:00
// The job_id of the just-launched manual run. The caller uses it to
// transition straight into the live-progress modal so the operator
// never has to chase a banner after pressing Run.
onLaunched : ( jobId : string ) = > void
2026-06-19 23:38:57 +02:00
} ) {
const [ step , setStep ] = useState < 1 | 2 > ( 1 )
const [ backend , setBackend ] = useState < "pbs" | "local" | "borg" > ( "local" )
const [ profileMode , setProfileMode ] = useState < "default" | "custom" > ( "default" )
const [ customPaths , setCustomPaths ] = useState < Set < string > > ( new Set ( ) )
const [ pbsRepository , setPbsRepository ] = useState < string > ( "" )
const [ pbsBackupId , setPbsBackupId ] = useState < string > ( "" )
2026-07-05 09:30:13 +02:00
// Encryption mode. See CreateJobDialog above for the full comment
// block — the three modes and the import flow are identical here.
const [ pbsEncryptMode , setPbsEncryptMode ] = useState < "none" | "new" | "existing" > ( "none" )
const [ pbsImportFile , setPbsImportFile ] = useState < File | null > ( null )
2026-07-05 23:58:58 +02:00
const [ pbsImportKeyfilePass , setPbsImportKeyfilePass ] = useState < string > ( "" )
2026-07-05 09:30:13 +02:00
const [ pbsImportBusy , setPbsImportBusy ] = useState < boolean > ( false )
const pbsEncrypt = pbsEncryptMode !== "none"
2026-06-20 21:59:19 +02:00
const [ pbsRecoveryPass , setPbsRecoveryPass ] = useState < string > ( "" )
const [ pbsRecoveryPass2 , setPbsRecoveryPass2 ] = useState < string > ( "" )
2026-06-21 00:35:22 +02:00
// Operator opted to replace an already-configured recovery escrow.
// Default behavior matches the shell: don't prompt for the
// passphrase if one is already saved — only show the inputs when
// there's no escrow yet OR the operator explicitly asks to change.
const [ pbsRecoveryChange , setPbsRecoveryChange ] = useState < boolean > ( false )
2026-06-20 21:59:19 +02:00
// Whether the host already has an escrow blob configured. When
// present, the passphrase input becomes optional ("leave blank to
// keep saved"). Refreshed after a successful setup call.
const { data : pbsRecoveryStatus , mutate : mutatePbsRecovery } = useSWR < {
2026-07-05 23:58:58 +02:00
has_keyfile : boolean ; has_recovery : boolean ; has_keyfile_passphrase? : boolean
2026-06-20 21:59:19 +02:00
} > (
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-07-05 09:30:13 +02:00
setPbsEncryptMode ( "none" )
setPbsImportFile ( null )
2026-07-05 23:58:58 +02:00
setPbsImportKeyfilePass ( "" )
2026-07-05 09:30:13 +02:00
setPbsImportBusy ( false )
2026-06-20 21:59:19 +02:00
setPbsRecoveryPass ( "" )
setPbsRecoveryPass2 ( "" )
2026-06-21 00:35:22 +02:00
setPbsRecoveryChange ( false )
2026-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-07-05 16:50:06 +02:00
// Same three encryption submit paths as CreateJobDialog. Only
// "Import + no keyfile installed yet" actually uploads; the on-disk
// reuse case sends mode=existing without touching the file.
const needsImport =
backend === "pbs" &&
pbsEncryptMode === "existing" &&
! pbsRecoveryStatus ? . has_keyfile
if ( needsImport ) {
2026-07-05 09:30:13 +02:00
if ( ! pbsImportFile ) {
setError ( "Pick a keyfile to import." )
setSubmitting ( false )
return
}
setPbsImportBusy ( true )
try {
const buf = new Uint8Array ( await pbsImportFile . arrayBuffer ( ) )
let bin = ""
for ( let i = 0 ; i < buf . length ; i ++ ) bin += String . fromCharCode ( buf [ i ] )
const content_b64 = btoa ( bin )
await fetchApi ( "/api/host-backups/pbs-encryption/import" , {
method : "POST" ,
headers : { "Content-Type" : "application/json" } ,
2026-07-05 23:58:58 +02:00
body : JSON.stringify ( {
content_b64 ,
keyfile_passphrase : pbsImportKeyfilePass ,
} ) ,
2026-07-05 09:30:13 +02:00
} )
mutatePbsRecovery ( )
} catch ( e ) {
2026-07-05 23:14:52 +02:00
// The import endpoint returns `{error, tool_output, tool_exit_code}`
// on validation failure. Surface the tool output verbatim so the
// operator can tell a real "not a keyfile" from a "keyfile needs
// a passphrase / uses unsupported KDF" and act on it.
const err = e as Error & { body ? : { tool_output? : string ; tool_exit_code? : number } }
const detail = err . body ? . tool_output
? ` ${ err . message } \ n \ nproxmox-backup-client output: \ n ${ err . body . tool_output } `
: err . message
setError ( ` Keyfile import failed: ${ detail || String ( e ) } ` )
2026-07-05 09:30:13 +02:00
setPbsImportBusy ( false )
setSubmitting ( false )
return
}
setPbsImportBusy ( false )
}
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-07-05 09:30:13 +02:00
body . pbs_encrypt_mode = pbsEncryptMode
2026-06-19 23:38:57 +02:00
} else if ( backend === "local" ) {
if ( localDestDir . trim ( ) ) body . local_dest_dir = localDestDir . trim ( )
} else if ( backend === "borg" ) {
2026-06-20 21:59:19 +02:00
// Passphrase + encrypt_mode are inherited from the destination
// (borg-pass-<name>.txt sidecar + 4th field of borg-targets.txt).
2026-06-19 23:38:57 +02:00
body . borg_repo = borgRepoSelected
}
2026-06-20 21:59:19 +02:00
const resp = await fetchApi < { status : string ; job_id : string } > (
"/api/host-backups/manual-run" ,
{
method : "POST" ,
headers : { "Content-Type" : "application/json" } ,
body : JSON.stringify ( body ) ,
} ,
)
onLaunched ( resp . job_id )
2026-06-19 23:38:57 +02:00
} catch ( e ) {
setError ( e instanceof Error ? e.message : String ( e ) )
} finally {
setSubmitting ( false )
}
}
return (
< Dialog open = { open } onOpenChange = { ( v ) = > { if ( ! v ) onClose ( ) } } >
< DialogContent className = "max-w-2xl max-h-[85vh] flex flex-col" >
< DialogHeader >
< DialogTitle className = "flex items-center gap-2" >
< PlayCircle className = "h-5 w-5 text-blue-500" / >
Run a one - shot backup
< / DialogTitle >
< DialogDescription >
Step { step } of 2 · The backup runs once . It appears in the list with a "manual" badge and won ' t fire again .
< / DialogDescription >
< / DialogHeader >
< div className = "flex items-center gap-1" >
{ [ 1 , 2 ] . map ( ( n ) = > (
< div
key = { n }
className = { ` h-1 flex-1 rounded-full ${ n <= step ? "bg-blue-500" : "bg-border" } ` }
/ >
) ) }
< / div >
< div className = "overflow-y-auto pr-2 -mr-2 space-y-4 flex-1" >
{ step === 1 && (
< div className = "space-y-4" >
< div >
< Label > Backend < / Label >
< div className = "grid grid-cols-1 sm:grid-cols-3 gap-2 mt-1" >
{ ( [ "pbs" , "local" , "borg" ] as const ) . map ( ( b ) = > {
const Icon = b === "pbs" ? Server : b === "local" ? HardDrive : Archive
const desc = b === "pbs"
? "Proxmox Backup Server."
: b === "local"
? "tar.zst archive into a local directory."
: "Borg repo (SSH or local/USB disk)."
return (
< button
key = { b }
type = "button"
onClick = { ( ) = > setBackend ( b ) }
className = { ` text-left p-3 rounded-md border ${ backend === b ? "border-blue-500 bg-blue-500/5" : "border-border bg-background/40" } hover:bg-white/5 transition-colors ` }
>
< div className = "flex items-center gap-2 font-medium text-sm" >
< Icon className = "h-4 w-4" / >
{ b . toUpperCase ( ) }
< / div >
< div className = "text-xs text-muted-foreground mt-1" > { desc } < / div >
< / button >
)
} ) }
< / div >
< / div >
< div >
< Label > Backup profile < / Label >
< div className = "grid grid-cols-2 gap-2 mt-1" >
< button
type = "button"
onClick = { ( ) = > setProfileMode ( "default" ) }
className = { ` text-left p-3 rounded-md border ${ profileMode === "default" ? "border-blue-500 bg-blue-500/5" : "border-border bg-background/40" } hover:bg-white/5 transition-colors ` }
>
< div className = "text-sm font-medium" > Default < / div >
< div className = "text-xs text-muted-foreground mt-1" >
Critical config paths ( { defaultPaths . length } entries ) .
< / div >
< / button >
< button
type = "button"
onClick = { ( ) = > setProfileMode ( "custom" ) }
className = { ` text-left p-3 rounded-md border ${ profileMode === "custom" ? "border-blue-500 bg-blue-500/5" : "border-border bg-background/40" } hover:bg-white/5 transition-colors ` }
>
< div className = "text-sm font-medium" > Custom < / div >
< div className = "text-xs text-muted-foreground mt-1" >
Pick which paths to include .
< / div >
< / button >
< / div >
< / div >
{ profileMode === "custom" && (
< div >
< Label > Paths to include ( { customPaths . size } / { defaultPaths . length } ) < / Label >
< ScrollArea className = "h-48 mt-1 rounded-md border border-border p-2" >
{ defaultPaths . map ( ( p ) = > (
< label
key = { p }
className = "flex items-center gap-2 py-1 px-1 hover:bg-white/5 rounded text-xs cursor-pointer"
>
< Checkbox
checked = { customPaths . has ( p ) }
onCheckedChange = { ( ) = > togglePath ( p ) }
/ >
< span className = "font-mono" > { p } < / span >
< / label >
) ) }
< / ScrollArea >
< / div >
) }
< / div >
) }
{ step === 2 && (
< div className = "space-y-4" >
{ backend === "pbs" && (
< >
< div >
< Label > PBS repository < / Label >
{ destResp ? . pbs ? . length ? (
< div className = "mt-1 flex items-center gap-2" >
< Select value = { pbsRepository } onValueChange = { setPbsRepository } >
2026-06-21 00:35:22 +02:00
< SelectTrigger className = "flex-1 min-w-0" >
{ / * M a n u a l r e n d e r : S e l e c t V a l u e ' s d e f a u l t m o d e
duplicates the item children and cuts
them in the middle on mobile . We render
"name — repository" ourselves with a
proper trailing ellipsis . * / }
{ ( ( ) = > {
const sel = destResp . pbs . find ( ( r ) = > r . repository === pbsRepository )
if ( ! sel ) {
return < span className = "truncate text-muted-foreground" > Pick a PBS repository < / span >
}
return (
< span className = "truncate font-mono text-left" >
{ sel . name }
< span className = "text-muted-foreground" > — { sel . repository } < / span >
< / span >
)
} ) ( ) }
2026-06-19 23:38:57 +02:00
< / SelectTrigger >
< SelectContent >
{ destResp . pbs . map ( ( r ) = > (
< SelectItem key = { ` ${ r . name } - ${ r . repository } ` } value = { r . repository } >
< span className = "font-mono" > { r . name } < / span >
< span className = "text-muted-foreground ml-2" > — { r . repository } < / span >
< / SelectItem >
) ) }
< / SelectContent >
< / Select >
< Button
type = "button"
size = "sm"
variant = "outline"
className = "shrink-0 bg-blue-500/10 border-blue-500/40 !text-blue-400 hover:bg-blue-500/20 hover:!text-blue-300"
onClick = { ( ) = > setAddingDestType ( "pbs" ) }
title = "Save another PBS repository"
>
< Plus className = "h-3.5 w-3.5 mr-1" / >
Add another
< / Button >
< / div >
) : (
< div className = "mt-1 p-3 rounded-md border border-amber-500/40 bg-amber-500/5 text-xs space-y-2" >
< div > No PBS repository configured yet . You can add one without leaving this dialog . < / div >
< Button
type = "button"
size = "sm"
variant = "outline"
className = "bg-blue-500/10 border-blue-500/40 !text-blue-400 hover:bg-blue-500/20 hover:!text-blue-300"
onClick = { ( ) = > setAddingDestType ( "pbs" ) }
>
< Plus className = "h-3.5 w-3.5 mr-1" / >
Add PBS repository
< / Button >
< / div >
) }
< / div >
< div >
< Label htmlFor = "manualPbsBackupId" > Group name in PBS < / Label >
< Input
id = "manualPbsBackupId"
value = { pbsBackupId }
onChange = { ( e ) = > setPbsBackupId ( e . target . value ) }
placeholder = "Leave blank for the default"
className = "font-mono mt-1"
/ >
< / div >
2026-06-20 21:59:19 +02:00
< div className = "pt-2 border-t border-border space-y-3" >
2026-07-05 16:50:06 +02:00
< label className = "flex items-start gap-2 cursor-pointer" >
< Checkbox
checked = { pbsEncrypt }
onCheckedChange = { ( v ) = > {
const checked = ! ! v
if ( ! checked ) {
setPbsEncryptMode ( "none" )
} else {
setPbsEncryptMode ( pbsRecoveryStatus ? . has_keyfile ? "existing" : "new" )
}
} }
className = "mt-0.5"
/ >
< div className = "flex-1" >
< div className = "inline-flex items-center gap-1.5 text-sm" >
< Lock className = "h-3.5 w-3.5 text-emerald-400" / >
Encrypt this backup ( client - side keyfile )
< / div >
< p className = "text-[11px] text-muted-foreground mt-1" >
Uses the shared keyfile at < code className = "font-mono" > / usr / local / share / proxmenux / pbs - key . conf < / code > .
2026-07-05 09:30:13 +02:00
< / p >
< / div >
2026-07-05 16:50:06 +02:00
< / label >
{ pbsEncrypt && pbsRecoveryStatus ? . has_keyfile && (
2026-07-06 00:30:47 +02:00
< KeyfileManageInline
reuseText = "An encryption key is already installed on this host — it will be reused for this backup. Use the buttons below to change the stored passphrase, replace the key with a new one, or remove it entirely."
onReplaced = { ( ) = > setPbsEncryptMode ( "new" ) }
mutateStatus = { mutatePbsRecovery }
/ >
2026-07-05 09:30:13 +02:00
) }
2026-07-05 16:50:06 +02:00
{ pbsEncrypt && ! pbsRecoveryStatus ? . has_keyfile && (
< div className = "space-y-2 rounded-md border border-emerald-500/30 bg-emerald-500/5 p-3" >
< div className = "text-[11px] font-medium" >
No encryption key is stored on this host . Choose how to set one up :
< / div >
< label className = "flex items-start gap-2 cursor-pointer text-[11px]" >
< input
type = "radio"
name = "manualPbsEncryptSetup"
checked = { pbsEncryptMode === "new" }
onChange = { ( ) = > setPbsEncryptMode ( "new" ) }
className = "mt-1"
/ >
< div className = "flex-1" >
< div className = "font-medium" > Generate a new keyfile ( per host — safest isolation ) < / div >
< div className = "text-muted-foreground" > Creates a fresh keyfile . Set a recovery passphrase ( below ) to survive host loss . < / div >
< / div >
< / label >
< label className = "flex items-start gap-2 cursor-pointer text-[11px]" >
< input
type = "radio"
name = "manualPbsEncryptSetup"
checked = { pbsEncryptMode === "existing" }
onChange = { ( ) = > setPbsEncryptMode ( "existing" ) }
className = "mt-1"
/ >
< div className = "flex-1" >
< div className = "font-medium" > Import an existing keyfile ( shared across hosts ) < / div >
< div className = "text-muted-foreground" > Reuse the same keyfile another host already has . < / div >
< / div >
< / label >
{ pbsEncryptMode === "existing" && (
2026-07-05 23:58:58 +02:00
< div className = "space-y-2 pt-1" >
< div className = "space-y-1.5" >
< Label htmlFor = "manualPbsImportFile" className = "text-[11px] font-medium" >
Keyfile to import
< / Label >
< Input
id = "manualPbsImportFile"
type = "file"
accept = ".conf,.key,application/json,text/plain"
onChange = { ( e ) = > setPbsImportFile ( e . target . files ? . [ 0 ] ? ? null ) }
disabled = { pbsImportBusy }
className = "h-8 text-[11px]"
/ >
< p className = "text-[10px] text-muted-foreground" >
ProxMenux does not validate the file — any keyfile accepted by your PBS is accepted here .
< / p >
< / div >
< div className = "space-y-1.5" >
< Label htmlFor = "manualPbsImportKeyfilePass" className = "text-[11px] font-medium" >
Keyfile passphrase ( optional )
< / Label >
< Input
id = "manualPbsImportKeyfilePass"
type = "password"
value = { pbsImportKeyfilePass }
onChange = { ( e ) = > setPbsImportKeyfilePass ( e . target . value ) }
disabled = { pbsImportBusy }
placeholder = "Leave blank if the keyfile has no passphrase"
className = "h-8 text-[11px] font-mono"
/ >
< p className = "text-[10px] text-muted-foreground" >
Only needed for keyfiles created with < code className = "font-mono" > -- kdf scrypt < / code > ( the default of < code className = "font-mono" > proxmox - backup - client key create < / code > ) . Stored at < code className = "font-mono" > / usr / local / share / proxmenux / pbs - key . pass < / code > ( chmod 600 ) and reused by every encrypted job on this host .
< / p >
< / div >
2026-07-05 16:50:06 +02:00
< / div >
) }
< / div >
) }
2026-06-20 21:59:19 +02:00
{ 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 && (
2026-07-05 23:14:52 +02:00
< div className = "text-xs text-red-500 px-3 py-2 rounded-md border border-red-500/30 bg-red-500/10 whitespace-pre-wrap break-words" >
2026-06-19 23:38:57 +02:00
{ error }
< / div >
) }
< / div >
) }
< / div >
< div className = "flex items-center justify-between gap-2 pt-3 border-t border-border" >
< Button variant = "ghost" onClick = { onClose } disabled = { submitting } >
Cancel
< / Button >
< div className = "flex items-center gap-2" >
{ step > 1 && (
< Button
variant = "outline"
onClick = { ( ) = > setStep ( ( s ) = > ( s - 1 ) as 1 | 2 ) }
disabled = { submitting }
>
< ChevronLeft className = "h-4 w-4 mr-1" / >
Back
< / Button >
) }
{ step < 2 ? (
< Button
onClick = { ( ) = > setStep ( 2 ) }
disabled = { ! canAdvanceFrom1 }
>
Next
< ChevronRight className = "h-4 w-4 ml-1" / >
< / Button >
) : (
< Button
onClick = { handleRun }
disabled = { ! canSubmit || submitting }
2026-06-20 21:59:19 +02:00
className = "bg-purple-500 hover:bg-purple-600 text-white"
2026-06-19 23:38:57 +02:00
>
{ submitting ? (
< Loader2 className = "h-4 w-4 animate-spin mr-2" / >
) : (
< PlayCircle className = "h-4 w-4 mr-2" / >
) }
Run backup
< / Button >
) }
< / div >
< / div >
< / DialogContent >
{ / * A d d D e s t i n a t i o n D i a l o g s p a w n e d f r o m i n s i d e t h e m a n u a l - b a c k u p
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 < {
2026-07-05 23:58:58 +02:00
has_keyfile : boolean ; has_recovery : boolean ; has_keyfile_passphrase? : boolean
2026-06-20 21:59:19 +02:00
} > ( hasPbs ? "/api/host-backups/pbs-recovery/status" : null , fetcher )
const { data : pbsRecoveryAvailable } = useSWR < {
snapshots : Array < { repo_name : string ; repo_repository : string ; backup_id : string ; source_host : string ; backup_time : number ; snapshot : string } >
errors : Array < { repo_name : string ; error : string } >
} > (
hasPbs && pbsRecoveryStatus && ! pbsRecoveryStatus . has_keyfile
? "/api/host-backups/pbs-recovery/available"
: null ,
fetcher ,
)
const [ recoveringKeyfile , setRecoveringKeyfile ] = useState < boolean > ( false )
const backupsCountFor = ( it : UnifiedDest ) : number = > {
if ( it . kind === "local" ) {
const root = it . path . replace ( /\/+$/ , "" ) + "/"
return ( localArchivesResp ? . archives || [ ] ) . filter (
( a ) = > ( a . path || "" ) . startsWith ( root ) ,
) . length
}
const name = it . kind === "pbs" ? it.name : it.kind === "borg" ? it . name : ""
return ( remoteArchivesResp ? . snapshots || [ ] ) . filter (
( s ) = > s . backend === it . kind && s . repo_name === name ,
) . length
}
2026-06-19 23:38:57 +02:00
// Flatten PBS + Borg + Local into one list. Order: locals first
// (default always on top), then PBS, then Borg — matches the
// mental model of "what's already on this machine" before any
// remote target.
const items : UnifiedDest [ ] = ( ( ) = > {
const out : UnifiedDest [ ] = [ ]
for ( const e of destinations ? . local ? . entries || [ ] ) {
out . push ( {
id : ` local: ${ e . path } ` ,
kind : "local" ,
path : e.path ,
source : e.source as "default" | "custom" ,
removable : e.removable ,
2026-06-20 21:59:19 +02:00
jobsUsing : e.jobs_using || [ ] ,
2026-06-19 23:38:57 +02:00
} )
}
for ( const r of destinations ? . pbs || [ ] ) {
out . push ( {
id : ` pbs: ${ r . name } : ${ r . repository } ` ,
kind : "pbs" ,
name : r.name ,
repository : r.repository ,
source : r.source as "proxmox" | "manual" ,
fingerprint : r.fingerprint || undefined ,
removable : r.source === "manual" ,
2026-06-20 21:59:19 +02:00
jobsUsing : r.jobs_using || [ ] ,
2026-06-19 23:38:57 +02:00
} )
}
for ( const r of destinations ? . borg || [ ] ) {
const ssh = parseBorgSsh ( r . repository )
out . push ( {
id : ` borg: ${ r . name } ` ,
kind : "borg" ,
name : r.name ,
repository : r.repository ,
isSsh : ! ! ssh ,
ssh : ssh ? ? undefined ,
sshKeyPath : ( r as { ssh_key_path? : string } ) . ssh_key_path ,
2026-06-20 21:59:19 +02:00
encryptMode : r.encrypt_mode ,
hasPassphrase : r.has_passphrase ,
2026-06-19 23:38:57 +02:00
removable : true ,
2026-06-20 21:59:19 +02:00
jobsUsing : r.jobs_using || [ ] ,
2026-06-19 23:38:57 +02:00
} )
}
return out
} ) ( )
// Build the capacity-probe payload + key for SWR. The key includes
// every destination's id so SWR re-fetches when the list changes
// (add / remove). 30 s refresh keeps the bars roughly live without
// hammering ssh / pbs every render.
const capacityTargets = items . map ( ( it ) = > {
if ( it . kind === "local" ) return { id : it.id , kind : "local" , path : it.path }
if ( it . kind === "pbs" ) return { id : it.id , kind : "pbs" , name : it.name , repository : it.repository }
if ( it . isSsh && it . ssh ) {
return {
id : it.id , kind : "borg-ssh" ,
host : it.ssh.host , user : it.ssh.user ,
remote_path : it.ssh.remotePath ,
key_path : it.sshKeyPath || "" ,
}
}
return { id : it.id , kind : "borg-local" , path : it.repository }
} )
const capacityKey = items . length
? ` /api/host-backups/destinations/capacity?keys= ${ items . map ( ( i ) = > i . id ) . join ( "," ) } `
: null
const { data : capacityResp } = useSWR < { results : CapacityInfo [ ] } > (
capacityKey ,
( ) = >
fetchApi ( "/api/host-backups/destinations/capacity" , {
method : "POST" ,
headers : { "Content-Type" : "application/json" } ,
body : JSON.stringify ( { targets : capacityTargets } ) ,
} ) ,
{ refreshInterval : 30_000 , revalidateOnFocus : false } ,
)
const capByEdid = new Map < string , CapacityInfo > (
( capacityResp ? . results || [ ] ) . map ( ( r ) = > [ r . id , r ] as const ) ,
)
2026-06-20 21:59:19 +02:00
async function removePbs ( name : string , force = false ) {
2026-06-19 23:38:57 +02:00
setBusyKey ( ` pbs: ${ name } ` )
setError ( null )
try {
2026-06-20 21:59:19 +02:00
await fetchApi ( ` /api/host-backups/destinations/pbs/ ${ encodeURIComponent ( name ) } ${ force ? "?force=true" : "" } ` , { method : "DELETE" } )
2026-06-19 23:38:57 +02:00
onChanged ( )
} catch ( e ) {
setError ( e instanceof Error ? e.message : String ( e ) )
} finally {
setBusyKey ( null )
}
}
2026-06-20 21:59:19 +02:00
async function removeBorg ( name : string , force = false ) {
2026-06-19 23:38:57 +02:00
setBusyKey ( ` borg: ${ name } ` )
setError ( null )
try {
2026-06-20 21:59:19 +02:00
await fetchApi ( ` /api/host-backups/destinations/borg/ ${ encodeURIComponent ( name ) } ${ force ? "?force=true" : "" } ` , { method : "DELETE" } )
2026-06-19 23:38:57 +02:00
onChanged ( )
} catch ( e ) {
setError ( e instanceof Error ? e.message : String ( e ) )
} finally {
setBusyKey ( null )
}
}
2026-06-20 21:59:19 +02:00
async function removeLocal ( path : string , force = false ) {
2026-06-19 23:38:57 +02:00
setBusyKey ( ` local: ${ path } ` )
setError ( null )
try {
2026-06-20 21:59:19 +02:00
const params = new URLSearchParams ( { path } )
if ( force ) params . set ( "force" , "true" )
2026-06-19 23:38:57 +02:00
await fetchApi (
2026-06-20 21:59:19 +02:00
` /api/host-backups/destinations/local? ${ params . toString ( ) } ` ,
2026-06-19 23:38:57 +02:00
{ method : "DELETE" } ,
)
onChanged ( )
} catch ( e ) {
setError ( e instanceof Error ? e.message : String ( e ) )
} finally {
setBusyKey ( null )
}
}
2026-06-20 21:59:19 +02:00
async function confirmDeleteDest() {
const it = confirmingDest
if ( ! it ) return
const force = it . jobsUsing . length > 0
setConfirmingDest ( null )
if ( it . kind === "pbs" ) return removePbs ( it . name , force )
if ( it . kind === "borg" ) return removeBorg ( it . name , force )
if ( it . kind === "local" ) return removeLocal ( it . path , force )
}
2026-06-19 23:38:57 +02:00
// Detach a USB drive — only the filesystem is unmounted; the path
// stays in local-target.conf so re-plugging the same disk picks it
// up again without re-adding the destination.
async function unmountUsb ( mountpoint : string ) {
setBusyKey ( ` umount: ${ mountpoint } ` )
setError ( null )
try {
await fetchApi ( "/api/host-backups/usb-drives/unmount" , {
method : "POST" ,
headers : { "Content-Type" : "application/json" } ,
body : JSON.stringify ( { mountpoint } ) ,
} )
onChanged ( )
} catch ( e ) {
setError ( e instanceof Error ? e.message : String ( e ) )
} finally {
setBusyKey ( null )
}
}
return (
< div className = "space-y-3" >
2026-06-20 21:59:19 +02:00
< div className = "flex justify-end" >
2026-06-19 23:38:57 +02:00
< Button
size = "sm"
2026-06-20 21:59:19 +02:00
className = "h-8 px-3 bg-blue-500 hover:bg-blue-600 text-white"
2026-06-19 23:38:57 +02:00
onClick = { ( ) = > setConfiguring ( true ) }
>
< Plus className = "h-4 w-4 mr-1" / >
Configure destination
< / Button >
< / div >
{ error && (
2026-07-05 23:14:52 +02:00
< div className = "text-xs text-red-500 px-3 py-2 rounded-md border border-red-500/30 bg-red-500/10 whitespace-pre-wrap break-words" >
2026-06-19 23:38:57 +02:00
{ error }
< / div >
) }
2026-06-20 21:59:19 +02:00
{ / * R e c o v e r y b a n n e r : o n l y w h e n t h e r e ' s n o l o c a l k e y f i l e A N D a t
least one escrow blob was found on a configured PBS . Click
opens a passphrase dialog and the keyfile is rebuilt from
the blob . Equivalent to hb_pbs_try_keyfile_recovery in the
shell — what runs after a fresh PVE install when the
operator points the new host at the same PBS . * / }
{ hasPbs && pbsRecoveryStatus && ! pbsRecoveryStatus . has_keyfile && ( pbsRecoveryAvailable ? . snapshots ? . length ? ? 0 ) > 0 && (
< button
type = "button"
onClick = { ( ) = > setRecoveringKeyfile ( true ) }
className = "w-full flex items-start gap-2 px-3 py-2.5 rounded-md border border-emerald-500/40 bg-emerald-500/5 hover:bg-emerald-500/10 transition-colors text-left"
title = "A recovery copy of the PBS keyfile was found on this PBS server"
>
< Lock className = "h-4 w-4 mt-0.5 text-emerald-400 shrink-0" / >
< div className = "min-w-0 flex-1" >
< div className = "text-sm font-medium text-foreground" >
PBS keyfile is missing — recover it from PBS
< / div >
< div className = "text-[11px] text-muted-foreground mt-0.5" >
2026-07-05 16:50:06 +02:00
An encrypted recovery copy of the keyfile is available ( { pbsRecoveryAvailable ! . snapshots . length } backup { pbsRecoveryAvailable ! . snapshots . length === 1 ? "" : "s" } ) . Click to rebuild the keyfile using your recovery passphrase .
2026-06-20 21:59:19 +02:00
< / div >
< / div >
< span className = "text-xs text-emerald-300 shrink-0 self-center" > Recover → < / span >
< / button >
) }
2026-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
{ / * E d i t - a n - e x i s t i n g - d e s t i n a t i o n d i a l o g . U s e s t h e s a m e f o r m a s
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 ( )
} }
/ >
{ / * D e l e t e - d e s t i n a t i o n c o n f i r m . A l w a y s o p e n s ( u n i f o r m U X ) b u t
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
{ / * T o p r o w s p l i t i n t w o : a w r a p p a b l e l e f t s i d e ( i c o n + h e a d l i n e +
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 >
) }
{ / * E n c r y p t i o n i n d i c a t o r — i c o n - o n l y c h i p f o r e n c r y p t e d
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" } ` }
>
{ / * I c o n s i z e d t o r o u g h l y t h e t e x t - [ 1 0 p x ] l i n e - b o x o f
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 >
{ / * A c t i o n c h i p s — a l w a y s p i n n e d t o t h e r i g h t , n e v e r w r a p .
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 >
{ / * C a p a c i t y b a r — m a t c h e s t h e h e i g h t o f t h e < P r o g r e s s > c o m p o n e n t
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 )
}
for ( const r of existingDest ? . borg || [ ] ) {
// Borg local-mode: the `repository` IS the path. SSH repos
// start with `ssh://` and are excluded automatically.
if ( ! r . repository . startsWith ( "ssh://" ) ) out . push ( r . repository )
}
return out
} ) ( )
// Reset every time the dialog OPENS — not just on close. If we're
// opening in edit mode, hydrate the form from the saved destination
// so the operator only has to change what they want. Name stays
// read-only in edit mode (it's the key for sidecars / state files).
2026-06-19 23:38:57 +02:00
useEffect ( ( ) = > {
2026-06-20 21:59:19 +02:00
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 ( "" )
2026-06-19 23:38:57 +02:00
setBorgSshKeyPath ( "/root/.ssh/proxmenux_borg" )
2026-06-20 21:59:19 +02:00
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 ( "" )
2026-06-19 23:38:57 +02:00
setLocalPath ( "" )
2026-06-20 21:59:19 +02:00
return
2026-06-19 23:38:57 +02:00
}
2026-06-20 21:59:19 +02:00
// 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
{ / * E n c r y p t i o n + p a s s p h r a s e l i v e o n t h e d e s t i n a t i o n , n o t o n
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 && (
2026-07-05 23:14:52 +02:00
< div className = "text-xs text-red-500 px-3 py-2 rounded-md border border-red-500/30 bg-red-500/10 whitespace-pre-wrap break-words" >
2026-06-19 23:38:57 +02:00
{ error }
< / div >
) }
< / div >
< div className = "flex justify-end gap-2 pt-3 border-t border-border" >
< Button variant = "ghost" onClick = { onClose } disabled = { submitting } > Cancel < / Button >
2026-06-22 11:38:06 +02:00
< Button
onClick = { handleSave }
disabled = { ! canSubmit || submitting }
className = "bg-blue-600 hover:bg-blue-700 text-white"
>
{ submitting ? < Loader2 className = "h-4 w-4 animate-spin mr-2" / > : < Save className = "h-4 w-4 mr-2" / > }
2026-06-19 23:38:57 +02:00
Save
< / Button >
< / div >
< / DialogContent >
< / Dialog >
)
}
// ──────────────────────────────────────────────────────────────
// UsbPicker — inline picker for mounted / unmounted / empty USB
// drives. Used inside AddDestinationDialog for Local and Borg-local
// so the operator can mount + format USB media without leaving the
// "configure destination" flow.
// ──────────────────────────────────────────────────────────────
2026-06-20 21:59:19 +02:00
function UsbPicker ( {
onPick ,
excludePaths = [ ] ,
} : {
onPick : ( path : string ) = > void
// Hide a USB drive ONLY when an existing destination already targets
// its mountpoint root verbatim. Subdirectory uses don't claim the
// whole disk — both Local and Borg can share the same USB by
// pointing at different subdirs (`/mnt/usbX/borgbackup` for Borg,
// `/mnt/usbX/local-dump` for Local). The previous prefix-based
// filter was too greedy and made it impossible to add a second
// destination type once any one of them landed on the disk.
excludePaths? : string [ ]
} ) {
2026-06-19 23:38:57 +02:00
const { data , mutate , isLoading } = useSWR < { drives : UsbDrive [ ] } > (
"/api/host-backups/usb-drives" ,
fetcher ,
{ refreshInterval : 0 } ,
)
2026-06-20 21:59:19 +02:00
const normalized = new Set ( ( excludePaths || [ ] ) . map ( ( p ) = > p . replace ( /\/+$/ , "" ) ) )
const drives = ( data ? . drives ? ? [ ] ) . filter ( ( d ) = > {
const mp = ( d . path_or_device || "" ) . replace ( /\/+$/ , "" )
return ! mp || ! normalized . has ( mp )
} )
2026-06-19 23:38:57 +02:00
const [ busyKey , setBusyKey ] = useState < string | null > ( null )
const [ error , setError ] = useState < string | null > ( null )
const [ formatTarget , setFormatTarget ] = useState < UsbDrive | null > ( null )
const [ formatTyped , setFormatTyped ] = useState ( "" )
async function mountAndUse ( d : UsbDrive ) {
setBusyKey ( d . path_or_device )
setError ( null )
try {
const resp = await fetchApi < { mountpoint : string } > (
"/api/host-backups/usb-drives/mount" ,
{
method : "POST" ,
headers : { "Content-Type" : "application/json" } ,
body : JSON.stringify ( {
device : d.path_or_device ,
label : d.label ,
uuid : d.uuid ,
} ) ,
} ,
)
mutate ( )
if ( resp ? . mountpoint ) onPick ( resp . mountpoint )
} catch ( e ) {
setError ( e instanceof Error ? e.message : String ( e ) )
} finally {
setBusyKey ( null )
}
}
async function doFormat() {
if ( ! formatTarget ) return
if ( formatTyped !== formatTarget . path_or_device ) return
setBusyKey ( formatTarget . path_or_device )
setError ( null )
try {
const resp = await fetchApi < { mountpoint? : string } > (
"/api/host-backups/usb-drives/format" ,
{
method : "POST" ,
headers : { "Content-Type" : "application/json" } ,
body : JSON.stringify ( {
device : formatTarget.path_or_device ,
confirm_device : formatTyped ,
} ) ,
} ,
)
mutate ( )
setFormatTarget ( null )
setFormatTyped ( "" )
if ( resp ? . mountpoint ) onPick ( resp . mountpoint )
} catch ( e ) {
setError ( e instanceof Error ? e.message : String ( e ) )
} finally {
setBusyKey ( null )
}
}
return (
< >
< div className = "rounded-md border border-border bg-background/40 p-3 space-y-2" >
< div className = "flex items-center justify-between gap-2" >
< div className = "flex items-center gap-2 text-xs font-medium" >
< HardDrive className = "h-3.5 w-3.5 text-orange-400" / >
USB drives detected
{ drives . length > 0 && (
< Badge variant = "outline" className = "text-[10px]" > { drives . length } < / Badge >
) }
< / div >
< Button
type = "button"
size = "sm"
variant = "ghost"
className = "h-7 text-xs"
onClick = { ( ) = > mutate ( ) }
disabled = { isLoading }
>
{ isLoading ? < Loader2 className = "h-3 w-3 animate-spin" / > : < RefreshCw className = "h-3 w-3" / > }
< span className = "ml-1" > Refresh < / span >
< / Button >
< / div >
{ error && (
< div className = "text-xs text-red-500 px-2 py-1 rounded border border-red-500/30 bg-red-500/10" >
{ error }
< / div >
) }
{ isLoading ? (
< div className = "flex items-center gap-2 py-2 text-xs text-muted-foreground" >
< Loader2 className = "h-3 w-3 animate-spin" / > Scanning …
< / div >
) : drives . length === 0 ? (
< p className = "text-[11px] text-muted-foreground italic" >
No USB drives detected . Plug a USB stick and click Refresh .
< / p >
) : (
< div className = "space-y-1.5" >
{ drives . map ( ( d ) = > {
const busy = busyKey === d . path_or_device
const stateBadge =
d . state === "mounted"
? { label : "mounted" , cls : "border-green-500/40 text-green-400 bg-green-500/5" }
: d . state === "unmounted"
? { label : "unmounted" , cls : "border-amber-500/40 text-amber-400 bg-amber-500/5" }
: { label : "unformatted" , cls : "border-red-500/40 text-red-400 bg-red-500/5" }
return (
< div
key = { d . uuid || d . path_or_device }
className = "flex flex-col sm:flex-row sm:items-center justify-between gap-2 p-2 rounded border border-border bg-background"
>
< div className = "min-w-0 flex-1" >
< div className = "flex items-center gap-2 flex-wrap" >
< code className = "font-mono text-xs text-foreground" > { d . path_or_device } < / code >
< Badge variant = "outline" className = { ` text-[10px] uppercase tracking-wide ${ stateBadge . cls } ` } >
{ stateBadge . label }
< / Badge >
{ d . size && (
< span className = "text-[10px] text-muted-foreground" > { d . size } < / span >
) }
{ d . fstype && (
< span className = "text-[10px] text-muted-foreground font-mono" > { d . fstype } < / span >
) }
{ d . label && (
< span className = "text-[10px] text-muted-foreground italic" > "{d.label}" < / span >
) }
< / div >
< / div >
< div className = "shrink-0 flex items-center gap-1" >
{ d . state === "mounted" && (
< Button
type = "button"
size = "sm"
variant = "outline"
className = "h-7 text-xs bg-blue-500/10 border-blue-500/40 !text-blue-400 hover:bg-blue-500/20 hover:!text-blue-300"
onClick = { ( ) = > onPick ( d . path_or_device ) }
>
Use
< / Button >
) }
{ d . state === "unmounted" && (
< Button
type = "button"
size = "sm"
variant = "outline"
className = "h-7 text-xs bg-blue-500/10 border-blue-500/40 !text-blue-400 hover:bg-blue-500/20 hover:!text-blue-300"
disabled = { busy }
onClick = { ( ) = > mountAndUse ( d ) }
>
{ busy ? < Loader2 className = "h-3 w-3 animate-spin mr-1" / > : null }
Mount and use
< / Button >
) }
{ d . state === "empty" && (
< Button
type = "button"
size = "sm"
variant = "outline"
className = "h-7 text-xs bg-red-500/10 border-red-500/40 !text-red-400 hover:bg-red-500/20 hover:!text-red-300"
disabled = { busy }
onClick = { ( ) = > { setFormatTarget ( d ) ; setFormatTyped ( "" ) } }
>
Format & use
< / Button >
) }
< / div >
< / div >
)
} ) }
< / div >
) }
< / div >
{ /* Type-to-confirm format dialog — destructive op. */ }
< Dialog open = { formatTarget !== null } onOpenChange = { ( v ) = > { if ( ! v ) { setFormatTarget ( null ) ; setFormatTyped ( "" ) } } } >
< DialogContent className = "max-w-md bg-card border-border" >
< DialogHeader >
< DialogTitle className = "flex items-center gap-2 text-base" >
< AlertTriangle className = "h-5 w-5 text-red-500" / >
Format USB drive
< / DialogTitle >
< DialogDescription className = "text-xs" >
This will partition the disk ( GPT + a single ext4 ) , then mount it . Everything on this device will be permanently lost .
< / DialogDescription >
< / DialogHeader >
{ formatTarget && (
< div className = "space-y-2" >
< div className = "text-xs font-mono px-3 py-2 rounded-md border border-border bg-background/40 break-all" >
{ formatTarget . path_or_device }
{ formatTarget . size && < span className = "text-muted-foreground" > · { formatTarget . size } < / span > }
< / div >
< Label htmlFor = "formatConfirmInput" className = "text-xs" >
Type the device path exactly to confirm :
< / Label >
< Input
id = "formatConfirmInput"
value = { formatTyped }
onChange = { ( e ) = > setFormatTyped ( e . target . value ) }
placeholder = { formatTarget . path_or_device }
className = "font-mono"
/ >
< / div >
) }
< div className = "flex justify-end gap-2" >
< Button
variant = "ghost"
onClick = { ( ) = > { setFormatTarget ( null ) ; setFormatTyped ( "" ) } }
disabled = { busyKey === formatTarget ? . path_or_device }
>
Cancel
< / Button >
< Button
variant = "destructive"
disabled = { formatTyped !== formatTarget ? . path_or_device || busyKey === formatTarget ? . path_or_device }
onClick = { doFormat }
>
{ busyKey === formatTarget ? . path_or_device ? (
< Loader2 className = "h-4 w-4 mr-2 animate-spin" / >
) : (
< AlertTriangle className = "h-4 w-4 mr-2" / >
) }
Wipe and format
< / Button >
< / div >
< / DialogContent >
< / Dialog >
< / >
)
}
// ──────────────────────────────────────────────────────────────
// Custom backup paths (Sprint C).
//
// Persisted at $HB_STATE_DIR/backup-extra-paths.txt (one path per line).
// The runner merges this list into every backup — manual or scheduled,
// default or custom profile.
// ──────────────────────────────────────────────────────────────
interface ExtraPathEntry {
path : string
exists : boolean
}
function ExtraPathsSection() {
const { data , mutate } = useSWR < { paths : ExtraPathEntry [ ] } > (
"/api/host-backups/extra-paths" ,
fetcher ,
{ refreshInterval : 60000 } ,
)
const [ newPath , setNewPath ] = useState ( "" )
const [ busyPath , setBusyPath ] = useState < string | null > ( null )
const [ error , setError ] = useState < string | null > ( null )
const [ submitting , setSubmitting ] = useState ( false )
const paths = data ? . paths ? ? [ ]
async function addPath() {
if ( ! newPath . trim ( ) ) return
setSubmitting ( true )
setError ( null )
try {
await fetchApi ( "/api/host-backups/extra-paths" , {
method : "POST" ,
headers : { "Content-Type" : "application/json" } ,
body : JSON.stringify ( { path : newPath.trim ( ) } ) ,
} )
setNewPath ( "" )
mutate ( )
} catch ( e ) {
setError ( e instanceof Error ? e.message : String ( e ) )
} finally {
setSubmitting ( false )
}
}
async function removePath ( p : string ) {
setBusyPath ( p )
setError ( null )
try {
await fetchApi ( ` /api/host-backups/extra-paths?path= ${ encodeURIComponent ( p ) } ` , { method : "DELETE" } )
mutate ( )
} catch ( e ) {
setError ( e instanceof Error ? e.message : String ( e ) )
} finally {
setBusyPath ( null )
}
}
return (
< div className = "space-y-3" >
< p className = "text-xs text-muted-foreground" >
Paths added here are included in < span className = "font-medium text-foreground" > every backup < / span > ( manual or scheduled , default or custom profile ) on top of the default profile list . Useful for application data , custom config dirs , etc . ( { paths . length } configured )
< / p >
< div className = "flex flex-col sm:flex-row gap-2" >
< Input
value = { newPath }
onChange = { ( e ) = > setNewPath ( e . target . value ) }
placeholder = "/root/my-folder, /srv/myapp/data, /etc/cron.d ..."
className = "font-mono"
onKeyDown = { ( e ) = > { if ( e . key === "Enter" && newPath . trim ( ) ) addPath ( ) } }
/ >
< Button
onClick = { addPath }
disabled = { ! newPath . trim ( ) || submitting }
className = "shrink-0 bg-blue-500 hover:bg-blue-600 text-white"
>
{ submitting ? < Loader2 className = "h-4 w-4 animate-spin mr-2" / > : < Plus className = "h-4 w-4 mr-2" / > }
Add path
< / Button >
< / div >
< p className = "text-[11px] text-muted-foreground" >
Use the full absolute path . < code className = "font-mono" > / hola < / code > is not the same as < code className = "font-mono" > / root / hola < / code > — the path has to exist on this host exactly as you type it .
< / p >
{ error && (
2026-07-05 23:14:52 +02:00
< div className = "text-xs text-red-500 px-3 py-2 rounded-md border border-red-500/30 bg-red-500/10 whitespace-pre-wrap break-words" >
2026-06-19 23:38:57 +02:00
{ error }
< / div >
) }
{ paths . length === 0 ? (
< div className = "text-xs text-muted-foreground py-3 text-center" >
No custom paths yet — backups use the default profile only .
< / div >
) : (
< div className = "space-y-1" >
{ paths . map ( ( p ) = > (
< div
key = { p . path }
className = "flex items-center justify-between gap-3 p-2 rounded-md border border-border bg-background/40"
>
< div className = "min-w-0 flex-1 flex items-center gap-2" >
< span className = "font-mono text-xs truncate" title = { p . path } > { p . path } < / span >
{ ! p . exists && (
< Badge variant = "outline" className = "text-[10px] text-amber-500 border-amber-500/40" >
missing on disk
< / Badge >
) }
< / div >
2026-06-20 21:59:19 +02:00
< button
type = "button"
2026-06-19 23:38:57 +02:00
disabled = { busyPath === p . path }
onClick = { ( ) = > removePath ( p . path ) }
title = "Remove this custom path"
2026-06-20 21:59:19 +02:00
className = "shrink-0 inline-flex items-center justify-center h-7 w-7 rounded-md border border-red-500/40 bg-red-500/10 text-red-400 hover:bg-red-500/20 hover:text-red-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
2026-06-19 23:38:57 +02:00
>
{ busyPath === p . path ? < Loader2 className = "h-3.5 w-3.5 animate-spin" / > : < Trash2 className = "h-3.5 w-3.5" / > }
2026-06-20 21:59:19 +02:00
< / button >
2026-06-19 23:38:57 +02:00
< / div >
) ) }
< / div >
) }
< / div >
)
}
// ──────────────────────────────────────────────────────────────
// Backup configuration wrapper (Sprint D.2.a).
//
// Single card hosting Destinations + Custom paths via top-level tabs.
// Future tabs (USB drives, ...) plug in here too.
// ──────────────────────────────────────────────────────────────
function BackupConfigurationCard ( {
destinations ,
onDestChanged ,
} : {
destinations? : DestinationsResp
onDestChanged : ( ) = > void
} ) {
return (
< Card className = "bg-card border-border" >
< CardHeader className = "space-y-0 pb-3" >
< div className = "flex items-center gap-2" >
< Server className = "h-5 w-5 text-blue-500" / >
< CardTitle className = "text-base font-semibold" > Backup configuration < / CardTitle >
< / div >
< / CardHeader >
< CardContent className = "space-y-6" >
{ / * D e s t i n a t i o n s — l i s t + a d d / r e m o v e o f P B S / B o r g / L o c a l t a r g e t s .
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 && (
2026-07-05 23:14:52 +02:00
< div className = "text-xs text-red-500 px-3 py-2 rounded-md border border-red-500/30 bg-red-500/10 whitespace-pre-wrap break-words" >
2026-06-19 23:38:57 +02:00
{ error }
< / div >
) }
{ isLoading ? (
< div className = "text-xs text-muted-foreground py-3 text-center" >
< Loader2 className = "h-4 w-4 animate-spin inline mr-2" / >
Scanning USB drives …
< / div >
) : drives . length === 0 ? (
< div className = "text-xs text-muted-foreground py-3 text-center" >
No USB drives detected . Plug one in and refresh — Monitor scans for them automatically every 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 ( ) } } >
{ / * S i z e d d o w n t o 3 x l n o w t h a t t h e l o g t a i l l i v e s i n t h e
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
{ / * E n c r y p t i o n b a d g e — m i r r o r s t h e I n s p e c t M o d a l o n e
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 && (
2026-07-05 23:14:52 +02:00
< div className = "text-xs text-red-500 px-3 py-2 rounded-md border border-red-500/30 bg-red-500/10 whitespace-pre-wrap break-words" >
2026-06-19 23:38:57 +02:00
{ actionError }
< / div >
) }
{ isLoading || ! detail ? (
< div className = "flex items-center gap-2 py-6 text-sm text-muted-foreground" >
< Loader2 className = "h-4 w-4 animate-spin" / >
Loading job …
< / div >
) : (
< ScrollArea className = "max-h-[60vh] pr-2" >
{ ( ( ) = > {
// One accent color per section. The destination color is
// bound to the job's backend (purple/blue/fuchsia) so the
// section feels like a continuation of the method badge in
// the title; the other sections use neutral accent colors
// that match their semantic (time → amber, content → emerald).
const destAccent =
detail . method === "pbs"
? "text-purple-400"
: detail . method === "borg"
? "text-fuchsia-400"
: "text-blue-400"
return (
< div className = "space-y-4 text-sm" >
{ /* ─── Last run ─────────────────────────── */ }
{ / * T h e j o b m o d a l s h o w s o n l y t h e v e r d i c t + t i m e s t a m p .
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 && (
2026-07-05 23:14:52 +02:00
< div className = "text-xs text-red-500 px-3 py-2 rounded-md border border-red-500/30 bg-red-500/10 whitespace-pre-wrap break-words" >
2026-06-20 21:59:19 +02:00
{ actionError }
< / div >
) }
{ isLoading || ! detail ? (
< div className = "flex items-center gap-2 py-6 text-sm text-muted-foreground" >
< Loader2 className = "h-4 w-4 animate-spin" / >
Loading job …
< / div >
) : (
< ScrollArea className = "max-h-[60vh] pr-2" >
< div className = "space-y-4 text-sm" >
2026-06-21 00:35:22 +02:00
{ / * S t a t u s + l i v e l o g — m a n u a l o n e - s h o t h a s n o c o n c e p t
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 >
{ / * D e s t i n a t i o n — o n l y t h e f i e l d s t h a t a p p l y t o t h e
picked backend . No Schedule / Retention / Profile here
because manual jobs don ' t carry those . * / }
< section className = "space-y-1" >
< h4 className = { ` text-xs font-semibold uppercase tracking-wide flex items-center gap-1.5 ${ destAccent } ` } >
< HardDrive className = "h-3.5 w-3.5" / > Destination
< / h4 >
{ detail . method === "pbs" && (
< >
{ detail . pbs_repository && (
< Field icon = { < Server className = { ` h-3 w-3 ${ destAccent } opacity-80 ` } / > } label = "repository" value = { detail . pbs_repository } mono / >
) }
{ detail . pbs_backup_id && (
< Field icon = { < Archive className = { ` h-3 w-3 ${ destAccent } opacity-80 ` } / > } label = "backup-id" value = { detail . pbs_backup_id } mono / >
) }
< / >
) }
{ detail . method === "borg" && (
< >
{ detail . borg_repo && (
< Field icon = { < Server className = { ` h-3 w-3 ${ destAccent } opacity-80 ` } / > } label = "repo" value = { detail . borg_repo } mono / >
) }
< Field icon = { < FileSearch className = { ` h-3 w-3 ${ destAccent } opacity-80 ` } / > } label = "encryption" value = { detail . borg_encrypt_mode } mono / >
< / >
) }
{ detail . method === "local" && detail . local_dest_dir && (
< Field icon = { < HardDrive className = { ` h-3 w-3 ${ destAccent } opacity-80 ` } / > } label = "dir" value = { detail . local_dest_dir } mono / >
) }
< / section >
< / div >
< / ScrollArea >
) }
< / DialogContent >
< / Dialog >
< / >
)
}
// ──────────────────────────────────────────────────────────────
// PbsKeyfileRecoveryDialog
// ──────────────────────────────────────────────────────────────
// Drops the missing PBS keyfile back onto disk by pulling the escrow
2026-07-04 21:37:39 +02:00
// blob from a keyrecovery backup and decrypting it with the operator's
// recovery passphrase. Mirrors the shell's hb_pbs_try_keyfile_recovery
// flow. Triggered from the banner in DestinationsSection when the
// local keyfile is missing but at least one escrow backup is present
// on a configured PBS. The backend endpoint surfaces both the current
// `hostcfg-<host>-keyrecovery` and the legacy
// `proxmenux-keyrecovery-<host>` naming, so pre-1.2.2.2 escrow blobs
// stay recoverable without manual migration.
2026-06-20 21:59:19 +02:00
// ──────────────────────────────────────────────────────────────
function PbsKeyfileRecoveryDialog ( {
open ,
snapshots ,
onClose ,
onRecovered ,
} : {
open : boolean
snapshots : Array < { repo_name : string ; repo_repository : string ; backup_id : string ; source_host : string ; backup_time : number ; snapshot : string } >
onClose : ( ) = > void
onRecovered : ( ) = > void
} ) {
const [ selectedKey , setSelectedKey ] = useState < string > ( "" )
const [ passphrase , setPassphrase ] = useState < string > ( "" )
const [ busy , setBusy ] = useState < boolean > ( false )
const [ error , setError ] = useState < string | null > ( null )
useEffect ( ( ) = > {
if ( ! open ) {
setSelectedKey ( "" )
setPassphrase ( "" )
setBusy ( false )
setError ( null )
return
}
if ( snapshots . length >= 1 && ! selectedKey ) {
setSelectedKey ( ` ${ snapshots [ 0 ] . repo_name } :: ${ snapshots [ 0 ] . snapshot } ` )
}
} , [ open , snapshots ] )
const selected = snapshots . find ( ( s ) = > ` ${ s . repo_name } :: ${ s . snapshot } ` === selectedKey ) || null
async function handleRecover() {
if ( ! selected || ! passphrase ) return
setBusy ( true )
setError ( null )
try {
await fetchApi ( "/api/host-backups/pbs-recovery/restore" , {
method : "POST" ,
headers : { "Content-Type" : "application/json" } ,
body : JSON.stringify ( {
repo_name : selected.repo_name ,
snapshot : selected.snapshot ,
passphrase ,
} ) ,
} )
onRecovered ( )
} catch ( e ) {
setError ( e instanceof Error ? e.message : String ( e ) )
} finally {
setBusy ( false )
}
}
return (
< Dialog open = { open } onOpenChange = { ( v ) = > { if ( ! v && ! busy ) onClose ( ) } } >
< DialogContent className = "max-w-lg bg-card border-border" >
< DialogHeader >
< DialogTitle className = "flex items-center gap-2 text-base" >
< Lock className = "h-5 w-5 text-emerald-400" / >
Recover PBS keyfile
< / DialogTitle >
< DialogDescription className = "text-xs" >
The encrypted keyfile blob is downloaded from PBS and decrypted with your recovery passphrase . The resulting key is written to < code className = "font-mono" > / usr / local / share / proxmenux / pbs - key . conf < / code > .
< / DialogDescription >
< / DialogHeader >
{ snapshots . length > 1 && (
< div className = "space-y-1" >
< Label className = "text-xs" > Recovery snapshot < / Label >
< Select value = { selectedKey } onValueChange = { setSelectedKey } >
< SelectTrigger >
< SelectValue / >
< / SelectTrigger >
< SelectContent >
{ snapshots . map ( ( s ) = > {
let when : string
try { when = new Date ( s . backup_time * 1000 ) . toLocaleString ( ) }
catch { when = String ( s . backup_time ) }
return (
< SelectItem key = { ` ${ s . repo_name } :: ${ s . snapshot } ` } value = { ` ${ s . repo_name } :: ${ s . snapshot } ` } >
< span className = "font-mono" > { s . source_host } < / span >
< span className = "text-muted-foreground ml-2" > — { s . repo_name } · { when } < / span >
< / SelectItem >
)
} ) }
< / SelectContent >
< / Select >
< p className = "text-[11px] text-muted-foreground" >
Pick the source host whose passphrase you remember — each install uploads its own escrow .
< / p >
< / div >
) }
{ selected && (
< div className = "text-xs space-y-1 px-3 py-2 rounded-md border border-border bg-background/40" >
< div className = "flex items-center gap-2" >
< span className = "text-muted-foreground" > Source host : < / span >
< span className = "font-mono" > { selected . source_host } < / span >
< / div >
< div className = "flex items-center gap-2" >
< span className = "text-muted-foreground" > PBS : < / span >
< span className = "font-mono break-all" > { selected . repo_repository } < / span >
< / div >
< / div >
) }
< div >
< Label htmlFor = "recoveryPass" className = "text-xs" > Recovery passphrase < / Label >
< Input
id = "recoveryPass"
type = "password"
value = { passphrase }
onChange = { ( e ) = > setPassphrase ( e . target . value ) }
placeholder = "The passphrase set when the keyfile was created"
className = "font-mono mt-1"
autoFocus
onKeyDown = { ( e ) = > {
if ( e . key === "Enter" && passphrase && ! busy ) handleRecover ( )
} }
/ >
< / div >
{ error && (
2026-07-05 23:14:52 +02:00
< div className = "text-xs text-red-500 px-3 py-2 rounded-md border border-red-500/30 bg-red-500/10 whitespace-pre-wrap break-words" >
2026-06-20 21:59:19 +02:00
{ error }
< / div >
) }
< div className = "flex justify-end gap-2" >
< Button variant = "ghost" onClick = { onClose } disabled = { busy } >
Cancel
< / Button >
< Button
2026-06-21 22:41:54 +02:00
className = "bg-green-600 hover:bg-green-700 text-white"
2026-06-20 21:59:19 +02:00
disabled = { ! selected || ! passphrase || busy }
onClick = { handleRecover }
>
{ busy ? (
< Loader2 className = "h-4 w-4 mr-2 animate-spin" / >
) : (
< Lock className = "h-4 w-4 mr-2" / >
) }
Recover keyfile
< / Button >
< / div >
< / DialogContent >
< / Dialog >
)
}
2026-06-21 00:35:22 +02:00
// ──────────────────────────────────────────────────────────────
// ArchiveContentsModal
// ──────────────────────────────────────────────────────────────
// "View contents" of any backup snapshot. Calls /inspect-archive
// (extract to staging + parse_manifest + run_restore dry-run +
// walk rootfs + cleanup) and renders the JSON as a HTML report.
// Same shape regardless of backend so the operator gets a
// consistent view across PBS, Borg and local.
// ──────────────────────────────────────────────────────────────
interface InspectResponse {
manifest? : any
manifest_error? : string
manifest_missing? : boolean
plan? : any
plan_error? : string
plan_raw_stderr? : string
files? : Array < { path : string ; size : number } >
files_truncated? : boolean
files_total_count? : number
metadata_files? : Record < string , string >
2026-06-21 22:41:54 +02:00
rollback_plan? : RollbackPlan
}
interface RollbackPlan {
backup_time? : string
vms_in_backup : number [ ]
vms_in_host : number [ ]
vms_to_remove : number [ ]
vms_to_restore : number [ ]
lxcs_in_backup : number [ ]
lxcs_in_host : number [ ]
lxcs_to_remove : number [ ]
lxcs_to_restore : number [ ]
components_to_uninstall : string [ ]
components_to_reinstall : string [ ]
2026-06-21 00:35:22 +02:00
}
function ArchiveContentsModal ( {
open ,
onClose ,
source ,
repo_name ,
snapshot ,
path ,
display_id ,
} : {
open : boolean
onClose : ( ) = > void
source : "pbs" | "borg" | "local" | null
repo_name? : string
snapshot? : string
path? : string
display_id? : string
} ) {
const [ data , setData ] = useState < InspectResponse | null > ( null )
const [ loading , setLoading ] = useState ( false )
const [ error , setError ] = useState < string | null > ( null )
useEffect ( ( ) = > {
if ( ! open || ! source ) {
setData ( null )
setError ( null )
setLoading ( false )
return
}
const body : Record < string , string > = { source }
if ( source === "local" ) {
if ( ! path ) { setError ( "Local archive path missing" ) ; return }
body . path = path
} else {
if ( ! repo_name || ! snapshot ) { setError ( "repo_name and snapshot required" ) ; return }
body . repo_name = repo_name
body . snapshot = snapshot
}
setLoading ( true )
setError ( null )
fetchApi ( "/api/host-backups/inspect-archive" , {
method : "POST" ,
headers : { "Content-Type" : "application/json" } ,
body : JSON.stringify ( body ) ,
} )
. then ( ( r : any ) = > setData ( r ) )
. catch ( ( e ) = > setError ( e instanceof Error ? e.message : String ( e ) ) )
. finally ( ( ) = > setLoading ( false ) )
} , [ open , source , repo_name , snapshot , path ] )
const manifest = data ? . manifest
const plan = data ? . plan
const files = data ? . files
return (
< Dialog open = { open } onOpenChange = { ( v ) = > { if ( ! v ) onClose ( ) } } >
< DialogContent className = "max-w-4xl bg-card border-border h-[85vh] flex flex-col p-0" >
< DialogHeader className = "px-6 pt-6 pb-3 shrink-0" >
< DialogTitle className = "flex items-center gap-2 text-base" >
< Eye className = "h-5 w-5 text-blue-400" / >
Backup contents
< / DialogTitle >
< DialogDescription className = "text-xs font-mono break-all" >
{ display_id }
< / DialogDescription >
< / DialogHeader >
{ loading && (
< div className = "flex items-center gap-3 py-12 justify-center text-sm text-muted-foreground px-6" >
< Loader2 className = "h-5 w-5 animate-spin" / >
Extracting snapshot and analyzing — this may take a minute on large backups …
< / div >
) }
{ 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 } / >
) }
{ plan . components_to_reinstall . length > 0 && (
< Row label = "Components:" items = { plan . components_to_reinstall } / >
2026-06-21 22:41:54 +02:00
) }
2026-06-24 21:58:21 +02:00
< div className = "text-[10px] text-muted-foreground pt-1" >
Only the / etc / pve config is restored — disk images stay where they live .
< / div >
2026-06-21 22:41:54 +02:00
< / div >
)
}
// ──────────────────────────────────────────────────────────────
// RestoreOptionsModal
// ──────────────────────────────────────────────────────────────
// Two-step UI for restoring a snapshot:
// Step 1 — pick Complete restore vs Custom by paths.
// Step 2 — if Custom, tick the paths to restore.
// `pathsAvailable` is what the backend's list_paths.sh detected
// in this specific backup (default profile + operator extras).
// Perfect parity with the TUI's _rs_select_component_paths after
// the paths-not-components refactor.
// ──────────────────────────────────────────────────────────────
function RestoreOptionsModal ( {
open ,
onClose ,
stagingPath ,
pathsAvailable ,
rollbackPlan ,
2026-07-03 18:59:45 +02:00
crossKernel ,
hydration ,
2026-06-21 22:41:54 +02:00
display_id ,
onLaunch ,
} : {
open : boolean
onClose : ( ) = > void
stagingPath : string
pathsAvailable : string [ ]
rollbackPlan? : RollbackPlan
2026-07-03 18:59:45 +02:00
crossKernel ? : {
direction : "same" | "bk_older" | "bk_newer"
backupKernel : string
targetKernel : string
blockedPaths : string [ ]
}
hydration ? : {
applies : boolean
actions : string [ ]
}
2026-06-21 22:41:54 +02:00
display_id? : string
2026-06-21 23:49:41 +02:00
onLaunch : ( mode : "full" | "custom" , paths : string [ ] , rollbackExecute? : boolean ) = > void
2026-06-21 22:41:54 +02:00
} ) {
2026-07-03 18:59:45 +02:00
// When direction === "bk_older" the CLI-side filter (RS_SKIP_PATHS)
// will drop these prefixes from any restore. We mirror that
// behavior in the picker so the operator can see what will be
// skipped rather than being surprised at run time.
const isBkOlder = crossKernel ? . direction === "bk_older"
const blockedPrefixes = isBkOlder ? ( crossKernel ? . blockedPaths ? ? [ ] ) : [ ]
const isPathBlocked = ( p : string ) = >
blockedPrefixes . some ( ( b ) = > p === b || p . startsWith ( b . endsWith ( "/" ) ? b : ` ${ b } / ` ) )
2026-06-21 22:41:54 +02:00
const [ step , setStep ] = useState < "choose" | "custom" > ( "choose" )
const [ selected , setSelected ] = useState < Set < string > > ( new Set ( ) )
const [ error , setError ] = useState < string | null > ( null )
const [ filter , setFilter ] = useState < string > ( "" )
const [ destructiveAck , setDestructiveAck ] = useState < boolean > ( false )
// A Complete restore on a host that has VMs/LXCs/components NOT
// present in the backup is destructive (those entries get removed
// at next boot by apply_cluster_postboot.sh's rollback step). We
// force the operator to tick an explicit "I understand" checkbox
// before enabling the launch button.
const hasDestructive = ! ! rollbackPlan && (
( rollbackPlan . vms_to_remove ? . length || 0 ) > 0
|| ( rollbackPlan . lxcs_to_remove ? . length || 0 ) > 0
|| ( rollbackPlan . components_to_uninstall ? . length || 0 ) > 0
)
useEffect ( ( ) = > {
if ( ! open ) {
setStep ( "choose" )
setSelected ( new Set ( ) )
setError ( null )
setFilter ( "" )
setDestructiveAck ( false )
}
} , [ open ] )
const togglePath = ( p : string ) = > {
2026-07-03 18:59:45 +02:00
if ( isPathBlocked ( p ) ) return
2026-06-21 22:41:54 +02:00
setSelected ( ( prev ) = > {
const next = new Set ( prev )
if ( next . has ( p ) ) next . delete ( p )
else next . add ( p )
return next
} )
}
const toggleAll = ( ) = > {
2026-07-03 18:59:45 +02:00
const selectable = filteredPaths . filter ( ( p ) = > ! isPathBlocked ( p ) )
if ( selected . size === selectable . length ) {
2026-06-21 22:41:54 +02:00
setSelected ( new Set ( ) )
} else {
2026-07-03 18:59:45 +02:00
setSelected ( new Set ( selectable ) )
2026-06-21 22:41:54 +02:00
}
}
const filteredPaths = filter
? pathsAvailable . filter ( ( p ) = > p . toLowerCase ( ) . includes ( filter . toLowerCase ( ) ) )
: pathsAvailable
2026-07-03 18:59:45 +02:00
const selectableCount = filteredPaths . filter ( ( p ) = > ! isPathBlocked ( p ) ) . length
2026-06-21 22:41:54 +02:00
const launch = ( mode : "full" | "custom" ) = > {
if ( mode === "custom" && selected . size === 0 ) {
setError ( "Pick at least one path to continue." )
return
}
setError ( null )
2026-06-21 23:49:41 +02:00
onLaunch ( mode , Array . from ( selected ) , mode === "full" && hasDestructive && destructiveAck )
2026-06-21 22:41:54 +02:00
}
return (
< Dialog open = { open } onOpenChange = { ( v ) = > { if ( ! v && step !== "preparing" ) onClose ( ) } } >
< DialogContent className = "max-w-2xl bg-card border-border" >
< DialogHeader >
< DialogTitle className = "flex items-center gap-2 text-base" >
< DatabaseBackup className = "h-5 w-5 text-emerald-400" / >
Restore — choose mode
< / DialogTitle >
< DialogDescription className = "text-xs font-mono break-all" >
{ display_id }
< / DialogDescription >
< / DialogHeader >
{ step === "choose" && (
< div className = "space-y-3" >
2026-07-05 16:50:06 +02:00
{ / * P o s t - r e s t o r e M o n i t o r h i n t — t h e B a c k u p s t a b r e n d e r s a l i v e
progress card driven by / var / l i b / p r o x m e n u x / r e s t o r e - s t a t e . j s o n ,
so the operator has a place to watch component reinstalls ,
sanity warnings , and the rollback delta after the reboot . * / }
< div className = "rounded-md border border-blue-500/40 bg-blue-500/5 p-3 text-[11px] text-muted-foreground flex items-start gap-2" >
< History className = "h-3.5 w-3.5 text-blue-400 shrink-0 mt-0.5" / >
< div >
2026-07-05 16:58:27 +02:00
After the reboot , this Backups tab will show a live post - restore progress card with estimated time , per - component status , log tail and rollback delta . If Telegram / Discord / ntfy notifications are configured , you ' ll also receive the "Host restore finished" event .
2026-07-05 16:50:06 +02:00
< / div >
< / div >
2026-07-03 18:59:45 +02:00
{ isBkOlder && (
< div className = "rounded-md border border-amber-500/50 bg-amber-500/5 p-3" >
< div className = "text-[11px] font-semibold uppercase tracking-wider text-amber-400 flex items-center gap-1.5" >
< AlertTriangle className = "h-3.5 w-3.5" / >
Cross - kernel restore — safe subset only
< / div >
< div className = "text-[11px] text-muted-foreground mt-2 space-y-1.5" >
< div >
Backup kernel < code className = "font-mono" > { crossKernel ? . backupKernel } < / code > is older than target kernel < code className = "font-mono" > { crossKernel ? . targetKernel } < / code > .
< / div >
< div >
Both Complete and Custom restore will automatically skip kernel / boot - tied paths to avoid a kernel panic on next boot . Everything else ( VMs , LXCs , network , components , custom paths ) restores normally .
< / div >
{ blockedPrefixes . length > 0 && (
< details className = "mt-1.5" >
< summary className = "cursor-pointer text-amber-400/90 hover:text-amber-300" >
Paths that will be skipped ( { blockedPrefixes . length } )
< / summary >
< ul className = "mt-1.5 pl-3 space-y-0.5 max-h-40 overflow-auto" >
{ blockedPrefixes . map ( ( p ) = > (
< li key = { p } className = "font-mono text-[10.5px] text-muted-foreground/80 break-all" > { p } < / li >
) ) }
< / ul >
< / details >
) }
< / div >
< / div >
) }
{ isBkOlder && hydration ? . applies && hydration . actions . length > 0 && (
< div className = "rounded-md border border-emerald-500/50 bg-emerald-500/5 p-3" >
< div className = "text-[11px] font-semibold uppercase tracking-wider text-emerald-400 flex items-center gap-1.5" >
< CheckCircle2 className = "h-3.5 w-3.5" / >
Operator config re - applied via kernel - agnostic merge
< / div >
< div className = "text-[11px] text-muted-foreground mt-2 space-y-1.5" >
< div >
The following operator - authored settings will be merged into the target automatically , without touching kernel - tied defaults . Runs on both Complete and Custom restore .
< / div >
< ul className = "mt-1.5 pl-3 space-y-0.5 max-h-40 overflow-auto" >
{ hydration . actions . map ( ( a , i ) = > (
< li key = { ` ${ a } - ${ i } ` } className = "text-[10.5px] text-emerald-300/90 break-all" >
< span className = "text-emerald-500 mr-1" > • < / span > { a }
< / li >
) ) }
< / ul >
< / div >
< / div >
) }
2026-06-21 22:41:54 +02:00
{ / * S u r f a c e d e s t r u c t i v e d e l t a s B E F O R E t h e o p e r a t o r p i c k s
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 >
)
}