mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-14 18:56:52 +00:00
feat(host-backup): custom SSH port for Borg remote targets Add Borg destinations no longer assume port 22. The Monitor form and the shell TUI both take an optional port (default 22, range 1-65535) and embed it in the persisted ssh://user@host:port/path URL. BORG_RSH, the sshpass key installer and the capacity probe all honour it. Reported by @songochain in #236 — NAS-style Borg hosts on non-standard SSH ports are now first-class targets. Existing borg-targets.txt entries without a port keep working; the port is only serialised into the URL when it differs from 22.
9042 lines
416 KiB
TypeScript
9042 lines
416 KiB
TypeScript
"use client"
|
|
|
|
import { useState, useEffect, useRef } from "react"
|
|
import useSWR from "swr"
|
|
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
|
|
import { Button } from "./ui/button"
|
|
import { Badge } from "./ui/badge"
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "./ui/dialog"
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
|
|
import { Input } from "./ui/input"
|
|
import { Label } from "./ui/label"
|
|
import { Checkbox } from "./ui/checkbox"
|
|
import { ScrollArea } from "./ui/scroll-area"
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs"
|
|
import {
|
|
DatabaseBackup,
|
|
Clock,
|
|
HardDrive,
|
|
Server,
|
|
CheckCircle2,
|
|
AlertTriangle,
|
|
XCircle,
|
|
Loader2,
|
|
PlayCircle,
|
|
Archive,
|
|
FileSearch,
|
|
Calendar,
|
|
Trash2,
|
|
Plus,
|
|
ChevronRight,
|
|
ChevronLeft,
|
|
Pencil,
|
|
Save,
|
|
Download,
|
|
FileText,
|
|
Power,
|
|
RefreshCw,
|
|
Lock,
|
|
Eye,
|
|
Network,
|
|
Cpu,
|
|
Package,
|
|
ShieldCheck,
|
|
Info,
|
|
ListTree,
|
|
History,
|
|
} from "lucide-react"
|
|
import { ScriptTerminalModal } from "./script-terminal-modal"
|
|
import { RestoreProgressCard } from "./restore-progress-card"
|
|
import { fetchApi, getApiUrl } from "../lib/api-config"
|
|
import { fetchTerminalTicket } from "../lib/terminal-ws"
|
|
import { formatStorage, formatBytes } from "../lib/utils"
|
|
import { getStorageUsageColor } from "../lib/storage-usage-color"
|
|
import { useI18n, useT } from "../lib/i18n/provider"
|
|
|
|
type TFunction = (key: string, params?: Record<string, string | number>) => string
|
|
|
|
// ── Shape contracts with the backend (flask_server.py: api_host_backups_*) ──
|
|
|
|
interface BackupJob {
|
|
id: string
|
|
destination: string
|
|
method: string // "pbs" | "borg" | "local" | "unknown"
|
|
on_calendar: string // "OnCalendar=..." for timer jobs, "attached → storage:X" for attached
|
|
retention: string // "last=5, daily=7, ..."
|
|
timer_enabled: boolean // legacy — only meaningful for non-attached jobs
|
|
enabled: boolean // unified state (timer for non-attached, ENABLED= for attached)
|
|
attached: boolean // PVE vzdump-attached: no own timer, trigger from hook
|
|
pve_storage: string | null // storage id the attached job listens for
|
|
profile_mode: string // "default" | "custom"
|
|
manual: boolean // MANUAL_RUN=1 — one-shot, no schedule, won't re-fire
|
|
last_status: string | null
|
|
next_run: string | null
|
|
// True when this job ships encrypted backups — PBS_KEYFILE set for
|
|
// PBS, or BORG_ENCRYPT_MODE != "none" for Borg. Drives the lock
|
|
// badge in the row + the toggle's initial state in Edit.
|
|
encrypted?: boolean
|
|
}
|
|
|
|
// Sprint H — remote backups (PBS + Borg). Listing is cheap (metadata
|
|
// only); the heavy lifting happens on demand when the operator clicks
|
|
// Download. Same shape for both backends so the UI renders them
|
|
// uniformly — `backend` is the only switch.
|
|
interface RemoteSnapshot {
|
|
backend: "pbs" | "borg"
|
|
repo_name: string
|
|
repo_repository: string
|
|
snapshot: string // canonical id used to extract / restore
|
|
backup_type: string
|
|
backup_id: string
|
|
backup_time: number // unix seconds
|
|
size_bytes: number // 0 for Borg (omitted by `borg list`)
|
|
owner: string | null
|
|
protected: boolean
|
|
files: Array<{ filename: string; size: number }>
|
|
fingerprint: string | null
|
|
// Whether THIS snapshot is encrypted. For PBS it's derived from
|
|
// any file having crypt-mode != "none"; for Borg it follows the
|
|
// target's encrypt_mode.
|
|
encrypted?: boolean
|
|
borg_id?: string // Borg-only fields when available
|
|
borg_start_iso?: string
|
|
}
|
|
|
|
interface RemoteArchivesResp {
|
|
snapshots: RemoteSnapshot[]
|
|
errors: Array<{ backend: string; repo_name: string; error: string }>
|
|
}
|
|
|
|
interface ExportTask {
|
|
task_id: string
|
|
backend: string
|
|
repo_name: string
|
|
snapshot: string
|
|
state: "queued" | "restoring" | "packing" | "completed" | "failed"
|
|
message: string
|
|
size_bytes: number
|
|
output_path: string | null
|
|
error: string | null
|
|
}
|
|
|
|
// Unified archive descriptor — local .tar.zst files and remote PBS /
|
|
// Borg snapshots share the same row layout; `source` decides which
|
|
// code paths apply (inline download vs export-then-download, manifest
|
|
// available vs not, etc.).
|
|
interface UnifiedArchive {
|
|
source: "local" | "pbs" | "borg"
|
|
display_id: string // what shows in mono in the row
|
|
size_bytes: number
|
|
created_at: number // unix seconds
|
|
source_label: string // "/var/lib/vz/dump" or "PBS my-pbs" or "Borg my-borg"
|
|
// One of the two is populated depending on `source`.
|
|
local?: BackupArchive
|
|
remote?: RemoteSnapshot // for both pbs and borg
|
|
}
|
|
|
|
interface BackupArchive {
|
|
id: string // basename of the .tar file (also the URL slug)
|
|
path: string // absolute path on host
|
|
size_bytes: number
|
|
mtime: number // unix seconds
|
|
// From the backend identifier — see _identify_host_backup() in flask_server.py.
|
|
// kind: "manual" / "scheduled" when we know; "legacy" when only the in-tar
|
|
// marker confirmed it's a ProxMenux backup (no sidecar, no name match).
|
|
job_id: string | null
|
|
kind: "manual" | "scheduled" | "legacy"
|
|
profile: string | null
|
|
source_hostname: string | null
|
|
// Which detection path identified this archive. Surfaced as a small tooltip
|
|
// hint so the operator knows whether the metadata is authoritative
|
|
// (sidecar) or inferred (filename / tar-peek).
|
|
detected_via: "sidecar" | "job_id_match" | "hostcfg_prefix" | "tar_peek"
|
|
}
|
|
|
|
interface ManifestSourceHost {
|
|
hostname: string
|
|
pve_version: string | null
|
|
roles: string[]
|
|
kernel: string
|
|
boot_mode: string
|
|
cpu_model: string
|
|
memory_kb: number
|
|
}
|
|
|
|
interface PreflightCheck {
|
|
id: string
|
|
severity: "pass" | "warn" | "fail"
|
|
message: string
|
|
details: Record<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)
|
|
|
|
// Detect encrypted-backup key errors coming from proxmox-backup-client
|
|
// and pull the manifest + provided fingerprints out of the message so
|
|
// the UI can render them prominently instead of burying the info
|
|
// inside a raw stack-trace-shaped string. Returns null when the error
|
|
// is not encryption-related — the caller should fall back to plain
|
|
// text rendering in that case.
|
|
type KeyfileError = {
|
|
manifestFp: string | null
|
|
providedFp: string | null
|
|
raw: string
|
|
}
|
|
const parseKeyfileError = (raw: string): KeyfileError | null => {
|
|
if (!raw) return null
|
|
const lower = raw.toLowerCase()
|
|
if (!/missing key|wrong key|was created with key|manifest.?s key|does not match|no key found|unable to (load|read) key|failed to decrypt/.test(lower)) {
|
|
return null
|
|
}
|
|
const manifest = raw.match(/(?:was created with key|manifest'?s key)\s+([0-9a-f:]+)/i)
|
|
const provided = raw.match(/provided key\s+([0-9a-f:]+)/i)
|
|
return {
|
|
manifestFp: manifest?.[1] ?? null,
|
|
providedFp: provided?.[1] ?? null,
|
|
raw,
|
|
}
|
|
}
|
|
|
|
const KeyfileErrorBlock: React.FC<{ err: KeyfileError; className?: string }> = ({ err, className }) => {
|
|
const t = useT()
|
|
|
|
return (
|
|
<div className={`text-xs rounded-md border border-amber-500/40 bg-amber-500/10 text-amber-100 px-3 py-3 space-y-2 ${className ?? ""}`}>
|
|
<div className="flex items-start gap-2">
|
|
<Lock className="h-4 w-4 mt-0.5 shrink-0 text-amber-400" />
|
|
<div className="flex-1 min-w-0 space-y-1">
|
|
<div className="font-semibold text-amber-300">{t("backup.keyfileError.title")}</div>
|
|
<div className="text-amber-100/90">{t("backup.keyfileError.description")}</div>
|
|
{err.manifestFp && (
|
|
<div className="pt-1">
|
|
<div className="text-[10.5px] uppercase tracking-wider text-amber-300/80">{t("backup.keyfileError.requiredManifest")}</div>
|
|
<div className="font-mono text-[11px] break-all">{err.manifestFp}</div>
|
|
</div>
|
|
)}
|
|
{err.providedFp && (
|
|
<div>
|
|
<div className="text-[10.5px] uppercase tracking-wider text-amber-300/80">{t("backup.keyfileError.currentlyInstalled")}</div>
|
|
<div className="font-mono text-[11px] break-all">{err.providedFp}</div>
|
|
</div>
|
|
)}
|
|
<div className="pt-1 text-amber-100/90">
|
|
{t("backup.keyfileError.importFrom")}{" "}
|
|
<span className="font-medium">{t("backup.keyfileError.importPath")}</span>
|
|
{" "}{t("backup.keyfileError.retryHint")}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const formatMtime = (mtime: number) =>
|
|
new Date(mtime * 1000).toLocaleString(undefined, {
|
|
year: "numeric",
|
|
month: "short",
|
|
day: "numeric",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
})
|
|
|
|
const formatNext = (iso: string | null) => {
|
|
if (!iso) return "—"
|
|
try {
|
|
return new Date(iso).toLocaleString()
|
|
} catch {
|
|
return iso
|
|
}
|
|
}
|
|
|
|
// Best-effort human form of systemd OnCalendar expressions. Whatever
|
|
// we don't recognise falls back to "<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, t: TFunction): string => {
|
|
if (!raw) return "—"
|
|
const s = raw.trim()
|
|
if (!s) return "—"
|
|
const lower = s.toLowerCase()
|
|
if (lower === "hourly") return t("backup.schedule.everyHourAtMinuteZero")
|
|
if (lower === "daily") return t("backup.schedule.everyDayAtMidnight")
|
|
if (lower === "weekly") return t("backup.schedule.everyMondayAtMidnight")
|
|
if (lower === "monthly") return t("backup.schedule.firstDayMonthly")
|
|
if (lower === "yearly" || lower === "annually") return t("backup.schedule.janFirst")
|
|
if (lower === "minutely") return t("backup.schedule.everyMinute")
|
|
// *-*-* HH:MM[:SS] → "Every day at HH:MM"
|
|
let m = s.match(/^\*-\*-\*\s+(\d{1,2}):(\d{2})(?::(\d{2}))?$/)
|
|
if (m) return t("backup.schedule.everyDayAt", { time: `${m[1].padStart(2, "0")}:${m[2]}` })
|
|
// *-*-* *:MM:SS → "Every hour at minute MM"
|
|
m = s.match(/^\*-\*-\*\s+\*:(\d{2})(?::(\d{2}))?$/)
|
|
if (m) return t("backup.schedule.everyHourAtMinute", { minute: m[1] })
|
|
// Mon,Tue *-*-* HH:MM:SS → "<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: t("backup.weekdays.monday"), tue: t("backup.weekdays.tuesday"), wed: t("backup.weekdays.wednesday"),
|
|
thu: t("backup.weekdays.thursday"), fri: t("backup.weekdays.friday"), sat: t("backup.weekdays.saturday"), sun: t("backup.weekdays.sunday"),
|
|
}
|
|
const rangeMatch = chunk.match(/^([A-Za-z]+)\.\.([A-Za-z]+)$/)
|
|
if (rangeMatch) {
|
|
const order = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
|
|
const start = order.indexOf(rangeMatch[1].slice(0, 3).toLowerCase())
|
|
const end = order.indexOf(rangeMatch[2].slice(0, 3).toLowerCase())
|
|
if (start >= 0 && end >= 0 && start <= end) {
|
|
return order.slice(start, end + 1).map((d) => days[d]).join(", ")
|
|
}
|
|
}
|
|
return chunk
|
|
.split(",")
|
|
.map((d) => days[d.trim().slice(0, 3).toLowerCase()] || d.trim())
|
|
.join(", ")
|
|
}
|
|
return t("backup.schedule.weekdaysAt", { days: expandWeekdays(m[1]), time: `${m[2].padStart(2, "0")}:${m[3]}` })
|
|
}
|
|
return t("backup.schedule.rawOnCalendar", { value: s })
|
|
}
|
|
|
|
// A job is "running" when its .status file has RUN_AT (runner started)
|
|
// but no RESULT yet (runner hasn't written the verdict). Lets the UI
|
|
// flag in-progress runs both inline (badge in the row) and from the
|
|
// Manual backups card so an operator who closes the dialog can re-open
|
|
// the live stream.
|
|
function isJobRunning(j: BackupJob | null | undefined): boolean {
|
|
if (!j?.last_status) return false
|
|
const s = parseJobStatus(j.last_status)
|
|
return !!(s?.runAt && !s?.result)
|
|
}
|
|
|
|
// last_status is the raw RUN_AT=..., RESULT=..., LOG_FILE=... blob the
|
|
// scheduler runner persists. We only care about RESULT + RUN_AT for the
|
|
// UI; everything else is noise the operator doesn't need on a list view.
|
|
function parseJobStatus(raw: string | null): { result: string | null; runAt: string | null; logFile: string | null } | null {
|
|
if (!raw) return null
|
|
const map: Record<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 localizedBackendLabel = (source: string, t: (key: string) => string): string =>
|
|
source === "pbs" ? "PBS" : source === "borg" ? "Borg" : t("backup.backends.local")
|
|
|
|
const formatCalendarPreview = (value: string, language: string, t: (key: string) => string): string => {
|
|
if (language !== "sk") return value
|
|
const match = value.match(/^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s+(\d{4})-(\d{2})-(\d{2})\s+(.+)$/i)
|
|
if (!match) return value
|
|
const [, weekday, year, month, day, rest] = match
|
|
return `${t(`backup.weekdays.short.${weekday.toLowerCase()}`)} ${Number(day)}. ${Number(month)}. ${year} ${rest}`
|
|
}
|
|
|
|
const formatCalendarDistance = (value: string, language: string): string => {
|
|
if (language !== "sk") return value
|
|
const distance = value.replace(/\s+left$/i, "").replace(/\bdays?\b/gi, "d")
|
|
return `zostáva ${distance}`
|
|
}
|
|
|
|
const formatRunAt = (iso: string | null) => {
|
|
if (!iso) return null
|
|
try {
|
|
return new Date(iso).toLocaleString(undefined, {
|
|
day: "2-digit",
|
|
month: "short",
|
|
year: "numeric",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
})
|
|
} catch {
|
|
return iso
|
|
}
|
|
}
|
|
|
|
// ── PBS keyfile management (shared between CreateJob + ManualBackup) ──
|
|
//
|
|
// Rendered inside the encryption section when a keyfile is already
|
|
// installed. Replaces the older static info box that pointed to a
|
|
// non-existent "Settings → Host backup" panel: the operator now has
|
|
// concrete actions (download keyfile, update stored passphrase,
|
|
// change recovery model, remove keyfile) with destructive-confirmation
|
|
// modals that call the /api/host-backups/pbs-encryption/* endpoints
|
|
// and mutate pbsRecoveryStatus so the parent dialog re-renders on
|
|
// success. There is no "Replace keyfile" action any more — replacing
|
|
// the key would silently orphan every encrypted backup that used it.
|
|
// The operator instead uses Remove + set up again via the normal flow
|
|
// (Generate a new keyfile / Use an existing keyfile), which surfaces
|
|
// the source picker properly.
|
|
// ──────────────────────────────────────────────────────────────
|
|
// PbsKeyfileActions — the trimmed action set the operator sees
|
|
// inside every PBS destination row: Download, Upload (import a new
|
|
// keyfile), Delete. No stored-passphrase rotation, no escrow toggle
|
|
// — those live in the setup dialogs where the operator makes the
|
|
// initial call. Placing these three next to the destination they
|
|
// belong to matches "the key is a PBS thing" without dragging in
|
|
// the fuller management surface.
|
|
// ──────────────────────────────────────────────────────────────
|
|
function PbsKeyfileActions({ pbsRepository }: { pbsRepository?: string }) {
|
|
const t = useT()
|
|
const { data: info, mutate: mutateInfo } = useSWR<{
|
|
installed: boolean
|
|
fingerprint?: string
|
|
path: string
|
|
escrow_mode?: "none" | "local" | "full"
|
|
}>("/api/host-backups/pbs-encryption/keyfile-info", fetcher)
|
|
|
|
const [uploadOpen, setUploadOpen] = useState(false)
|
|
const [deleteOpen, setDeleteOpen] = useState(false)
|
|
const [importFile, setImportFile] = useState<File | null>(null)
|
|
const [importPath, setImportPath] = useState("")
|
|
const [busy, setBusy] = useState(false)
|
|
const [err, setErr] = useState<string | null>(null)
|
|
|
|
// Auto-discover a PVE-managed keyfile at /etc/pve/priv/storage/<NAME>.enc
|
|
// that matches this specific PBS repository. When present, the Upload
|
|
// modal surfaces a one-click "Use PVE-managed key" shortcut at the
|
|
// top so the operator doesn't have to hunt down the file path — the
|
|
// exact convenience the shell TUI already provides during its import
|
|
// wizard. Only fetched while the modal is open + a repository is known.
|
|
const { data: pveDiscover } = useSWR<{
|
|
entries: Array<{ name: string; server: string; datastore: string; path: string; matches_repository: boolean }>
|
|
}>(
|
|
uploadOpen && pbsRepository
|
|
? `/api/host-backups/pbs-encryption/discover-pve-keyfiles?repository=${encodeURIComponent(pbsRepository)}`
|
|
: null,
|
|
fetcher,
|
|
)
|
|
const pveMatch = pveDiscover?.entries?.find((e) => e.matches_repository) || null
|
|
|
|
const closeUpload = () => {
|
|
setUploadOpen(false)
|
|
setImportFile(null)
|
|
setImportPath("")
|
|
setBusy(false)
|
|
setErr(null)
|
|
}
|
|
const closeDelete = () => {
|
|
setDeleteOpen(false)
|
|
setBusy(false)
|
|
setErr(null)
|
|
}
|
|
|
|
const download = () => {
|
|
// Same plain-<a>-download trick used elsewhere: bypasses fetch so
|
|
// an ad-blocker or a stale SWR cache can't intervene.
|
|
const a = document.createElement("a")
|
|
a.href = "/api/host-backups/pbs-encryption/download-keyfile"
|
|
a.download = "pbs-key.conf"
|
|
document.body.appendChild(a)
|
|
a.click()
|
|
document.body.removeChild(a)
|
|
}
|
|
|
|
const runUpload = async () => {
|
|
if (!importFile && !importPath.trim()) {
|
|
setErr(t("backup.errors.pickKeyfile"))
|
|
return
|
|
}
|
|
setBusy(true)
|
|
setErr(null)
|
|
try {
|
|
const body: Record<string, unknown> = { escrow_mode: "none" }
|
|
if (importFile) {
|
|
const buf = new Uint8Array(await importFile.arrayBuffer())
|
|
let bin = ""
|
|
for (let i = 0; i < buf.length; i++) bin += String.fromCharCode(buf[i])
|
|
body.source = "content"
|
|
body.content_b64 = btoa(bin)
|
|
} else {
|
|
body.source = "path"
|
|
body.keyfile_path = importPath.trim()
|
|
}
|
|
await fetchApi("/api/host-backups/pbs-encryption/import", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
})
|
|
await mutateInfo()
|
|
closeUpload()
|
|
} catch (e) {
|
|
setErr(e instanceof Error ? e.message : String(e))
|
|
setBusy(false)
|
|
}
|
|
}
|
|
|
|
const runDelete = async () => {
|
|
setBusy(true)
|
|
setErr(null)
|
|
try {
|
|
await fetchApi("/api/host-backups/pbs-encryption/remove", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({}),
|
|
})
|
|
await mutateInfo()
|
|
closeDelete()
|
|
} catch (e) {
|
|
setErr(e instanceof Error ? e.message : String(e))
|
|
setBusy(false)
|
|
}
|
|
}
|
|
|
|
const installed = !!info?.installed
|
|
|
|
return (
|
|
<div className="mt-2 border-t border-white/10 pt-2 space-y-1.5">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<div className="text-xs min-w-0 flex-1 flex items-center gap-1.5">
|
|
<span className="font-medium text-muted-foreground">{t("backup.keyfileActions.keyfileLabel")}</span>{" "}
|
|
{installed ? (
|
|
<span
|
|
className="inline-flex items-center gap-1 text-emerald-400 font-medium"
|
|
title={info?.fingerprint ? t("backup.keyfileActions.fingerprintTitle", { fingerprint: info.fingerprint }) : undefined}
|
|
>
|
|
<CheckCircle2 className="h-4 w-4" />
|
|
{t("backup.keyfileActions.installed")}
|
|
</span>
|
|
) : (
|
|
<span className="text-blue-400 font-medium">{t("backup.keyfileActions.notInstalled")}</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-1.5 shrink-0">
|
|
{installed && (
|
|
<>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="outline"
|
|
className="h-7 text-[11px] !text-emerald-400 border-emerald-500/40 hover:bg-emerald-500/10"
|
|
onClick={download}
|
|
title={t("backup.keyfileActions.downloadTitle")}
|
|
>
|
|
<Download className="h-3.5 w-3.5 mr-1" />
|
|
{t("backup.actions.download")}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="outline"
|
|
className="h-7 text-[11px] !text-red-400 border-red-500/40 hover:bg-red-500/10"
|
|
onClick={() => setDeleteOpen(true)}
|
|
title={t("backup.keyfileActions.deleteTitle")}
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5 mr-1" />
|
|
{t("backup.actions.delete")}
|
|
</Button>
|
|
</>
|
|
)}
|
|
{!installed && (
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="outline"
|
|
className="h-7 text-[11px] !text-blue-400 border-blue-500/40 hover:bg-blue-500/10"
|
|
onClick={() => setUploadOpen(true)}
|
|
title={t("backup.keyfileActions.uploadTitle")}
|
|
>
|
|
<Lock className="h-3.5 w-3.5 mr-1" />
|
|
{t("backup.actions.upload")}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<Dialog open={uploadOpen} onOpenChange={(v) => !v && closeUpload()}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>{t("backup.keyfileActions.uploadDialogTitle")}</DialogTitle>
|
|
<DialogDescription>
|
|
{t("backup.keyfileActions.uploadDialogDescriptionBefore")} <code className="font-mono">/usr/local/share/proxmenux/pbs-key.conf</code> {t("backup.keyfileActions.uploadDialogDescriptionAfter")}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-3">
|
|
{pveMatch && (
|
|
<div className="rounded-md border border-emerald-500/40 bg-emerald-500/10 p-3 space-y-2 text-xs">
|
|
<div className="flex items-start gap-2">
|
|
<Lock className="h-4 w-4 text-emerald-400 mt-0.5 shrink-0" />
|
|
<div className="flex-1 space-y-1">
|
|
<div className="font-semibold text-emerald-300">{t("backup.keyfileActions.pveKeyDetected")}</div>
|
|
<div className="text-muted-foreground">
|
|
{t("backup.keyfileActions.pveKeyDescriptionBefore")} <code className="font-mono text-[10.5px]">{pveMatch.name}</code> {t("backup.keyfileActions.pveKeyDescriptionMiddle")}{" "}
|
|
<code className="font-mono text-[10.5px] break-all">{pveMatch.path}</code>. {t("backup.keyfileActions.pveKeyDescriptionAfter")}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex justify-end">
|
|
<Button
|
|
size="sm"
|
|
onClick={() => {
|
|
setImportPath(pveMatch.path)
|
|
setImportFile(null)
|
|
}}
|
|
disabled={busy}
|
|
className="!bg-emerald-600 hover:!bg-emerald-700 !text-white h-7 text-[11px]"
|
|
>
|
|
{t("backup.keyfileActions.useThisKey")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{pveMatch && <div className="text-[10px] text-center text-muted-foreground">{t("backup.keyfileActions.orUploadOwn")}</div>}
|
|
<div>
|
|
<Label htmlFor="pbsKfUploadFile" className="text-xs">{t("backup.keyfileActions.uploadFromMachine")}</Label>
|
|
<Input
|
|
id="pbsKfUploadFile"
|
|
type="file"
|
|
accept=".conf,.key,application/json,text/plain"
|
|
onChange={(e) => setImportFile(e.target.files?.[0] ?? null)}
|
|
disabled={busy || !!importPath.trim()}
|
|
className="h-9 mt-1"
|
|
/>
|
|
</div>
|
|
<div className="text-[10px] text-center text-muted-foreground">{t("backup.common.or")}</div>
|
|
<div>
|
|
<Label htmlFor="pbsKfUploadPath" className="text-xs">{t("backup.fields.absolutePathOnHost")}</Label>
|
|
<Input
|
|
id="pbsKfUploadPath"
|
|
type="text"
|
|
value={importPath}
|
|
onChange={(e) => setImportPath(e.target.value)}
|
|
disabled={busy || !!importFile}
|
|
placeholder={t("backup.placeholders.keyfilePath")}
|
|
className="h-9 mt-1 font-mono text-xs"
|
|
/>
|
|
</div>
|
|
{err && (
|
|
<div className="text-xs text-red-500 px-3 py-2 rounded-md border border-red-500/30 bg-red-500/10 whitespace-pre-wrap break-words">{err}</div>
|
|
)}
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={closeUpload} disabled={busy}>{t("actions.cancel")}</Button>
|
|
<Button
|
|
onClick={runUpload}
|
|
disabled={busy || (!importFile && !importPath.trim())}
|
|
className="!bg-blue-500 hover:!bg-blue-600 !text-white"
|
|
>
|
|
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : t("backup.actions.import")}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<Dialog open={deleteOpen} onOpenChange={(v) => !v && closeDelete()}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<AlertTriangle className="h-5 w-5 text-red-400" />
|
|
{t("backup.keyfileActions.deleteDialogTitle")}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{t("backup.keyfileActions.deleteDialogDescription")}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<ul className="text-xs text-muted-foreground list-disc list-inside space-y-1 pl-2">
|
|
<li>{t("backup.keyfileActions.deleteWarningNewBackups")}</li>
|
|
<li>{t("backup.keyfileActions.deleteWarningDownloadsBefore")} <strong className="text-red-400">{t("backup.keyfileActions.willFail")}</strong> {t("backup.keyfileActions.deleteWarningDownloadsAfter")}</li>
|
|
<li>{t("backup.keyfileActions.deleteWarningRecoveryBlobs")}</li>
|
|
</ul>
|
|
{err && (
|
|
<div className="text-xs text-red-500 px-3 py-2 rounded-md border border-red-500/30 bg-red-500/10 whitespace-pre-wrap break-words">{err}</div>
|
|
)}
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={closeDelete} disabled={busy}>{t("actions.cancel")}</Button>
|
|
<Button onClick={runDelete} disabled={busy} className="!bg-red-500 hover:!bg-red-600 !text-white">
|
|
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : t("backup.actions.delete")}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function KeyfileActionsBar({
|
|
mutateStatus,
|
|
escrowMode,
|
|
}: {
|
|
mutateStatus: () => Promise<unknown>
|
|
escrowMode?: "none" | "local" | "full"
|
|
}) {
|
|
const t = useT()
|
|
const [busy, setBusy] = useState(false)
|
|
const [err, setErr] = useState<string | null>(null)
|
|
const [pass1, setPass1] = useState("")
|
|
const [pass2, setPass2] = useState("")
|
|
// Pending radio state — reflects the operator's intended mode after
|
|
// Apply. Seeded from the current mode so a No-op click on Apply is
|
|
// rejected. Binary UX ('none' vs 'full') matches the setup wizard.
|
|
const currentIsFull = escrowMode === "full"
|
|
const [pendingMode, setPendingMode] = useState<"none" | "full">(currentIsFull ? "full" : "none")
|
|
|
|
const runApply = async () => {
|
|
// Yes → passphrase required + match. No → passphrase ignored.
|
|
if (pendingMode === "full") {
|
|
if (!pass1) {
|
|
setErr(t("backup.errors.recoveryPassphraseRequired"))
|
|
return
|
|
}
|
|
if (pass1 !== pass2) {
|
|
setErr(t("backup.errors.passphrasesDoNotMatch"))
|
|
return
|
|
}
|
|
}
|
|
setBusy(true)
|
|
setErr(null)
|
|
try {
|
|
const body: Record<string, unknown> = { escrow_mode: pendingMode }
|
|
if (pendingMode === "full") body.escrow_passphrase = pass1
|
|
await fetchApi("/api/host-backups/pbs-encryption/set-escrow-mode", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
})
|
|
await mutateStatus()
|
|
setPass1("")
|
|
setPass2("")
|
|
} catch (e) {
|
|
setErr(e instanceof Error ? e.message : String(e))
|
|
} finally {
|
|
setBusy(false)
|
|
}
|
|
}
|
|
|
|
// Contextual Apply-button label. Three transitions:
|
|
// No → Yes → "Start uploading"
|
|
// Yes → No → "Stop uploading"
|
|
// Yes → Yes (new pw) → "Update passphrase" (rewraps envelope)
|
|
const applyLabel =
|
|
pendingMode === "full" && !currentIsFull
|
|
? t("backup.keyfileManagement.startUploading")
|
|
: pendingMode === "none" && currentIsFull
|
|
? t("backup.keyfileManagement.stopUploading")
|
|
: pendingMode === "full" && currentIsFull
|
|
? t("backup.keyfileManagement.updatePassphrase")
|
|
: t("backup.actions.apply")
|
|
|
|
// Apply is enabled when there is a real change to commit.
|
|
const canApply = (
|
|
(pendingMode !== (currentIsFull ? "full" : "none")) || // mode change
|
|
(pendingMode === "full" && !!pass1 && pass1 === pass2) // passphrase update
|
|
)
|
|
|
|
const download = () => {
|
|
const a = document.createElement("a")
|
|
a.href = "/api/host-backups/pbs-encryption/download-keyfile"
|
|
a.download = "pbs-key.conf"
|
|
document.body.appendChild(a)
|
|
a.click()
|
|
document.body.removeChild(a)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
<div className="text-[11px] font-medium text-foreground">{t("backup.keyfileManagement.title")}</div>
|
|
|
|
{/* Current status — icon + colour by state, no truncated fp. */}
|
|
{escrowMode !== undefined && (
|
|
<div className="flex items-center gap-2 text-xs bg-card border border-white/10 rounded px-2.5 py-1.5">
|
|
<span className="font-medium text-foreground">{t("backup.keyfileManagement.uploadToPbs")}</span>
|
|
{currentIsFull ? (
|
|
<span className="inline-flex items-center gap-1 text-emerald-400 font-medium">
|
|
<CheckCircle2 className="h-3.5 w-3.5" />
|
|
{t("backup.keyfileManagement.uploadYes")}
|
|
</span>
|
|
) : (
|
|
<span className="inline-flex items-center gap-1 text-blue-400 font-medium">
|
|
{t("backup.keyfileManagement.uploadNo")}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Change mode + passphrase — one integrated form, no popover.
|
|
Radio picks the intent; the passphrase pair unfolds when the
|
|
intent is Yes (both for a first-time upload and for a
|
|
passphrase rotation while already in Yes). */}
|
|
<div className="space-y-2 rounded-md border border-blue-500/30 bg-blue-500/5 p-3">
|
|
<div className="text-[11px] font-medium text-foreground">{t("backup.keyfileManagement.uploadQuestion")}</div>
|
|
<div className="grid gap-1.5">
|
|
<label className="flex items-start gap-2 cursor-pointer text-[11px]">
|
|
<input
|
|
type="radio"
|
|
name="pendingModeChange"
|
|
checked={pendingMode === "none"}
|
|
onChange={() => { setPendingMode("none"); setPass1(""); setPass2("") }}
|
|
className="mt-1"
|
|
/>
|
|
<div className="flex-1">
|
|
<div className="font-medium">{t("backup.keyfileManagement.keepLocalOnly")}</div>
|
|
<div className="text-muted-foreground">{t("backup.keyfileManagement.keepLocalDescriptionBefore")} <code className="font-mono text-[10.5px]">/usr/local/share/proxmenux/pbs-key.conf</code>. {t("backup.keyfileManagement.keepLocalDescriptionAfter")} <em>{t("backup.keyfileManagement.downloadKeyfile")}</em> {t("backup.keyfileManagement.below")}.</div>
|
|
</div>
|
|
</label>
|
|
<label className="flex items-start gap-2 cursor-pointer text-[11px]">
|
|
<input
|
|
type="radio"
|
|
name="pendingModeChange"
|
|
checked={pendingMode === "full"}
|
|
onChange={() => setPendingMode("full")}
|
|
className="mt-1"
|
|
/>
|
|
<div className="flex-1">
|
|
<div className="font-medium">{t("backup.keyfileManagement.yesUpload")}</div>
|
|
<div className="text-muted-foreground">{t("backup.keyfileManagement.yesUploadDescription")}</div>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
{pendingMode === "full" && (
|
|
<div className="space-y-2 pt-2 border-t border-blue-500/20">
|
|
<div>
|
|
<Label htmlFor="mgmtPass1" className="text-[11px]">
|
|
{currentIsFull ? t("backup.fields.newRecoveryPassphrase") : t("backup.fields.recoveryPassphrase")}
|
|
</Label>
|
|
<Input
|
|
id="mgmtPass1"
|
|
type="password"
|
|
value={pass1}
|
|
onChange={(e) => setPass1(e.target.value)}
|
|
placeholder={currentIsFull ? t("backup.placeholders.newRecoveryPassphrase") : t("backup.placeholders.recoveryPassphrase")}
|
|
className="font-mono mt-1 h-8 text-xs"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="mgmtPass2" className="text-[11px]">{t("backup.fields.confirmPassphrase")}</Label>
|
|
<Input
|
|
id="mgmtPass2"
|
|
type="password"
|
|
value={pass2}
|
|
onChange={(e) => setPass2(e.target.value)}
|
|
placeholder={t("backup.placeholders.typeItAgain")}
|
|
className="font-mono mt-1 h-8 text-xs"
|
|
/>
|
|
{pass1 && pass2 && pass1 !== pass2 && (
|
|
<p className="text-[11px] text-red-400 mt-1">{t("backup.errors.passphrasesDontMatch")}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{err && (
|
|
<div className="text-[11px] text-red-500 px-2 py-1.5 rounded-md border border-red-500/30 bg-red-500/10 whitespace-pre-wrap break-words">{err}</div>
|
|
)}
|
|
<div className="flex justify-end">
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
onClick={runApply}
|
|
disabled={busy || !canApply}
|
|
className="!bg-blue-500 hover:!bg-blue-600 !text-white disabled:opacity-50"
|
|
>
|
|
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : applyLabel}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap gap-1.5">
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="outline"
|
|
className="h-7 text-[11px] !text-emerald-400 border-emerald-500/40 hover:bg-emerald-500/10"
|
|
onClick={download}
|
|
>
|
|
<Download className="h-3.5 w-3.5 mr-1" />
|
|
{t("backup.keyfileManagement.downloadKeyfile")}
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Deletion lives inside the PBS destination row (PbsKeyfileActions);
|
|
setup menus of Create Job / Manual Backup never surface it. */}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function HostBackup() {
|
|
const t = useT()
|
|
const { data: jobsResp, error: jobsErr, mutate: mutateJobs } = useSWR<{ jobs: BackupJob[] }>(
|
|
"/api/host-backups/jobs",
|
|
fetcher,
|
|
{ refreshInterval: 30000 },
|
|
)
|
|
const [busyJobId, setBusyJobId] = useState<string | null>(null)
|
|
const [actionError, setActionError] = useState<string | null>(null)
|
|
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)
|
|
const [watchingManualId, setWatchingManualId] = useState<string | null>(null)
|
|
const [runningManual, setRunningManual] = useState<boolean>(false)
|
|
// Independent tracker for the most recent manual backup that's still
|
|
// running. Survives the operator closing the ManualJobWatchModal —
|
|
// the modal only drives auto-refresh while it's OPEN, so if the
|
|
// operator dismisses it before the job finishes, the archive lists
|
|
// would only refresh on the next SWR tick (30-60 s). Polling the
|
|
// job status here at 3 s and calling mutate once we see the run
|
|
// transition to completed closes that gap regardless of modal
|
|
// state. Set = single slot per launch; a fresh manual replaces the
|
|
// slot (the previous one falls back to the periodic SWR tick).
|
|
const [pendingManualJobId, setPendingManualJobId] = useState<string | null>(null)
|
|
|
|
async function confirmDeleteJob() {
|
|
if (!jobToDelete) return
|
|
const id = jobToDelete.id
|
|
setBusyJobId(id)
|
|
setActionError(null)
|
|
try {
|
|
await fetchApi(`/api/host-backups/jobs/${encodeURIComponent(id)}`, { method: "DELETE" })
|
|
mutateJobs()
|
|
setJobToDelete(null)
|
|
} catch (e) {
|
|
setActionError(t("backup.errors.deleteFailed", { error: `"${id}": ${e instanceof Error ? e.message : String(e)}` }))
|
|
} finally {
|
|
setBusyJobId(null)
|
|
}
|
|
}
|
|
const { data: destinationsResp, mutate: mutateDestinations } = useSWR<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[] }>(
|
|
"/api/host-backups/archives",
|
|
fetcher,
|
|
{ refreshInterval: 30000 },
|
|
)
|
|
|
|
// Background poll for the pending manual backup, independent of the
|
|
// watch modal being open. Fires mutateArchives/mutateRemoteArchives
|
|
// as soon as the run flips to done so the operator sees the new
|
|
// entry in Available Archives without a manual refresh.
|
|
const { data: pendingManualPoll } = useSWR<JobDetail>(
|
|
pendingManualJobId ? `/api/host-backups/jobs/${encodeURIComponent(pendingManualJobId)}` : null,
|
|
fetcher,
|
|
{ refreshInterval: 3000 },
|
|
)
|
|
useEffect(() => {
|
|
const d = pendingManualPoll as JobDetail | undefined
|
|
if (pendingManualJobId && d?.last_run_at && d?.last_result) {
|
|
mutateJobs()
|
|
mutateArchives()
|
|
mutateRemoteArchives()
|
|
setPendingManualJobId(null)
|
|
}
|
|
}, [pendingManualPoll, pendingManualJobId, mutateJobs, mutateArchives, mutateRemoteArchives])
|
|
|
|
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
|
|
})()
|
|
|
|
return (
|
|
<div className="space-y-4 md:space-y-6">
|
|
{/* ── Post-restore progress card ────────────────────
|
|
Renders only while a restore is running or its
|
|
summary hasn't been acknowledged. Once dismissed,
|
|
it collapses to a "Past restores" ghost button. */}
|
|
<RestoreProgressCard />
|
|
|
|
{/* ── 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">{t("backup.jobs.scheduledTitle")}</CardTitle>
|
|
<Badge variant="outline" className="ml-1">
|
|
{jobsResp?.jobs?.filter((j) => !j.manual).length ?? 0}
|
|
</Badge>
|
|
</div>
|
|
<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" />
|
|
{t("backup.jobs.createJob")}
|
|
</Button>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{jobsErr ? (
|
|
<div className="text-sm text-red-500 py-4">{t("backup.jobs.loadFailed")}</div>
|
|
) : !jobsResp ? (
|
|
<div className="flex items-center gap-2 py-4 text-sm text-muted-foreground">
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
{t("backup.common.loading")}
|
|
</div>
|
|
) : jobsResp.jobs.filter((j) => !j.manual).length === 0 ? null : (
|
|
<div className="space-y-2">
|
|
{actionError && (
|
|
<div className="text-xs text-red-500 px-3 py-2 rounded-md border border-red-500/30 bg-red-500/10 whitespace-pre-wrap break-words">
|
|
{actionError}
|
|
</div>
|
|
)}
|
|
{jobsResp.jobs.filter((j) => !j.manual).map((j) => {
|
|
const status = parseJobStatus(j.last_status)
|
|
const running = isJobRunning(j)
|
|
// While running we override the ok/failed pill with a
|
|
// running spinner — same way the modal does. Clicking
|
|
// the row re-opens JobDetailModal which auto-detects
|
|
// the in-progress state and resumes streaming.
|
|
const statusBadge = running
|
|
? { label: t("backup.status.running"), cls: "bg-blue-500/10 border-blue-500/40 text-blue-300" }
|
|
: status?.result === "ok"
|
|
? { label: "ok", cls: "bg-emerald-500/10 border-emerald-500/40 text-emerald-400" }
|
|
: status?.result
|
|
? { label: status.result, cls: "bg-red-500/10 border-red-500/40 text-red-400" }
|
|
: null
|
|
const lastRunWhen = formatRunAt(status?.runAt ?? null)
|
|
return (
|
|
<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={t("backup.jobs.openJobTitle")}
|
|
>
|
|
<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">
|
|
{t("backup.status.manual")}
|
|
</Badge>
|
|
)}
|
|
{j.attached && (
|
|
<Badge variant="outline" className="text-[10px] text-blue-400 border-blue-400/40 bg-blue-500/5">
|
|
{t("backup.status.attached")}
|
|
</Badge>
|
|
)}
|
|
{j.encrypted && (
|
|
<Badge
|
|
variant="outline"
|
|
className="text-[10px] uppercase tracking-wide border-emerald-500/40 text-emerald-400 bg-emerald-500/5"
|
|
title={t("backup.jobs.encryptedTitle")}
|
|
>
|
|
<Lock className="h-3.5 w-3.5" />
|
|
</Badge>
|
|
)}
|
|
{/* Surface the profile mode at row level so the
|
|
operator can tell at a glance whether the
|
|
job ships the default path set or a custom
|
|
list — without having to open the detail
|
|
modal. Same wording the modal uses. */}
|
|
<Badge
|
|
variant="outline"
|
|
className={`text-[10px] uppercase tracking-wide ${
|
|
j.profile_mode === "custom"
|
|
? "border-cyan-500/40 text-cyan-400 bg-cyan-500/5"
|
|
: "border-border text-muted-foreground bg-card"
|
|
}`}
|
|
title={
|
|
j.profile_mode === "custom"
|
|
? t("backup.jobs.customProfileTitle")
|
|
: t("backup.jobs.defaultProfileTitle")
|
|
}
|
|
>
|
|
{j.profile_mode === "custom" ? t("backup.profile.custom") : t("backup.profile.default")}
|
|
</Badge>
|
|
{!j.enabled && !j.manual && (
|
|
<Badge variant="outline" className="text-[10px] text-amber-500 border-amber-500/40 bg-amber-500/5">
|
|
{t("status.disabled")}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
|
|
{/* Destination */}
|
|
<div className="mt-1 text-sm font-mono text-muted-foreground truncate" title={j.destination}>
|
|
{j.destination || "—"}
|
|
</div>
|
|
|
|
{/* Stats row — icons in green, values in foreground
|
|
white to match the rest of the dashboard. */}
|
|
<div className="mt-2 text-xs text-foreground flex items-center gap-x-4 gap-y-1 flex-wrap">
|
|
<span className="inline-flex items-center gap-1" title={j.on_calendar}>
|
|
<Calendar className="h-3.5 w-3.5 text-green-500" />
|
|
<span>{humanizeOnCalendar(j.on_calendar, t)}</span>
|
|
</span>
|
|
{j.retention && (
|
|
<span className="inline-flex items-center gap-1.5 flex-wrap" title={t("backup.jobs.retentionTitle")}>
|
|
<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={t("backup.jobs.nextRunTitle")}>
|
|
<Clock className="h-3.5 w-3.5 text-green-500" />
|
|
<span>{t("backup.jobs.nextRun", { time: 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>{t("backup.jobs.lastRunLabel")}</span>
|
|
{statusBadge && (
|
|
<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" />}
|
|
{statusBadge.label}
|
|
</span>
|
|
)}
|
|
{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>{t("backup.jobs.neverRun")}</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>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* ── Backup configuration (destinations + custom paths) ── */}
|
|
<BackupConfigurationCard
|
|
destinations={destinationsResp}
|
|
onDestChanged={() => mutateDestinations()}
|
|
/>
|
|
|
|
{/* ── Manual backups (entry point) ──────────────────── */}
|
|
<Card className="bg-card border-border">
|
|
<CardHeader className="pb-3">
|
|
<div className="flex items-center gap-2">
|
|
<PlayCircle className="h-5 w-5 text-purple-400" />
|
|
<CardTitle className="text-base font-semibold">{t("backup.manual.title")}</CardTitle>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3">
|
|
<p className="text-xs text-muted-foreground">
|
|
{t("backup.manual.description")}
|
|
</p>
|
|
<Button
|
|
className="w-full sm:w-auto bg-purple-500 hover:bg-purple-600 text-white"
|
|
onClick={() => setRunningManual(true)}
|
|
>
|
|
<PlayCircle className="h-4 w-4 mr-2" />
|
|
{t("backup.manual.run")}
|
|
</Button>
|
|
{/* In-progress manual jobs. If the operator closed the
|
|
ManualBackupDialog before the runner finished, this
|
|
banner is the way back into the live log — clicking
|
|
opens the JobDetailModal which automatically resumes
|
|
the streaming view for any in-progress job. */}
|
|
{jobsResp?.jobs
|
|
?.filter((j) => j.manual && isJobRunning(j))
|
|
.map((j) => (
|
|
<button
|
|
key={j.id}
|
|
type="button"
|
|
onClick={() => setWatchingManualId(j.id)}
|
|
className="w-full flex items-center gap-2 px-3 py-2 rounded-md border border-blue-500/40 bg-blue-500/5 hover:bg-blue-500/10 transition-colors text-left"
|
|
title={t("backup.manual.reopenLogTitle")}
|
|
>
|
|
<Loader2 className="h-4 w-4 animate-spin text-blue-400 shrink-0" />
|
|
<span className="text-sm min-w-0 flex-1 truncate">
|
|
{t("backup.manual.inProgress")} — <span className="font-mono">{j.id}</span>
|
|
</span>
|
|
<span className="text-xs text-blue-300 shrink-0">{t("backup.manual.viewProgress")}</span>
|
|
</button>
|
|
))}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* ── 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">{t("backup.archives.title")}</CardTitle>
|
|
</div>
|
|
<Badge variant="outline">{unifiedArchives.length}</Badge>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<p className="text-[11px] text-muted-foreground mb-3">
|
|
{t("backup.archives.descriptionBefore")} <code className="font-mono">.tar.zst</code> {t("backup.archives.descriptionAfter")}
|
|
</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">{t("backup.archives.remoteQueryWarning")}</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 ? (
|
|
<div className="text-sm text-red-500 py-4">{t("backup.archives.loadFailed")}</div>
|
|
) : !archivesResp && !remoteArchivesResp ? (
|
|
<div className="flex items-center gap-2 py-4 text-sm text-muted-foreground">
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
{t("backup.common.loading")}
|
|
</div>
|
|
) : unifiedArchives.length === 0 ? (
|
|
<div className="text-sm text-muted-foreground py-4">
|
|
{t("backup.archives.emptyBefore")} <span className="font-medium">{t("backup.manual.run")}</span> {t("backup.archives.emptyAfter")}
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{unifiedArchives.map((u) => {
|
|
const localKind = u.local?.kind
|
|
const localJobId = u.local?.job_id
|
|
const localHost = u.local?.source_hostname
|
|
const localPath = u.local?.path
|
|
// Same palette as the DestinationRow accents so each
|
|
// backend reads identically across the two cards.
|
|
const sourceBadgeCls =
|
|
u.source === "pbs"
|
|
? "text-purple-400 border-purple-500/20 bg-purple-500/10"
|
|
: u.source === "borg"
|
|
? "text-fuchsia-400 border-fuchsia-500/20 bg-fuchsia-500/10"
|
|
: "text-blue-400 border-blue-500/20 bg-blue-500/10"
|
|
return (
|
|
<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-card hover:bg-white/5 hover:border-blue-500/40 transition-colors group"
|
|
title={t("backup.archives.inspectTitle")}
|
|
>
|
|
<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}`}>
|
|
{localizedBackendLabel(u.source, t)}
|
|
</span>
|
|
{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={t("backup.archives.encryptedTitle")}
|
|
>
|
|
<Lock className="h-3.5 w-3.5" />
|
|
</span>
|
|
)}
|
|
<span className="inline-flex items-center gap-1">
|
|
<Clock className="h-3 w-3" />
|
|
{formatMtime(u.created_at)}
|
|
</span>
|
|
<span className="inline-flex items-center gap-1">
|
|
<HardDrive className="h-3 w-3" />
|
|
{formatBytes(u.size_bytes)}
|
|
</span>
|
|
<span title={u.source_label}>
|
|
{t("backup.archives.at")} <code className="font-mono">{u.source_label}</code>
|
|
</span>
|
|
{u.source === "local" && localKind === "scheduled" && localJobId ? (
|
|
<span>{t("backup.archives.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">
|
|
{t("backup.status.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">
|
|
{t("backup.status.manual")}
|
|
</span>
|
|
) : null}
|
|
{(u.source === "pbs" || u.source === "borg") && u.remote?.backup_id && (
|
|
<span>{u.source === "pbs" ? t("backup.archives.group") : t("backup.archives.archive")}: <code className="font-mono">{u.remote.backup_id}</code></span>
|
|
)}
|
|
{u.source === "local" && localHost && (
|
|
<span>{t("backup.archives.host")} <code className="font-mono">{localHost}</code></span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<ChevronRight className="h-4 w-4 text-muted-foreground group-hover:text-blue-400 transition-colors shrink-0" />
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* ── Inspect / preflight modal ──────────────────────── */}
|
|
<InspectModal
|
|
archive={inspectingArchive}
|
|
onClose={() => setInspectingArchive(null)}
|
|
onDeleted={() => {
|
|
setInspectingArchive(null)
|
|
// Refresh both lists — a deleted item might have been local
|
|
// or remote (PBS/Borg). Cheap enough to revalidate both.
|
|
mutateArchives()
|
|
mutateRemoteArchives()
|
|
}}
|
|
/>
|
|
|
|
{/* ── Create / Edit job wizard ─────────────────────── */}
|
|
<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)}
|
|
onLaunched={(jobId) => {
|
|
// Hand off straight to the live-progress modal so the
|
|
// operator never has to chase the "View progress" banner
|
|
// after pressing Run. Mutate jobs first so the new manual
|
|
// entry is in cache when the watch modal opens.
|
|
setRunningManual(false)
|
|
mutateJobs()
|
|
setWatchingManualId(jobId)
|
|
// Register the job with the modal-independent background
|
|
// poller so the Available Archives list refreshes the
|
|
// instant the run finishes, even if the operator closes
|
|
// the watch modal before then. Replaces the old fire-and-
|
|
// forget 5 s mutate that used to miss slow backups.
|
|
setPendingManualJobId(jobId)
|
|
}}
|
|
/>
|
|
|
|
{/* ── Job detail / actions modal ─────────────────────────── */}
|
|
<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)
|
|
}
|
|
}}
|
|
onChanged={() => {
|
|
mutateJobs()
|
|
mutateArchives()
|
|
mutateRemoteArchives()
|
|
}}
|
|
/>
|
|
|
|
{/* Manual jobs use their own modal — no Schedule/Retention/
|
|
Profile, no Edit/Run/Disable. Only the live log; the modal
|
|
is closed with the dialog's own X (no extra footer). */}
|
|
<ManualJobWatchModal
|
|
jobId={watchingManualId}
|
|
onClose={() => setWatchingManualId(null)}
|
|
onChanged={() => {
|
|
// The watch modal fires onChanged when a tracked run
|
|
// finishes (last_result transitions from null → ok/failed).
|
|
// Refresh jobs (status badge), local archives (new tar.zst)
|
|
// and remote archives (Borg/PBS snapshots) so the operator
|
|
// sees the new backup without waiting for the next tick.
|
|
mutateJobs()
|
|
mutateArchives()
|
|
mutateRemoteArchives()
|
|
}}
|
|
/>
|
|
|
|
{/* ── 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" />
|
|
{t("backup.deleteJob.title")}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{t("backup.deleteJob.description")}
|
|
</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">{t("backup.fields.jobId")}</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">{t("backup.fields.type")}</div>
|
|
<div className="text-xs">{t("backup.deleteJob.attachedToStorage")} <span className="font-mono">{jobToDelete.pve_storage}</span></div>
|
|
</>
|
|
)}
|
|
</div>
|
|
{jobToDelete.attached ? (
|
|
<p className="text-xs text-muted-foreground">
|
|
{t("backup.deleteJob.attachedWarning")}
|
|
</p>
|
|
) : (
|
|
<p className="text-xs text-muted-foreground">
|
|
{t("backup.deleteJob.timerWarning")}
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
<div className="flex justify-end gap-2 mt-2">
|
|
<Button
|
|
variant="ghost"
|
|
onClick={() => setJobToDelete(null)}
|
|
disabled={busyJobId === jobToDelete?.id}
|
|
>
|
|
{t("actions.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" />
|
|
)}
|
|
{t("backup.actions.delete")}
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</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,
|
|
onDeleted,
|
|
}: {
|
|
archive: UnifiedArchive | null
|
|
onClose: () => void
|
|
onDeleted?: () => void
|
|
}) {
|
|
const t = useT()
|
|
const open = archive !== null
|
|
// Aliases to the source-specific payloads — saves on `.local!` /
|
|
// `.remote!` repetition later. PBS and Borg share the same shape.
|
|
const localArc = archive?.source === "local" ? archive.local : undefined
|
|
const remoteArc = archive && archive.source !== "local" ? archive.remote : undefined
|
|
const isRemote = archive?.source === "pbs" || archive?.source === "borg"
|
|
const backendLabel = archive?.source === "pbs" ? "PBS" : archive?.source === "borg" ? "Borg" : t("backup.backends.local")
|
|
const [mode, setMode] = useState<string>("full")
|
|
const [report, setReport] = useState<PreflightReport | null>(null)
|
|
const [running, setRunning] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [instructions, setInstructions] = useState<{
|
|
archive_basename: string
|
|
mode_label: string
|
|
shell_command: string
|
|
menu_path: string[]
|
|
reboot_required: boolean
|
|
notes: string[]
|
|
} | null>(null)
|
|
const [fetchingInstructions, setFetchingInstructions] = useState(false)
|
|
const [downloading, setDownloading] = useState(false)
|
|
// Per-archive runner log — only available for local archives, where
|
|
// the runner writes `<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)
|
|
const [viewingContents, setViewingContents] = useState(false)
|
|
// Restore flow state:
|
|
// restorePreparing — extract running (blocking spinner overlay)
|
|
// restoreOptions — modal open after prepare succeeded
|
|
// restoreTerminal — ScriptTerminalModal open with monitor_apply.sh
|
|
// The extract happens BEFORE the options modal so the custom
|
|
// checklist can be filled from `components_available` (what's
|
|
// actually inside this backup) instead of a hardcoded list.
|
|
const [restorePreparing, setRestorePreparing] = useState(false)
|
|
const [restoreError, setRestoreError] = useState<string | null>(null)
|
|
const [restoreOptions, setRestoreOptions] = useState<{
|
|
stagingPath: string
|
|
pathsAvailable: string[]
|
|
rollbackPlan?: RollbackPlan
|
|
// Cross-kernel awareness passed through to RestoreOptionsModal:
|
|
// when direction === "bk_older" the modal shows the safe-subset
|
|
// banner and grays out any path prefixed by anything in
|
|
// blockedPaths. "bk_newer" and "same" pass through as no-ops.
|
|
crossKernel?: {
|
|
direction: "same" | "bk_older" | "bk_newer"
|
|
backupKernel: string
|
|
targetKernel: string
|
|
blockedPaths: string[]
|
|
}
|
|
// Kernel-agnostic hydration preview (bk_older only): what the
|
|
// restore will re-apply to the target via merge instead of
|
|
// whole-file copy — IOMMU cmdline tokens, VFIO modules,
|
|
// operator's GRUB keys, whitelisted vfio/nvidia files.
|
|
hydration?: {
|
|
applies: boolean
|
|
actions: string[]
|
|
}
|
|
} | null>(null)
|
|
const [restoreTerminal, setRestoreTerminal] = useState<{
|
|
stagingPath: string
|
|
mode: "full" | "custom"
|
|
paths: string[]
|
|
rollbackExecute?: boolean
|
|
} | null>(null)
|
|
|
|
// ── Keyfile-required inline import ────────────────────────────
|
|
// When this modal opens on an encrypted PBS backup and the local
|
|
// keyfile is missing at /usr/local/share/proxmenux/pbs-key.conf,
|
|
// Restore / Download / View contents would all fail with
|
|
// "missing key". We block the buttons and offer an inline Import
|
|
// panel that reuses /api/host-backups/pbs-encryption/import with
|
|
// source=content + escrow_mode=none — same endpoint as the setup
|
|
// flow, so the imported keyfile lands at the canonical path and is
|
|
// reused by every subsequent backup + restore.
|
|
const { data: keyfileInfo, mutate: mutateKeyfileInfo } = useSWR<{
|
|
installed: boolean
|
|
fingerprint?: string
|
|
path: string
|
|
}>(
|
|
open && remoteArc?.encrypted ? "/api/host-backups/pbs-encryption/keyfile-info" : null,
|
|
fetcher,
|
|
)
|
|
const needsKeyfile = !!(remoteArc?.encrypted && keyfileInfo && !keyfileInfo.installed)
|
|
// Same PVE-managed keyfile auto-discovery the setup wizards use: when
|
|
// the encrypted snapshot lives on a PBS whose PVE storage.cfg entry
|
|
// has an `encryption-key` file at /etc/pve/priv/storage/<NAME>.enc,
|
|
// surface a one-click "Use this key" shortcut in the amber panel so
|
|
// the operator doesn't have to type the full path. Only fetched when
|
|
// the amber gate is actually rendered (encrypted + no keyfile).
|
|
const { data: pveDiscoverInspect } = useSWR<{
|
|
entries: Array<{ name: string; server: string; datastore: string; path: string; matches_repository: boolean }>
|
|
}>(
|
|
needsKeyfile && remoteArc?.repo_repository
|
|
? `/api/host-backups/pbs-encryption/discover-pve-keyfiles?repository=${encodeURIComponent(remoteArc.repo_repository)}`
|
|
: null,
|
|
fetcher,
|
|
)
|
|
const pveMatchInspect = pveDiscoverInspect?.entries?.find((e) => e.matches_repository) || null
|
|
const [importFile, setImportFile] = useState<File | null>(null)
|
|
const [importPath, setImportPath] = useState<string>("")
|
|
const [importing, setImporting] = useState(false)
|
|
const [importError, setImportError] = useState<string | null>(null)
|
|
|
|
const runImportKeyfile = async () => {
|
|
// Either a file upload OR a typed absolute path on this host —
|
|
// both land at /usr/local/share/proxmenux/pbs-key.conf with
|
|
// escrow_mode='none'. When both are provided the file wins.
|
|
if (!importFile && !importPath.trim()) {
|
|
setImportError(t("backup.errors.pickKeyfile"))
|
|
return
|
|
}
|
|
setImporting(true)
|
|
setImportError(null)
|
|
try {
|
|
const body: Record<string, unknown> = { escrow_mode: "none" }
|
|
if (importFile) {
|
|
const buf = new Uint8Array(await importFile.arrayBuffer())
|
|
let bin = ""
|
|
for (let i = 0; i < buf.length; i++) bin += String.fromCharCode(buf[i])
|
|
body.source = "content"
|
|
body.content_b64 = btoa(bin)
|
|
} else {
|
|
body.source = "path"
|
|
body.keyfile_path = importPath.trim()
|
|
}
|
|
await fetchApi("/api/host-backups/pbs-encryption/import", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
})
|
|
await mutateKeyfileInfo()
|
|
setImportFile(null)
|
|
setImportPath("")
|
|
} catch (e) {
|
|
setImportError(e instanceof Error ? e.message : String(e))
|
|
} finally {
|
|
setImporting(false)
|
|
}
|
|
}
|
|
|
|
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(t("backup.errors.localArchivePathMissing")); setRestorePreparing(false); return }
|
|
body.path = localArc.path
|
|
} else if (remoteArc) {
|
|
body.repo_name = remoteArc.repo_name
|
|
body.snapshot = remoteArc.snapshot
|
|
} else {
|
|
setRestoreError(t("backup.errors.snapshotInfoMissing"))
|
|
setRestorePreparing(false)
|
|
return
|
|
}
|
|
try {
|
|
const r: any = await fetchApi("/api/host-backups/restore/prepare", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
})
|
|
if (!r?.staging_path) throw new Error(t("backup.errors.backendNoStagingPath"))
|
|
const ck = r.cross_kernel || {}
|
|
const hyd = r.hydration || {}
|
|
setRestoreOptions({
|
|
stagingPath: r.staging_path,
|
|
pathsAvailable: Array.isArray(r.paths_available) ? r.paths_available : [],
|
|
rollbackPlan: r.rollback_plan,
|
|
crossKernel: ck.direction
|
|
? {
|
|
direction: ck.direction,
|
|
backupKernel: ck.backup_kernel || "",
|
|
targetKernel: ck.target_kernel || "",
|
|
blockedPaths: Array.isArray(ck.blocked_paths) ? ck.blocked_paths : [],
|
|
}
|
|
: undefined,
|
|
hydration: hyd.applies
|
|
? {
|
|
applies: true,
|
|
actions: Array.isArray(hyd.actions) ? hyd.actions : [],
|
|
}
|
|
: undefined,
|
|
})
|
|
} catch (e) {
|
|
setRestoreError(e instanceof Error ? e.message : String(e))
|
|
} finally {
|
|
setRestorePreparing(false)
|
|
}
|
|
}
|
|
const [deletingArchive, setDeletingArchive] = useState(false)
|
|
const [archiveDeleteResult, setArchiveDeleteResult] = useState<string[] | null>(null)
|
|
|
|
// 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.
|
|
const downloadLocalArchive = async () => {
|
|
if (!localArc) return
|
|
setDownloading(true)
|
|
setError(null)
|
|
try {
|
|
const ticket = await fetchTerminalTicket()
|
|
let url = getApiUrl(`/api/host-backups/archives/${encodeURIComponent(localArc.id)}/download`)
|
|
if (ticket) url += `?ticket=${encodeURIComponent(ticket)}`
|
|
const a = document.createElement("a")
|
|
a.href = url
|
|
a.download = localArc.id
|
|
document.body.appendChild(a)
|
|
a.click()
|
|
document.body.removeChild(a)
|
|
} catch (e) {
|
|
setError(t("backup.errors.downloadFailed", { error: e instanceof Error ? e.message : String(e) }))
|
|
} finally {
|
|
// The download itself runs in the browser's network stack;
|
|
// we just initiated it. Clear the spinner immediately.
|
|
setDownloading(false)
|
|
}
|
|
}
|
|
|
|
// Remote export-then-download (PBS or Borg) — kicks off the
|
|
// server-side extract + pack, polls until the .tar.zst is ready,
|
|
// then streams it like the local case. Stage feedback (queued /
|
|
// restoring / packing) goes into `exportTask.state` so the modal
|
|
// can show what's happening live.
|
|
const [exportTask, setExportTask] = useState<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: t("backup.archives.startingExport"),
|
|
size_bytes: 0,
|
|
output_path: null,
|
|
error: null,
|
|
})
|
|
try {
|
|
const started = await fetchApi<{ task_id: string; state: string }>(
|
|
"/api/host-backups/remote-archives/export",
|
|
{
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
backend: remoteArc.backend,
|
|
repo_name: remoteArc.repo_name,
|
|
snapshot: remoteArc.snapshot,
|
|
}),
|
|
},
|
|
)
|
|
// Poll until done. The export can take from seconds (small host
|
|
// config) to minutes (multi-GB pxar) — poll every 1.5s so the
|
|
// UI feels responsive without DoS-ing Flask.
|
|
let task: ExportTask | null = null
|
|
while (true) {
|
|
await new Promise((res) => setTimeout(res, 1500))
|
|
task = await fetchApi<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 || t("backup.errors.exportDidNotComplete"))
|
|
}
|
|
// 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, "_")
|
|
const a = document.createElement("a")
|
|
a.href = url
|
|
a.download = `${remoteArc.backend}-${remoteArc.repo_name}-${safeSnap}.tar.zst`
|
|
document.body.appendChild(a)
|
|
a.click()
|
|
document.body.removeChild(a)
|
|
setExportTask(null)
|
|
} catch (e) {
|
|
setError(t("backup.errors.downloadFailed", { error: e instanceof Error ? e.message : String(e) }))
|
|
} finally {
|
|
setDownloading(false)
|
|
}
|
|
}
|
|
|
|
const downloadArchive = () => {
|
|
if (isRemote) return downloadRemoteArchive()
|
|
return downloadLocalArchive()
|
|
}
|
|
|
|
// Archive deletion — works for local, PBS and Borg backends.
|
|
// • local: DELETE /archives/<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
|
|
setDeletingArchive(true)
|
|
setError(null)
|
|
try {
|
|
let removed: string[] = []
|
|
if (archive.source === "local" && localArc) {
|
|
const resp = await fetchApi<{ status: string; removed: string[] }>(
|
|
`/api/host-backups/archives/${encodeURIComponent(localArc.id)}`,
|
|
{ method: "DELETE" },
|
|
)
|
|
removed = resp.removed || []
|
|
} else if (remoteArc) {
|
|
const resp = await fetchApi<{ status: string; removed: string }>(
|
|
`/api/host-backups/remote-archives`,
|
|
{
|
|
method: "DELETE",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
backend: remoteArc.backend,
|
|
repo_name: remoteArc.repo_name,
|
|
snapshot: remoteArc.snapshot,
|
|
}),
|
|
},
|
|
)
|
|
removed = resp.removed ? [resp.removed] : []
|
|
}
|
|
setArchiveDeleteResult(removed)
|
|
setShowDeleteArchiveConfirm(false)
|
|
if (onDeleted) onDeleted(); else onClose()
|
|
} catch (e) {
|
|
setError(t("backup.errors.deleteFailed", { error: e instanceof Error ? e.message : String(e) }))
|
|
} finally {
|
|
setDeletingArchive(false)
|
|
}
|
|
}
|
|
|
|
// Reset instructions when archive or mode changes — the previous
|
|
// command is no longer relevant.
|
|
useEffect(() => {
|
|
setInstructions(null)
|
|
}, [archive, mode])
|
|
|
|
const fetchInstructions = async () => {
|
|
if (!localArc) return
|
|
setFetchingInstructions(true)
|
|
setError(null)
|
|
try {
|
|
const res = await fetchApi<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)
|
|
}
|
|
}
|
|
|
|
const { data: manifest, error: manifestErr } = useSWR<{
|
|
source_host: ManifestSourceHost
|
|
proxmenux_installed_components: Array<{ id: string; version_at_backup: string | null }>
|
|
vms_lxcs_at_backup: { vms: unknown[]; lxcs: unknown[] }
|
|
storage_inventory?: { zfs_pools?: unknown[]; lvm?: { vgs?: unknown[] } }
|
|
}>(
|
|
localArc ? `/api/host-backups/archives/${encodeURIComponent(localArc.id)}/manifest` : null,
|
|
fetcher,
|
|
)
|
|
|
|
const runPreflight = async () => {
|
|
if (!localArc) return
|
|
setRunning(true)
|
|
setError(null)
|
|
setReport(null)
|
|
try {
|
|
const res = await fetchApi<PreflightReport>(
|
|
`/api/host-backups/archives/${encodeURIComponent(localArc.id)}/preflight`,
|
|
{
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ mode }),
|
|
},
|
|
)
|
|
setReport(res)
|
|
} catch (e: any) {
|
|
setError(e?.message || t("backup.errors.preflightFailed"))
|
|
} finally {
|
|
setRunning(false)
|
|
}
|
|
}
|
|
|
|
// Reset state when archive changes — the key= prop on DialogContent
|
|
// forces React to unmount + remount so all useState() values are
|
|
// discarded automatically. Cleaner than manually tracking each.
|
|
const archiveKey = archive ? `${archive.source}:${archive.display_id}` : ""
|
|
|
|
return (
|
|
<>
|
|
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
|
<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">
|
|
<DialogTitle className="flex items-center gap-2 flex-wrap text-base pr-8">
|
|
<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>
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
{/* ── 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">
|
|
{/* Backup info — uniform grid, with backend-specific
|
|
rows mixed in only when they carry data. Backend +
|
|
encryption badges live here (instead of the header,
|
|
where they used to overlap the close button). */}
|
|
<section className="rounded-md border border-border bg-card p-3 space-y-1 text-xs">
|
|
<div className="flex items-center justify-between gap-2 flex-wrap">
|
|
<div className="text-[10px] uppercase tracking-wider text-muted-foreground">{t("backup.archives.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"
|
|
}`}>
|
|
{localizedBackendLabel(archive.source, t)}
|
|
</Badge>
|
|
)}
|
|
{remoteArc?.encrypted && (
|
|
<Badge
|
|
variant="outline"
|
|
className="text-emerald-400 border-emerald-500/40 bg-emerald-500/10"
|
|
title={t("backup.archives.encrypted")}
|
|
>
|
|
<Lock className="h-3.5 w-3.5" />
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-1">
|
|
{/* Time + size — present for every backend. */}
|
|
{archive && (archive.source === "pbs" || archive.source === "borg") && remoteArc ? (
|
|
<>
|
|
<div><span className="text-muted-foreground">{t("backup.archives.backupTimeLabel")}</span> {formatMtime(remoteArc.backup_time)}</div>
|
|
{remoteArc.size_bytes > 0 && <div><span className="text-muted-foreground">{t("backup.fields.sizeLabel")}</span> {formatBytes(remoteArc.size_bytes)}</div>}
|
|
<div><span className="text-muted-foreground">{t("backup.fields.repositoryLabel")}</span> <code className="font-mono break-all">{remoteArc.repo_repository}</code></div>
|
|
<div><span className="text-muted-foreground">{t("backup.fields.repoNameLabel")}</span> <code className="font-mono">{remoteArc.repo_name}</code></div>
|
|
<div>
|
|
<span className="text-muted-foreground">{remoteArc.backend === "pbs" ? t("backup.fields.backupGroupLabel") : t("backup.fields.archiveNameLabel")}</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">{t("backup.fields.ownerLabel")}</span> <code className="font-mono">{remoteArc.owner}</code></div>}
|
|
{remoteArc.borg_id && <div className="sm:col-span-2"><span className="text-muted-foreground">{t("backup.fields.borgIdLabel")}</span> <code className="font-mono text-[10px] break-all">{remoteArc.borg_id}</code></div>}
|
|
</>
|
|
) : localArc ? (
|
|
<>
|
|
<div><span className="text-muted-foreground">{t("backup.fields.createdLabel")}</span> {formatMtime(localArc.mtime)}</div>
|
|
<div><span className="text-muted-foreground">{t("backup.fields.sizeLabel")}</span> {formatBytes(localArc.size_bytes)}</div>
|
|
<div className="sm:col-span-2"><span className="text-muted-foreground">{t("backup.fields.pathLabel")}</span> <code className="font-mono break-all">{localArc.path}</code></div>
|
|
{localArc.job_id && <div><span className="text-muted-foreground">{t("backup.fields.jobIdLabel")}</span> <code className="font-mono">{localArc.job_id}</code></div>}
|
|
{localArc.profile && <div><span className="text-muted-foreground">{t("backup.fields.profileLabel")}</span> <code className="font-mono">{localArc.profile === "default" ? t("backup.profile.default") : localArc.profile === "custom" ? t("backup.profile.custom") : localArc.profile}</code></div>}
|
|
{localArc.source_hostname && <div><span className="text-muted-foreground">{t("backup.fields.sourceHostLabel")}</span> <code className="font-mono">{localArc.source_hostname}</code></div>}
|
|
<div><span className="text-muted-foreground">{t("backup.fields.detectedViaLabel")}</span> <code className="font-mono text-[10px]">{localArc.detected_via === "sidecar" ? t("backup.archives.companionFile") : localArc.detected_via}</code></div>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
{/* 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">
|
|
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">{t("backup.archives.filesInBackup")}</div>
|
|
<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>
|
|
<span className="text-muted-foreground">{formatBytes(f.size)}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
</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" /> {t("backup.logs.runLog")}
|
|
</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>{t("backup.logs.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" />
|
|
{t("backup.logs.openFull")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
{/* 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-card">
|
|
<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">{t(`backup.taskStates.${exportTask.state}`)}</span>
|
|
<span className="text-muted-foreground">— {exportTask.message}</span>
|
|
</div>
|
|
{exportTask.state === "failed" && exportTask.error && (
|
|
<div className="text-red-500 mt-1">{exportTask.error}</div>
|
|
)}
|
|
{exportTask.state === "completed" && exportTask.size_bytes > 0 && (
|
|
<div className="text-emerald-400">{t("backup.archives.packedSizeLabel")} {formatBytes(exportTask.size_bytes)}</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{error && (() => {
|
|
const kf = parseKeyfileError(error)
|
|
return kf ? (
|
|
<KeyfileErrorBlock err={kf} />
|
|
) : (
|
|
<div className="text-xs text-red-500 px-3 py-2 rounded-md border border-red-500/30 bg-red-500/10 whitespace-pre-wrap break-words">
|
|
{error}
|
|
</div>
|
|
)
|
|
})()}
|
|
|
|
{/* Encrypted-backup gate: no local keyfile → block the
|
|
three actions until the operator imports the key. The
|
|
import lands at the canonical path with escrow_mode=
|
|
'none', so subsequent runs reuse it silently. */}
|
|
{needsKeyfile && (
|
|
<section className="rounded-md border border-amber-500/40 bg-amber-500/5 p-3 space-y-2 text-xs">
|
|
<div className="flex items-start gap-2">
|
|
<AlertTriangle className="h-4 w-4 text-amber-400 mt-0.5 shrink-0" />
|
|
<div className="flex-1 space-y-1">
|
|
<div className="font-semibold text-amber-300">{t("backup.keyfileGate.title")}</div>
|
|
<div className="text-muted-foreground leading-relaxed">
|
|
{t("backup.keyfileGate.descriptionBefore")}
|
|
{" "}<code className="font-mono text-[10.5px]">/usr/local/share/proxmenux/pbs-key.conf</code>.
|
|
{" "}{t("backup.keyfileGate.descriptionAfter")}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{pveMatchInspect && (
|
|
<div className="rounded-md border border-emerald-500/40 bg-emerald-500/10 p-2.5 space-y-1.5 text-[11px]">
|
|
<div className="flex items-start gap-2">
|
|
<Lock className="h-3.5 w-3.5 text-emerald-400 mt-0.5 shrink-0" />
|
|
<div className="flex-1 space-y-1">
|
|
<div className="font-semibold text-emerald-300">{t("backup.keyfileActions.pveKeyDetected")}</div>
|
|
<div className="text-muted-foreground">
|
|
{t("backup.keyfileActions.pveKeyDescriptionBefore")} <code className="font-mono text-[10.5px]">{pveMatchInspect.name}</code> {t("backup.keyfileActions.pveKeyDescriptionMiddle")}{" "}
|
|
<code className="font-mono text-[10.5px] break-all">{pveMatchInspect.path}</code>. {t("backup.keyfileActions.pveKeyDescriptionAfter")}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex justify-end">
|
|
<Button
|
|
size="sm"
|
|
onClick={() => {
|
|
setImportPath(pveMatchInspect.path)
|
|
setImportFile(null)
|
|
}}
|
|
disabled={importing}
|
|
className="!bg-emerald-600 hover:!bg-emerald-700 !text-white h-6 text-[10.5px] px-2"
|
|
>
|
|
{t("backup.keyfileActions.useThisKey")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div className="space-y-1.5 pt-1 border-t border-amber-500/30">
|
|
<Label htmlFor="keyfileImport" className="text-[11px] font-medium">
|
|
{pveMatchInspect ? t("backup.keyfileActions.orUploadFromMachine") : t("backup.keyfileActions.uploadFromMachine")}
|
|
</Label>
|
|
<Input
|
|
id="keyfileImport"
|
|
type="file"
|
|
accept=".conf,.key,application/json,text/plain"
|
|
onChange={(e) => setImportFile(e.target.files?.[0] ?? null)}
|
|
disabled={importing || !!importPath.trim()}
|
|
className="h-8 text-[11px]"
|
|
/>
|
|
</div>
|
|
<div className="text-[10px] text-center text-muted-foreground">{t("backup.common.or")}</div>
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="keyfileImportPath" className="text-[11px] font-medium">{t("backup.fields.absolutePathOnHost")}</Label>
|
|
<Input
|
|
id="keyfileImportPath"
|
|
type="text"
|
|
value={importPath}
|
|
onChange={(e) => setImportPath(e.target.value)}
|
|
disabled={importing || !!importFile}
|
|
placeholder={t("backup.placeholders.keyfilePath")}
|
|
className="h-8 text-[11px] font-mono"
|
|
/>
|
|
</div>
|
|
{importError && (
|
|
<div className="text-red-500 px-2 py-1.5 rounded border border-red-500/30 bg-red-500/10 whitespace-pre-wrap break-words">
|
|
{importError}
|
|
</div>
|
|
)}
|
|
<div className="flex justify-end">
|
|
<Button
|
|
size="sm"
|
|
onClick={runImportKeyfile}
|
|
disabled={importing || (!importFile && !importPath.trim())}
|
|
className="bg-amber-500/20 border border-amber-500/40 text-amber-300 hover:bg-amber-500/30"
|
|
variant="outline"
|
|
>
|
|
{importing ? (
|
|
<Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" />
|
|
) : (
|
|
<Lock className="h-3.5 w-3.5 mr-1.5" />
|
|
)}
|
|
{t("backup.actions.importKeyfile")}
|
|
</Button>
|
|
</div>
|
|
</section>
|
|
)}
|
|
</div>
|
|
</ScrollArea>
|
|
|
|
{/* ── Footer: same 4 buttons for the 3 backends ─────────
|
|
Restore (green) · Download (blue) · View contents (blue)
|
|
on the left · Delete (red) on the right. On mobile only
|
|
Restore keeps its label — the others are icon-only to fit
|
|
the narrower viewport without wrapping. */}
|
|
<div className="px-4 sm:px-6 py-3 border-t border-border shrink-0 flex items-center justify-between gap-2">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<Button
|
|
onClick={beginRestore}
|
|
disabled={restorePreparing || needsKeyfile}
|
|
className="bg-green-600 hover:bg-green-700 text-white disabled:opacity-50"
|
|
title={needsKeyfile ? t("backup.archives.importKeyToRestoreTitle") : t("backup.archives.restoreTitle")}
|
|
>
|
|
{restorePreparing ? (
|
|
<Loader2 className="h-4 w-4 sm:mr-2 animate-spin" />
|
|
) : (
|
|
<DatabaseBackup className="h-4 w-4 sm:mr-2" />
|
|
)}
|
|
<span>{t("backup.actions.restore")}</span>
|
|
</Button>
|
|
<Button
|
|
onClick={downloadArchive}
|
|
disabled={downloading || needsKeyfile}
|
|
className="bg-blue-500/10 border border-blue-500/40 text-blue-400 hover:bg-blue-500/20 hover:text-blue-300 disabled:opacity-50"
|
|
variant="outline"
|
|
title={needsKeyfile ? t("backup.archives.importKeyToDownloadTitle") : t("backup.archives.downloadTitle")}
|
|
>
|
|
{downloading ? (
|
|
<Loader2 className="h-4 w-4 sm:mr-2 animate-spin" />
|
|
) : (
|
|
<Download className="h-4 w-4 sm:mr-2" />
|
|
)}
|
|
<span className="hidden sm:inline">{t("backup.actions.download")}</span>
|
|
</Button>
|
|
<Button
|
|
onClick={() => setViewingContents(true)}
|
|
disabled={needsKeyfile}
|
|
className="bg-blue-500/10 border border-blue-500/40 text-blue-400 hover:bg-blue-500/20 hover:text-blue-300 disabled:opacity-50"
|
|
variant="outline"
|
|
title={needsKeyfile ? t("backup.archives.importKeyToViewTitle") : t("backup.archives.viewContentsTitle")}
|
|
>
|
|
<Eye className="h-4 w-4 sm:mr-2" />
|
|
<span className="hidden sm:inline">{t("backup.actions.viewContents")}</span>
|
|
</Button>
|
|
</div>
|
|
<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"
|
|
? t("backup.archives.deleteLocalTitle")
|
|
: archive?.source === "pbs"
|
|
? t("backup.archives.deletePbsTitle")
|
|
: t("backup.archives.deleteBorgTitle")
|
|
}
|
|
>
|
|
{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">{t("backup.actions.delete")}</span>
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* ── 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}
|
|
/>
|
|
|
|
{/* ── Restore error toast (prepare failed) ─────────────────── */}
|
|
{restoreError && (() => {
|
|
const kf = parseKeyfileError(restoreError)
|
|
return (
|
|
<Dialog open={true} onOpenChange={() => setRestoreError(null)}>
|
|
<DialogContent className="max-w-md bg-card border-border">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-base flex items-center gap-2">
|
|
<AlertTriangle className="h-5 w-5 text-red-500" />
|
|
{kf ? t("backup.restore.blockedEncryptedTitle") : t("backup.restore.preparationFailedTitle")}
|
|
</DialogTitle>
|
|
{!kf && (
|
|
<DialogDescription className="text-xs text-red-400 break-all">
|
|
{restoreError}
|
|
</DialogDescription>
|
|
)}
|
|
</DialogHeader>
|
|
{kf && <KeyfileErrorBlock err={kf} />}
|
|
<div className="flex justify-end">
|
|
<Button variant="ghost" onClick={() => setRestoreError(null)}>{t("actions.close")}</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
})()}
|
|
|
|
{/* ── Restore options modal: opens only AFTER the prepare step,
|
|
so the Custom checklist already knows what's inside. ──── */}
|
|
<RestoreOptionsModal
|
|
open={restoreOptions !== null}
|
|
stagingPath={restoreOptions?.stagingPath ?? ""}
|
|
pathsAvailable={restoreOptions?.pathsAvailable ?? []}
|
|
rollbackPlan={restoreOptions?.rollbackPlan}
|
|
crossKernel={restoreOptions?.crossKernel}
|
|
hydration={restoreOptions?.hydration}
|
|
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)
|
|
}
|
|
}}
|
|
onLaunch={(mode, paths, rollbackExecute) => {
|
|
if (!restoreOptions) return
|
|
const sp = restoreOptions.stagingPath
|
|
setRestoreOptions(null)
|
|
setRestoreTerminal({ stagingPath: sp, mode, paths, rollbackExecute })
|
|
}}
|
|
/>
|
|
|
|
{/* ── Restore terminal — uses the canonical ScriptTerminalModal
|
|
(the same component Hardware/Security/Settings use to run
|
|
their scripts). Stable `key` per stagingPath so each restore
|
|
session is a fresh terminal. */}
|
|
{restoreTerminal && (
|
|
<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={t("backup.restore.terminalTitle", { mode: restoreTerminal.mode === "full" ? t("backup.restore.complete") : t("backup.restore.customByPaths") })}
|
|
description={
|
|
restoreTerminal.mode === "custom"
|
|
? t("backup.restore.pathsSelected", { count: restoreTerminal.paths.length })
|
|
: t("backup.restore.completeDescription")
|
|
}
|
|
params={{
|
|
EXECUTION_MODE: "web",
|
|
STAGING: restoreTerminal.stagingPath,
|
|
MODE: restoreTerminal.mode,
|
|
...(restoreTerminal.mode === "custom" && restoreTerminal.paths.length > 0
|
|
? { PATHS: restoreTerminal.paths.join(",") }
|
|
: {}),
|
|
...(restoreTerminal.rollbackExecute ? { ROLLBACK_EXECUTE: "1" } : {}),
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* ── Confirm archive deletion ───────────────────────────── */}
|
|
<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" />
|
|
{t("backup.archives.deleteBackendTitle", { backend: backendLabel })}
|
|
</DialogTitle>
|
|
<DialogDescription className="text-xs">
|
|
{archive?.source === "local"
|
|
? t("backup.archives.deleteLocalDescription")
|
|
: archive?.source === "pbs"
|
|
? t("backup.archives.deletePbsDescription", { repo: remoteArc?.repo_name ?? "" })
|
|
: t("backup.archives.deleteBorgDescription", { repo: remoteArc?.repo_name ?? "" })}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="text-sm font-mono px-3 py-2 rounded-md border border-border bg-card break-all">
|
|
{archive?.source === "local" ? localArc?.id : remoteArc?.snapshot}
|
|
</div>
|
|
<div className="flex justify-end gap-2">
|
|
<Button variant="ghost" onClick={() => setShowDeleteArchiveConfirm(false)} disabled={deletingArchive}>
|
|
{t("actions.cancel")}
|
|
</Button>
|
|
<Button variant="destructive" onClick={deleteArchive} disabled={deletingArchive}>
|
|
{deletingArchive ? (
|
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
) : (
|
|
<Trash2 className="h-4 w-4 mr-2" />
|
|
)}
|
|
{t("backup.actions.delete")}
|
|
</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" />
|
|
{t("backup.logs.runLog")}
|
|
</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)}>{t("actions.close")}</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
)
|
|
}
|
|
|
|
// ── Manifest summary panel ───────────────────────────────────
|
|
function ManifestSummary({
|
|
manifest,
|
|
}: {
|
|
manifest: {
|
|
source_host: ManifestSourceHost
|
|
proxmenux_installed_components: Array<{ id: string; version_at_backup: string | null }>
|
|
vms_lxcs_at_backup: { vms: unknown[]; lxcs: unknown[] }
|
|
storage_inventory?: { zfs_pools?: unknown[]; lvm?: { vgs?: unknown[] } }
|
|
}
|
|
}) {
|
|
const t = useT()
|
|
const sh = manifest.source_host
|
|
const zfsCount = manifest.storage_inventory?.zfs_pools?.length ?? 0
|
|
const lvmCount = manifest.storage_inventory?.lvm?.vgs?.length ?? 0
|
|
return (
|
|
<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={t("backup.fields.sourceHost")} value={sh.hostname} />
|
|
<Field label={t("backup.fields.pveVersion")} value={sh.pve_version || "—"} />
|
|
<Field label={t("backup.fields.roles")} value={sh.roles.join(", ")} />
|
|
<Field label={t("backup.fields.kernel")} value={sh.kernel} mono />
|
|
<Field label={t("backup.fields.bootMode")} value={sh.boot_mode} />
|
|
<Field label={t("backup.fields.memory")} value={`${Math.round(sh.memory_kb / 1024)} MB`} />
|
|
<Field label={t("backup.fields.zfsPools")} value={String(zfsCount)} />
|
|
<Field label={t("backup.fields.lvmVgs")} value={String(lvmCount)} />
|
|
<Field label={t("backup.fields.vmsLxcs")} 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">{t("backup.manifest.componentsAtBackup")}</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>
|
|
)
|
|
}
|
|
|
|
function Field({ icon, label, value, mono, labelClassName }: { icon?: React.ReactNode; label: string; value: string; mono?: boolean; labelClassName?: string }) {
|
|
return (
|
|
<div className="min-w-0">
|
|
<div className={`text-[10px] uppercase tracking-wider flex items-center gap-1 ${labelClassName ?? "text-muted-foreground"}`}>
|
|
{icon}
|
|
{label}
|
|
</div>
|
|
<div className={`${mono ? "font-mono break-all" : "break-words"}`} title={value}>
|
|
{value}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// 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 t = useT()
|
|
const order: Array<[string, string]> = [
|
|
["keep_last", "backup.retention.last"],
|
|
["keep_hourly", "backup.retention.hourly"],
|
|
["keep_daily", "backup.retention.daily"],
|
|
["keep_weekly", "backup.retention.weekly"],
|
|
["keep_monthly", "backup.retention.monthly"],
|
|
["keep_yearly", "backup.retention.yearly"],
|
|
]
|
|
const items = order
|
|
.map(([k, lbl]) => {
|
|
const v = (retention[k] || "").trim()
|
|
const n = Number.parseInt(v, 10)
|
|
if (!v || !Number.isFinite(n) || n <= 0) return null
|
|
return { label: lbl, value: n }
|
|
})
|
|
.filter((x): x is { label: string; value: number } => x !== null)
|
|
|
|
return (
|
|
<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" /> {t("backup.retention.title")}
|
|
</div>
|
|
{items.length === 0 ? (
|
|
<div className="text-muted-foreground italic text-xs mt-1">{t("backup.retention.noneAccumulate")}</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">{t(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[] }) {
|
|
const t = useT()
|
|
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" /> {t("backup.paths.title")}
|
|
<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 t = useT()
|
|
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" />
|
|
{t("backup.preflight.passCount", { count: summary.pass })}
|
|
</span>
|
|
<span className={`inline-flex items-center gap-1 ${warnColor}`}>
|
|
<AlertTriangle className="h-4 w-4" />
|
|
{t("backup.preflight.warnCount", { count: summary.warn })}
|
|
</span>
|
|
<span className={`inline-flex items-center gap-1 ${failColor}`}>
|
|
<XCircle className="h-4 w-4" />
|
|
{t("backup.preflight.failCount", { count: summary.fail })}
|
|
</span>
|
|
{summary.fail > 0 && (
|
|
<span className="ml-auto text-xs text-red-500">
|
|
{t("backup.preflight.applyWouldBeRefused")}
|
|
</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">{t("backup.preflight.storageInMode", { mode: String(report.storage.in_selected_mode) })}</div>
|
|
<div>
|
|
{t("backup.preflight.zfsPoolsCount", { count: report.storage.zfs.length })} ·
|
|
{" "}{t("backup.preflight.lvmVgsCount", { count: report.storage.lvm.length })} ·
|
|
{" "}{t("backup.preflight.pveStorageCount", { count: report.storage.pve_storage.length })}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-muted-foreground mb-1">{t("backup.preflight.networkInMode", { mode: String(report.network.in_selected_mode) })}</div>
|
|
<div>
|
|
{t("backup.preflight.keepCount", { count: report.network.keep.length })} ·
|
|
{" "}{t("backup.preflight.remapCount", { count: report.network.remap.length })} ·
|
|
{" "}{t("backup.preflight.orphanCount", { count: report.network.orphan.length })} ·
|
|
{" "}{t("backup.preflight.newCount", { count: report.network.new.length })}
|
|
</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">{t("backup.preflight.driverReinstallPlan", { count: 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>
|
|
)
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────
|
|
// Create job wizard (A3.1 — attached jobs only).
|
|
//
|
|
// Pre-loads three things in parallel so the form can render selects
|
|
// the moment the operator opens it:
|
|
// - PVE vzdump jobs (filtered by backend once they pick one)
|
|
// - Pre-configured destinations (PBS repos, Borg targets, local)
|
|
// - Default profile path list (used when profile_mode === "default"
|
|
// and also as the pool the custom checklist is populated from)
|
|
// ──────────────────────────────────────────────────────────────
|
|
|
|
interface PveVzdumpJob {
|
|
id: string
|
|
storage: string | null
|
|
storage_type: string | null
|
|
schedule: string | null
|
|
prune: string | null
|
|
enabled: boolean
|
|
}
|
|
|
|
// All Destination shapes carry `jobs_using`: list of job_ids that
|
|
// currently depend on the destination. Surfaced in the Delete-
|
|
// destination confirm flow so the operator sees the cascade impact
|
|
// before agreeing.
|
|
|
|
interface PbsRepo {
|
|
name: string
|
|
repository: string
|
|
fingerprint: string | null
|
|
source: "proxmox" | "manual"
|
|
jobs_using?: string[]
|
|
}
|
|
|
|
interface BorgRepo {
|
|
name: string
|
|
repository: string
|
|
ssh_key_path?: string
|
|
// Parsed from `ssh://user@host[:port]/path`; 22 for URLs without an
|
|
// explicit port and 0 for local-mode targets. Newer backends ship
|
|
// this; older ones don't and the frontend falls back to re-parsing
|
|
// the URL on the fly.
|
|
ssh_port?: number
|
|
// Encryption + saved-passphrase metadata. Newer backends ship these;
|
|
// older deployments without the fields default to "repokey" (the
|
|
// shell installer's historical default) and unknown-passphrase.
|
|
encrypt_mode?: "none" | "repokey" | "keyfile" | "authenticated"
|
|
has_passphrase?: boolean
|
|
jobs_using?: string[]
|
|
}
|
|
|
|
interface LocalTargetEntry {
|
|
path: string
|
|
source: "default" | "custom"
|
|
removable: boolean
|
|
jobs_using?: string[]
|
|
}
|
|
|
|
interface LocalTarget {
|
|
configured: string | null
|
|
default: string
|
|
effective: string
|
|
// Multi-path layout: the default plus any custom paths stacked on
|
|
// top. Present on every recent backend; older deployments without
|
|
// this field fall back to the single configured/effective accessors.
|
|
entries?: LocalTargetEntry[]
|
|
}
|
|
|
|
interface DestinationsResp {
|
|
pbs: PbsRepo[]
|
|
borg: BorgRepo[]
|
|
local: LocalTarget
|
|
}
|
|
|
|
interface JobDetail {
|
|
id: string
|
|
attached: boolean
|
|
enabled: boolean
|
|
manual: boolean
|
|
method: string
|
|
destination: string
|
|
on_calendar: string // human-formatted ("attached → storage:pbs" or "*-*-* 02:00")
|
|
next_run: string | null
|
|
pve_storage: string | null
|
|
pve_parent_job_id: string | null
|
|
profile_mode: string
|
|
paths: string[]
|
|
on_calendar_raw: string | null
|
|
retention: Record<string, string | undefined>
|
|
pbs_repository: string | null
|
|
pbs_backup_id: string | null
|
|
pbs_fingerprint: string | null
|
|
has_pbs_password: boolean
|
|
// Legacy — kept for backend responses that only track the on/off
|
|
// toggle. Newer builds also send pbs_encrypt_mode ("none" | "new"
|
|
// | "existing"); loadFromJobDetail prefers that when present.
|
|
pbs_encrypt: boolean
|
|
pbs_encrypt_mode?: "none" | "new" | "existing"
|
|
local_dest_dir: string | null
|
|
local_archive_ext: string | null
|
|
borg_repo: string | null
|
|
borg_encrypt_mode: string
|
|
has_borg_passphrase: boolean
|
|
// Log fields from Sprint Pulido P1
|
|
last_result: string | null // "ok" / "failed" / etc.
|
|
last_run_at: string | null // ISO-8601
|
|
last_log_path: string | null
|
|
last_log_size: number
|
|
last_log_tail: string[]
|
|
}
|
|
|
|
function CreateJobDialog({
|
|
open,
|
|
onClose,
|
|
onCreated,
|
|
editingJobId,
|
|
}: {
|
|
open: boolean
|
|
onClose: () => void
|
|
onCreated: () => void
|
|
editingJobId?: string | null
|
|
}) {
|
|
const { t, language } = useI18n()
|
|
const isEdit = !!editingJobId
|
|
const [step, setStep] = useState<1 | 2 | 3 | 4 | 5>(1)
|
|
const [jobId, setJobId] = useState("")
|
|
const [backend, setBackend] = useState<"pbs" | "local" | "borg">("pbs")
|
|
const [mode, setMode] = useState<"new" | "attach">("new")
|
|
const [pveJobId, setPveJobId] = useState<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>("")
|
|
// 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)
|
|
// Absolute-path alternative to the file upload — mirrors the shell
|
|
// wizard's `_hb_pbs_import_dialog` when there is no PVE .enc match.
|
|
const [pbsImportPath, setPbsImportPath] = useState<string>("")
|
|
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"
|
|
// 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>("")
|
|
// 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)
|
|
// Binary "upload key to PBS?" toggle. `true` maps to escrow_mode='full'
|
|
// (passphrase required, envelope uploaded on every backup); `false`
|
|
// maps to escrow_mode='none' (keyfile stays local, operator handles
|
|
// the offsite copy). Defaults to 'No' — the keyfile is always
|
|
// downloadable from the Monitor, so the operator can grab it and
|
|
// save it offsite themselves without leaving a passphrase-wrapped
|
|
// copy on the PBS server.
|
|
const [pbsUploadToPbs, setPbsUploadToPbs] = useState<boolean>(false)
|
|
// Whether the host already has an escrow blob configured. When
|
|
// present, the passphrase input becomes optional ("leave blank to
|
|
// keep saved"). Refreshed after a successful setup call.
|
|
const { data: pbsRecoveryStatus, mutate: mutatePbsRecovery } = useSWR<{
|
|
has_keyfile: boolean; has_recovery: boolean; has_keyfile_passphrase?: boolean; escrow_mode?: "none" | "local" | "full"
|
|
}>(
|
|
open && backend === "pbs" ? "/api/host-backups/pbs-recovery/status" : null,
|
|
fetcher,
|
|
)
|
|
// Auto-discover a PVE-managed keyfile for the currently selected
|
|
// PBS repository. When one is found, the "Use an existing keyfile"
|
|
// radio uses it silently (matches the shell wizard's PVE auto-detect
|
|
// path — the operator doesn't have to upload the same file twice).
|
|
const { data: pbsPveDiscover } = useSWR<{
|
|
entries: Array<{ name: string; server: string; datastore: string; path: string; matches_repository: boolean }>
|
|
}>(
|
|
open && backend === "pbs" && pbsRepository && !pbsRecoveryStatus?.has_keyfile
|
|
? `/api/host-backups/pbs-encryption/discover-pve-keyfiles?repository=${encodeURIComponent(pbsRepository)}`
|
|
: null,
|
|
fetcher,
|
|
)
|
|
const pbsPveMatch = pbsPveDiscover?.entries?.find((e) => e.matches_repository)
|
|
const [localDestDir, setLocalDestDir] = useState<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)
|
|
// Prefer the new pbs_encrypt_mode string when the backend sends it;
|
|
// fall back to the legacy bool for older `.env` layouts that only
|
|
// remember the on/off toggle.
|
|
const jd = jobDetail as unknown as { pbs_encrypt_mode?: string; pbs_encrypt?: boolean }
|
|
if (jd.pbs_encrypt_mode === "new" || jd.pbs_encrypt_mode === "existing" || jd.pbs_encrypt_mode === "none") {
|
|
setPbsEncryptMode(jd.pbs_encrypt_mode)
|
|
} else {
|
|
setPbsEncryptMode(jd.pbs_encrypt ? "new" : "none")
|
|
}
|
|
if (jobDetail.local_dest_dir) setLocalDestDir(jobDetail.local_dest_dir)
|
|
if (jobDetail.borg_repo) setBorgRepoSelected(jobDetail.borg_repo)
|
|
if (jobDetail.borg_encrypt_mode) {
|
|
setBorgEncryptMode(jobDetail.borg_encrypt_mode as "none" | "repokey" | "keyfile" | "authenticated")
|
|
}
|
|
setEditPreloaded(true)
|
|
}, [isEdit, jobDetail, editPreloaded])
|
|
|
|
// Reset the preload flag whenever the dialog closes so the next open
|
|
// (potentially for a different job) re-populates fresh.
|
|
useEffect(() => {
|
|
if (!open) {
|
|
setEditPreloaded(false)
|
|
setOriginalBackend(null)
|
|
setOriginalMode(null)
|
|
}
|
|
}, [open])
|
|
|
|
const { data: pveJobsResp } = useSWR<{ jobs: PveVzdumpJob[] }>(
|
|
open && mode === "attach" && backend !== "borg" ? `/api/host-backups/pve-vzdump-jobs?backend=${backend}` : null,
|
|
fetcher,
|
|
)
|
|
|
|
// Live calendar preview — only fetch when we're actually on Step 3 in
|
|
// "new" mode, otherwise the request is noise.
|
|
const calendarPreviewKey = open && mode === "new" && step === 3 && onCalendar.trim().length > 0
|
|
? `calendar-preview::${onCalendar}`
|
|
: null
|
|
const { data: calendarPreview } = useSWR<{
|
|
valid: boolean
|
|
error?: string
|
|
normalized?: string
|
|
next_elapse?: string
|
|
from_now?: string
|
|
}>(
|
|
calendarPreviewKey,
|
|
() =>
|
|
fetchApi("/api/host-backups/calendar-preview", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ expr: onCalendar }),
|
|
}),
|
|
{ dedupingInterval: 400 },
|
|
)
|
|
const { data: destResp, mutate: mutateDest } = useSWR<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("")
|
|
setPbsEncryptMode("none")
|
|
setPbsImportFile(null)
|
|
setPbsImportPath("")
|
|
setPbsImportBusy(false)
|
|
setPbsRecoveryPass("")
|
|
setPbsRecoveryPass2("")
|
|
setPbsRecoveryChange(false)
|
|
setPbsUploadToPbs(false)
|
|
setLocalDestDir("")
|
|
setBorgRepoSelected("")
|
|
setBorgPassphrase("")
|
|
setBorgEncryptMode("repokey")
|
|
setError(null)
|
|
setSubmitting(false)
|
|
}
|
|
}, [open])
|
|
|
|
// Borg can only be timer-based; coerce mode back to "new" if the
|
|
// operator picks borg after having selected attach.
|
|
useEffect(() => {
|
|
if (backend === "borg" && mode === "attach") {
|
|
setMode("new")
|
|
}
|
|
}, [backend, mode])
|
|
|
|
useEffect(() => {
|
|
setPveJobId("")
|
|
}, [backend, mode])
|
|
|
|
useEffect(() => {
|
|
if (backend === "pbs" && !pbsRepository && destResp?.pbs?.length) {
|
|
setPbsRepository(destResp.pbs[0].repository)
|
|
}
|
|
if (backend === "borg" && !borgRepoSelected && destResp?.borg?.length) {
|
|
setBorgRepoSelected(destResp.borg[0].repository)
|
|
}
|
|
}, [backend, destResp, pbsRepository, borgRepoSelected])
|
|
|
|
useEffect(() => {
|
|
if (!pendingAutoSelectDest || !destResp) return
|
|
if (pendingAutoSelectDest.kind === "pbs") {
|
|
const hit = destResp.pbs?.find((r) => r.repository === pendingAutoSelectDest.repository)
|
|
if (hit) {
|
|
setPbsRepository(hit.repository)
|
|
setPendingAutoSelectDest(null)
|
|
}
|
|
} else {
|
|
const hit = destResp.borg?.find((r) => r.repository === pendingAutoSelectDest.repository)
|
|
if (hit) {
|
|
setBorgRepoSelected(hit.repository)
|
|
setPendingAutoSelectDest(null)
|
|
}
|
|
}
|
|
}, [destResp, pendingAutoSelectDest])
|
|
|
|
const togglePath = (path: string) => {
|
|
setCustomPaths((prev) => {
|
|
const next = new Set(prev)
|
|
if (next.has(path)) next.delete(path)
|
|
else next.add(path)
|
|
return next
|
|
})
|
|
}
|
|
|
|
const selectedPveJob: PveVzdumpJob | undefined = pveJobsResp?.jobs?.find((j) => j.id === pveJobId)
|
|
const selectedBorgRepo: BorgRepo | undefined = destResp?.borg?.find((r) => r.repository === borgRepoSelected)
|
|
|
|
// ── Step validation gates ──
|
|
const idValid = /^[a-zA-Z0-9_-]+$/.test(jobId) && jobId.length > 0
|
|
const canAdvanceFrom1 = idValid
|
|
const canAdvanceFrom2 = true // selecting New/Attach is always possible
|
|
|
|
// Step 3 depends on mode. For "new" we additionally block advancing
|
|
// if the live calendar preview already came back invalid, or if
|
|
// weekly was picked without any day selected.
|
|
const canAdvanceFrom3 =
|
|
mode === "attach"
|
|
? !!pveJobId
|
|
: onCalendar.trim().length > 0
|
|
&& (calendarPreview === undefined || calendarPreview.valid !== false)
|
|
&& (scheduleType !== "weekly" || scheduleWeekdays.size > 0)
|
|
|
|
// Step 4: profile
|
|
const canAdvanceFrom4 =
|
|
profileMode === "default" || (profileMode === "custom" && customPaths.size > 0)
|
|
|
|
// Step 5 (submit) — backend-specific requirements
|
|
const backendValid =
|
|
backend === "pbs"
|
|
? !!pbsRepository
|
|
: backend === "local"
|
|
? true
|
|
: /* borg */ !!borgRepoSelected
|
|
|
|
// PBS encryption gate — choosing "Encrypt" makes the recovery
|
|
// passphrase mandatory. The button stays disabled until a matching
|
|
// passphrase pair is entered (or an already-saved escrow is reused).
|
|
// There is no opt-out: encrypting without recovery would leave the
|
|
// keyfile only on this host, and a reinstall would render every
|
|
// encrypted backup unrecoverable.
|
|
// Passphrase requirement only kicks in when: encryption is on AND
|
|
// there's no keyfile installed yet (fresh setup) AND the operator
|
|
// opted to upload the key to PBS. Every other case (reuse existing
|
|
// keyfile, or "no upload" mode) doesn't need a passphrase.
|
|
const pbsRecoveryOk = !(backend === "pbs" && pbsEncrypt) ||
|
|
pbsRecoveryStatus?.has_keyfile ||
|
|
!pbsUploadToPbs ||
|
|
(pbsRecoveryPass !== "" && pbsRecoveryPass === pbsRecoveryPass2)
|
|
|
|
const canSubmit =
|
|
canAdvanceFrom1 && canAdvanceFrom2 && canAdvanceFrom3 && canAdvanceFrom4 && backendValid && pbsRecoveryOk
|
|
|
|
async function handleCreate() {
|
|
if (!canSubmit) return
|
|
if (mode === "attach" && !selectedPveJob) return
|
|
setSubmitting(true)
|
|
setError(null)
|
|
// Encryption submit paths:
|
|
// • enabled + keyfile already on disk → mode = "existing", no upload
|
|
// (backend reuses the canonical file at _PBS_KEYFILE_PATH).
|
|
// • enabled + no keyfile + Generate → mode = "new", backend creates it.
|
|
// • enabled + no keyfile + Import → upload the file first, then
|
|
// mode = "existing" so the backend uses the freshly-installed key.
|
|
//
|
|
// Only the third branch actually needs to POST to
|
|
// /api/host-backups/pbs-encryption/import — the second is handled
|
|
// server-side, and the first has nothing to upload.
|
|
// Unified encryption setup: one atomic call to /pbs-encryption/import
|
|
// covers keyfile install + escrow setup, whether the operator picked
|
|
// "generate a new keyfile" or "import an existing one", and whether
|
|
// they opted to upload the envelope to PBS or keep the key local.
|
|
const needsInstall =
|
|
backend === "pbs" &&
|
|
pbsEncrypt &&
|
|
!pbsRecoveryStatus?.has_keyfile
|
|
if (needsInstall) {
|
|
// Existing + no PVE auto-detect requires either a file upload
|
|
// or a typed absolute path — mirrors the shell wizard.
|
|
// Existing + PVE auto-detect uses source=pve-storage.
|
|
if (pbsEncryptMode === "existing" && !pbsPveMatch && !pbsImportFile && !pbsImportPath.trim()) {
|
|
setError(t("backup.errors.pickKeyfile"))
|
|
setSubmitting(false)
|
|
return
|
|
}
|
|
if (pbsUploadToPbs && pbsRecoveryPass !== pbsRecoveryPass2) {
|
|
setError(t("backup.errors.passphrasesDoNotMatch"))
|
|
setSubmitting(false)
|
|
return
|
|
}
|
|
setPbsImportBusy(true)
|
|
try {
|
|
// Source picker mirrors the shell wizard's dispatch:
|
|
// generate → new keyfile via proxmox-backup-client
|
|
// pve-storage → reuse /etc/pve/priv/storage/<name>.enc
|
|
// content → upload a file the operator picked
|
|
// path → read a file at an absolute path
|
|
let source: "generate" | "pve-storage" | "content" | "path"
|
|
if (pbsEncryptMode === "new") source = "generate"
|
|
else if (pbsPveMatch) source = "pve-storage"
|
|
else if (pbsImportFile) source = "content"
|
|
else source = "path"
|
|
const body: Record<string, unknown> = {
|
|
source,
|
|
escrow_mode: pbsUploadToPbs ? "full" : "none",
|
|
}
|
|
if (pbsUploadToPbs) body.escrow_passphrase = pbsRecoveryPass
|
|
if (source === "pve-storage") {
|
|
body.pve_storage_name = pbsPveMatch!.name
|
|
} else if (source === "content") {
|
|
const buf = new Uint8Array(await pbsImportFile!.arrayBuffer())
|
|
let bin = ""
|
|
for (let i = 0; i < buf.length; i++) bin += String.fromCharCode(buf[i])
|
|
body.content_b64 = btoa(bin)
|
|
} else if (source === "path") {
|
|
body.keyfile_path = pbsImportPath.trim()
|
|
}
|
|
await fetchApi("/api/host-backups/pbs-encryption/import", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
})
|
|
mutatePbsRecovery()
|
|
} catch (e) {
|
|
const err = e as Error & { body?: { tool_output?: string; tool_exit_code?: number } }
|
|
const detail = err.body?.tool_output
|
|
? `${err.message}\n\n${t("backup.errors.proxmoxBackupClientOutput")}:\n${err.body.tool_output}`
|
|
: err.message
|
|
setError(t("backup.errors.encryptionSetupFailed", { detail: detail || String(e) }))
|
|
setPbsImportBusy(false)
|
|
setSubmitting(false)
|
|
return
|
|
}
|
|
setPbsImportBusy(false)
|
|
}
|
|
// NOTE: recovery escrow is now bundled into the /pbs-encryption/import
|
|
// call above. No separate /pbs-recovery/setup pass is needed.
|
|
try {
|
|
const body: Record<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
|
|
body.pbs_encrypt_mode = pbsEncryptMode
|
|
} else if (backend === "local") {
|
|
if (localDestDir.trim()) body.local_dest_dir = localDestDir.trim()
|
|
} else if (backend === "borg") {
|
|
// Passphrase + encrypt_mode are inherited from the destination
|
|
// (the runner reads borg-pass-<name>.txt + the 4th field of
|
|
// borg-targets.txt). Only send overrides when the operator
|
|
// really typed a new one in an edit flow.
|
|
body.borg_repo = borgRepoSelected
|
|
if (isEdit && borgPassphrase) body.borg_passphrase = borgPassphrase
|
|
}
|
|
// fetchApi returns the parsed JSON and throws (with the backend's
|
|
// error message in `error.message` when the body was JSON) on any
|
|
// non-2xx status — no need to inspect .ok or call .json() ourselves.
|
|
const url = isEdit
|
|
? `/api/host-backups/jobs/${encodeURIComponent(editingJobId || "")}`
|
|
: "/api/host-backups/jobs"
|
|
await fetchApi(url, {
|
|
method: isEdit ? "PUT" : "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
})
|
|
onCreated()
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : String(e))
|
|
} finally {
|
|
setSubmitting(false)
|
|
}
|
|
}
|
|
|
|
const compatibleJobs = pveJobsResp?.jobs ?? []
|
|
const defaultPaths = pathsResp?.paths ?? []
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
|
<DialogContent className="max-w-2xl max-h-[85vh] flex flex-col bg-accent [&_input]:bg-background [&_textarea]:bg-background [&_[role=combobox]]:bg-background">
|
|
<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 ? t("backup.jobs.editScheduledJob") : t("backup.jobs.createScheduledJob")}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{t("backup.jobs.stepOf", { step, total: 5 })} · {mode === "attach" ? t("backup.jobs.attachedToPveVzdump") : t("backup.jobs.standaloneScheduledJob")}
|
|
</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">{t("backup.fields.jobName")}</Label>
|
|
<Input
|
|
id="jobId"
|
|
value={jobId}
|
|
onChange={(e) => setJobId(e.target.value)}
|
|
disabled={isEdit}
|
|
className="font-mono mt-1"
|
|
placeholder="moja-zaloha-servera"
|
|
/>
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
{isEdit
|
|
? t("backup.jobs.jobNameLocked")
|
|
: <>{t("backup.jobs.jobNameHelpBefore")} <code className="font-mono">_</code> {t("backup.jobs.jobNameHelpAnd")} <code className="font-mono">-</code>. {t("backup.jobs.jobNameHelpAfter")}</>}
|
|
</p>
|
|
{!idValid && jobId.length > 0 && !isEdit && (
|
|
<p className="text-xs text-red-500 mt-1">{t("backup.jobs.invalidJobName")}</p>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<Label>{t("backup.fields.backend")}</Label>
|
|
{isEdit && (
|
|
<p className="text-xs text-muted-foreground mb-2">
|
|
{t("backup.jobs.backendEditHelp")}
|
|
</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"
|
|
? t("backup.backends.pbsDescription")
|
|
: b === "local"
|
|
? t("backup.backends.localDescription")
|
|
: t("backup.backends.borgDescriptionTimerOnly")
|
|
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-card"
|
|
} hover:bg-white/5`}
|
|
>
|
|
<div className="flex items-center gap-2 font-medium text-sm">
|
|
<Icon className="h-4 w-4" />
|
|
{b === "pbs" ? "PBS" : b === "borg" ? "Borg" : t("backup.backends.local")}
|
|
</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>{t("backup.schedule.howToSchedule")}</Label>
|
|
<p className="text-xs text-muted-foreground mb-2">
|
|
{backend === "borg"
|
|
? t("backup.schedule.borgTimerOnly")
|
|
: t("backup.schedule.modeHelp")}
|
|
</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-card"} hover:bg-white/5`}
|
|
>
|
|
<div className="text-sm font-medium flex items-center gap-2">
|
|
<Calendar className="h-4 w-4" />
|
|
{t("backup.jobs.newScheduledJob")}
|
|
</div>
|
|
<div className="text-xs text-muted-foreground mt-1">
|
|
{t("backup.jobs.newScheduledJobDescription")}
|
|
</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-card"
|
|
} ${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" />
|
|
{t("backup.jobs.attachToPveJob")}
|
|
{backend === "borg" && (
|
|
<Badge variant="outline" className="text-[10px] text-muted-foreground">
|
|
{t("backup.jobs.notAvailableForBorg")}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<div className="text-xs text-muted-foreground mt-1">
|
|
{t("backup.jobs.attachDescriptionBefore")} <code className="font-mono">job-end</code> {t("backup.jobs.attachDescriptionMiddle")} <code className="font-mono">{backend === "pbs" ? "PBS" : backend === "borg" ? "Borg" : t("backup.backends.local")}</code> {t("backup.jobs.attachDescriptionAfter")}
|
|
</div>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Step 3: branches by mode ─────────────────── */}
|
|
{step === 3 && mode === "attach" && (
|
|
<div className="space-y-3">
|
|
<div>
|
|
<Label>{t("backup.jobs.pickParentPveJob")}</Label>
|
|
<p className="text-xs text-muted-foreground mb-2">
|
|
{t("backup.jobs.parentPveJobHelpBefore")} <code className="font-mono">job-end</code> {t("backup.jobs.parentPveJobHelpAfter")}
|
|
</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" />
|
|
{t("backup.jobs.noCompatiblePveJob")}
|
|
</div>
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
{t("backup.jobs.noCompatiblePveJobDescriptionBefore")} <span className="font-mono">{backend === "pbs" ? "PBS" : backend === "borg" ? "Borg" : t("backup.backends.local")}</span> {t("backup.jobs.noCompatiblePveJobDescriptionMiddle")} <span className="font-medium">Datacenter → Backup</span> {t("backup.jobs.noCompatiblePveJobDescriptionAfter")}
|
|
</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-card"} 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">
|
|
{t("status.disabled")}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<div className="mt-1 text-xs text-muted-foreground flex items-center gap-x-3 gap-y-1 flex-wrap">
|
|
<span>{t("backup.fields.storageLabel")} <code className="font-mono">{j.storage}</code></span>
|
|
<span>{t("backup.fields.scheduleLabel")} <code className="font-mono">{j.schedule || "—"}</code></span>
|
|
<span>{t("backup.fields.retentionLabel")} <code className="font-mono">{j.prune || "—"}</code></span>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{step === 3 && mode === "new" && (
|
|
<div className="space-y-4">
|
|
<div>
|
|
<Label>{t("backup.fields.schedule")}</Label>
|
|
<p className="text-xs text-muted-foreground mb-2">
|
|
{t("backup.schedule.pickFrequency")}
|
|
</p>
|
|
<Select
|
|
value={scheduleType}
|
|
onValueChange={(v) => setScheduleType(v as typeof scheduleType)}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="daily">{t("backup.schedule.dailyOption")}</SelectItem>
|
|
<SelectItem value="hourly">{t("backup.schedule.hourlyOption")}</SelectItem>
|
|
<SelectItem value="weekly">{t("backup.schedule.weeklyOption")}</SelectItem>
|
|
<SelectItem value="monthly">{t("backup.schedule.monthlyOption")}</SelectItem>
|
|
<SelectItem value="advanced">{t("backup.schedule.advancedOption")}</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{scheduleType === "daily" && (
|
|
<div>
|
|
<Label htmlFor="schedTime">{t("backup.fields.timeOfDay")}</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">{t("backup.fields.minuteOfHour")}</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">
|
|
{t("backup.schedule.hourlyHelpBefore")} <code className="font-mono">0</code> {t("backup.schedule.hourlyHelpMiddle")} <code className="font-mono">30</code> {t("backup.schedule.hourlyHelpAfter")}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{scheduleType === "weekly" && (
|
|
<div className="space-y-3">
|
|
<div>
|
|
<Label>{t("backup.fields.daysOfWeek")}</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-card text-muted-foreground hover:bg-white/5"
|
|
}`}
|
|
>
|
|
{t(`backup.weekdays.short.${d.toLowerCase()}`)}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
{scheduleWeekdays.size === 0 && (
|
|
<p className="text-xs text-amber-500 mt-1">{t("backup.schedule.pickAtLeastOneDay")}</p>
|
|
)}
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="schedTimeW">{t("backup.fields.timeOfDay")}</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">{t("backup.fields.dayOfMonth")}</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">
|
|
{t("backup.schedule.monthlySkipHelp")}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="schedTimeM">{t("backup.fields.timeOfDay")}</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">{t("backup.fields.onCalendarExpression")}</Label>
|
|
<Input
|
|
id="schedAdv"
|
|
value={scheduleAdvanced}
|
|
onChange={(e) => setScheduleAdvanced(e.target.value)}
|
|
className="font-mono mt-1"
|
|
placeholder={t("backup.placeholders.onCalendar")}
|
|
/>
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
{t("backup.schedule.advancedHelpBefore")} <code className="font-mono">systemd-analyze calendar</code>. {t("backup.schedule.advancedHelpMiddle")} <span className="font-mono">man systemd.time</span> {t("backup.schedule.advancedHelpAfter")}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Live preview from the backend */}
|
|
<div className="rounded-md border border-border bg-card p-3 space-y-1 text-xs">
|
|
<div className="text-[10px] uppercase tracking-wider text-muted-foreground">{t("backup.schedule.preview")}</div>
|
|
<div className="flex items-baseline gap-2">
|
|
<span className="text-muted-foreground">{t("backup.fields.expressionLabel")}</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">{t("backup.fields.normalizedLabel")}</span>
|
|
<code className="font-mono">{calendarPreview.normalized}</code>
|
|
</div>
|
|
)}
|
|
{calendarPreview.next_elapse && (
|
|
<div className="flex items-baseline gap-2">
|
|
<span className="text-muted-foreground">{t("backup.jobs.nextRunLabel")}</span>
|
|
<span className="text-emerald-400">{formatCalendarPreview(calendarPreview.next_elapse, language, t)}</span>
|
|
{calendarPreview.from_now && (
|
|
<span className="text-muted-foreground">({formatCalendarDistance(calendarPreview.from_now, language)})</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</>
|
|
) : (
|
|
<div className="text-red-500">
|
|
<span className="font-medium">{t("backup.validation.invalidLabel")}</span> {calendarPreview.error}
|
|
</div>
|
|
)
|
|
) : (
|
|
<div className="text-muted-foreground italic">{t("backup.common.checking")}</div>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<Label>{t("backup.retention.title")}</Label>
|
|
<p className="text-xs text-muted-foreground mb-2">{t("backup.retention.zeroDisables")}</p>
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
|
{[
|
|
{ id: "keep-last", lbl: t("backup.retention.keepLast"), v: keepLast, set: setKeepLast },
|
|
{ id: "keep-hourly", lbl: t("backup.retention.keepHourly"), v: keepHourly, set: setKeepHourly },
|
|
{ id: "keep-daily", lbl: t("backup.retention.keepDaily"), v: keepDaily, set: setKeepDaily },
|
|
{ id: "keep-weekly", lbl: t("backup.retention.keepWeekly"), v: keepWeekly, set: setKeepWeekly },
|
|
{ id: "keep-monthly", lbl: t("backup.retention.keepMonthly"), v: keepMonthly, set: setKeepMonthly },
|
|
{ id: "keep-yearly", lbl: t("backup.retention.keepYearly"), v: keepYearly, set: setKeepYearly },
|
|
].map((row) => (
|
|
<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>{t("backup.profile.title")}</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-card"} hover:bg-white/5 transition-colors`}
|
|
>
|
|
<div className="text-sm font-medium">{t("backup.profile.default")}</div>
|
|
<div className="text-xs text-muted-foreground mt-1">
|
|
{t("backup.profile.defaultDescription", { count: defaultPaths.length })}
|
|
</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-card"} hover:bg-white/5 transition-colors`}
|
|
>
|
|
<div className="text-sm font-medium">{t("backup.profile.custom")}</div>
|
|
<div className="text-xs text-muted-foreground mt-1">
|
|
{t("backup.profile.customDescription")}
|
|
</div>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{profileMode === "custom" && (
|
|
<div>
|
|
<Label>{t("backup.profile.pathsToInclude", { selected: customPaths.size, total: 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>{t("backup.fields.pbsRepository")}</Label>
|
|
{destResp?.pbs?.length ? (
|
|
<div className="mt-1 flex items-center gap-2">
|
|
<Select value={pbsRepository} onValueChange={setPbsRepository}>
|
|
<SelectTrigger className="flex-1 min-w-0">
|
|
{/* Manual render: SelectValue's default mode
|
|
duplicates the item children and cuts
|
|
them in the middle on mobile. We render
|
|
"name — repository" ourselves with a
|
|
proper trailing ellipsis. */}
|
|
{(() => {
|
|
const sel = destResp.pbs.find((r) => r.repository === pbsRepository)
|
|
if (!sel) {
|
|
return <span className="truncate text-muted-foreground">{t("backup.placeholders.pickPbsRepository")}</span>
|
|
}
|
|
return (
|
|
<span className="truncate font-mono text-left">
|
|
{sel.name}
|
|
<span className="text-muted-foreground"> — {sel.repository}</span>
|
|
</span>
|
|
)
|
|
})()}
|
|
</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={t("backup.destinations.saveAnotherPbsTitle")}
|
|
>
|
|
<Plus className="h-3.5 w-3.5 mr-1" />
|
|
{t("backup.actions.addAnother")}
|
|
</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>{t("backup.destinations.noPbsInWizard")}</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" />
|
|
{t("backup.destinations.addPbsRepository")}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="pbsBackupId">{t("backup.fields.groupNameInPbs")}</Label>
|
|
<Input
|
|
id="pbsBackupId"
|
|
value={pbsBackupId}
|
|
onChange={(e) => setPbsBackupId(e.target.value)}
|
|
placeholder={t("backup.placeholders.leaveBlankForDefault")}
|
|
className="font-mono mt-1"
|
|
/>
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
{t("backup.jobs.pbsGroupHelp")}
|
|
</p>
|
|
</div>
|
|
{/* PBS client-side encryption — mirrors the shell wizard:
|
|
step 1 is a plain yes/no, step 2 only appears when
|
|
no keyfile is installed yet. Once a keyfile exists,
|
|
every future encrypted job silently reuses it. */}
|
|
<div className="pt-2 border-t border-border space-y-3">
|
|
<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" />
|
|
{t("backup.encryption.encryptBackups")}
|
|
</div>
|
|
<p className="text-[11px] text-muted-foreground mt-1">
|
|
{t("backup.encryption.pbsHelpBefore")} <code className="font-mono">--keyfile</code> {t("backup.encryption.pbsHelpMiddle")} <code className="font-mono">proxmox-backup-client backup</code>. {t("backup.encryption.pbsHelpAfter")} <code className="font-mono">/usr/local/share/proxmenux/pbs-key.conf</code> (chmod 0600) {t("backup.encryption.pbsHelpEnd")}
|
|
</p>
|
|
</div>
|
|
</label>
|
|
|
|
|
|
{pbsEncrypt && !pbsRecoveryStatus?.has_keyfile && (
|
|
<div className="space-y-2 rounded-md border border-emerald-500/30 bg-emerald-500/5 p-3">
|
|
<div className="text-[11px] font-medium">
|
|
{t("backup.encryption.noKeyStored")}
|
|
</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">{t("backup.encryption.generateNewKeyfile")}</div>
|
|
<div className="text-muted-foreground">{t("backup.encryption.generateNewKeyfileDescriptionBefore")} <code className="font-mono">/usr/local/share/proxmenux/pbs-key.conf</code>. {t("backup.encryption.generateNewKeyfileDescriptionAfter")}</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">{t("backup.encryption.importExistingKeyfile")}</div>
|
|
<div className="text-muted-foreground">{t("backup.encryption.importExistingKeyfileDescription")}</div>
|
|
</div>
|
|
</label>
|
|
{pbsEncryptMode === "existing" && pbsPveMatch && (
|
|
<div className="pt-1 rounded-md border border-emerald-500/40 bg-emerald-500/5 p-2.5 text-[11px] space-y-1">
|
|
<div className="flex items-start gap-1.5">
|
|
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-400 mt-0.5 shrink-0" />
|
|
<div className="flex-1">
|
|
<div className="font-medium text-foreground">{t("backup.encryption.autoDetectedFromPveStorage", { storage: pbsPveMatch.name })}</div>
|
|
<div className="text-muted-foreground mt-0.5">
|
|
{t("backup.encryption.existingKeyAt")} <code className="font-mono text-[10.5px]">{pbsPveMatch.path}</code>
|
|
</div>
|
|
<div className="text-muted-foreground mt-0.5">{t("backup.encryption.pveKeyWillBeCopied")}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{pbsEncryptMode === "existing" && !pbsPveMatch && (
|
|
<div className="space-y-2 pt-1">
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="pbsImportFile" className="text-[11px] font-medium">
|
|
{t("backup.keyfileActions.uploadFromMachine")}
|
|
</Label>
|
|
<Input
|
|
id="pbsImportFile"
|
|
type="file"
|
|
accept=".conf,.key,application/json,text/plain"
|
|
onChange={(e) => setPbsImportFile(e.target.files?.[0] ?? null)}
|
|
disabled={pbsImportBusy || !!pbsImportPath.trim()}
|
|
className="h-8 text-[11px]"
|
|
/>
|
|
</div>
|
|
<div className="text-[10px] text-center text-muted-foreground">{t("backup.common.or")}</div>
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="pbsImportPath" className="text-[11px] font-medium">
|
|
{t("backup.fields.absolutePathOnHost")}
|
|
</Label>
|
|
<Input
|
|
id="pbsImportPath"
|
|
type="text"
|
|
value={pbsImportPath}
|
|
onChange={(e) => setPbsImportPath(e.target.value)}
|
|
disabled={pbsImportBusy || !!pbsImportFile}
|
|
placeholder={t("backup.placeholders.keyfilePath")}
|
|
className="h-8 text-[11px] font-mono"
|
|
/>
|
|
<p className="text-[10px] text-muted-foreground">
|
|
{t("backup.encryption.importKeyfileHelpBefore")} <code className="font-mono">/usr/local/share/proxmenux/pbs-key.conf</code> {t("backup.encryption.importKeyfileHelpAfter")}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{pbsEncrypt && (
|
|
<div className="pl-7 space-y-3 rounded-md border border-blue-500/30 bg-blue-500/5 p-3">
|
|
{pbsRecoveryStatus?.has_keyfile && !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">{t("backup.keyfileManagement.keyfileInstalled")}</div>
|
|
<div className="text-muted-foreground mt-0.5">{t("backup.keyfileManagement.currentPbsUploadStays")}</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setPbsRecoveryChange(true)}
|
|
className="text-blue-400 hover:text-blue-300 underline underline-offset-2 shrink-0"
|
|
>
|
|
{t("backup.actions.change")}
|
|
</button>
|
|
</div>
|
|
)}
|
|
{pbsRecoveryStatus?.has_keyfile && pbsRecoveryChange && (
|
|
<>
|
|
<KeyfileActionsBar
|
|
mutateStatus={mutatePbsRecovery}
|
|
escrowMode={pbsRecoveryStatus?.escrow_mode}
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => setPbsRecoveryChange(false)}
|
|
className="text-[11px] text-muted-foreground hover:text-foreground underline underline-offset-2"
|
|
>
|
|
{t("backup.keyfileManagement.hideOptions")}
|
|
</button>
|
|
</>
|
|
)}
|
|
{!pbsRecoveryStatus?.has_keyfile && (
|
|
<>
|
|
<div>
|
|
<div className="text-[11px] font-medium text-foreground mb-2">{t("backup.keyfileManagement.uploadToPbs")}</div>
|
|
<div className="grid gap-1.5">
|
|
<label className="flex items-start gap-2 cursor-pointer text-[11px]">
|
|
<input
|
|
type="radio"
|
|
name="pbsUploadToPbsCreate"
|
|
checked={!pbsUploadToPbs}
|
|
onChange={() => setPbsUploadToPbs(false)}
|
|
className="mt-1"
|
|
/>
|
|
<div className="flex-1">
|
|
<div className="font-medium">{t("backup.keyfileManagement.keepLocalOnly")}</div>
|
|
<div className="text-muted-foreground">{t("backup.keyfileManagement.keepLocalDescriptionBefore")} <code className="font-mono text-[10.5px]">/usr/local/share/proxmenux/pbs-key.conf</code>. {t("backup.keyfileManagement.keepLocalDescriptionAfter")}</div>
|
|
</div>
|
|
</label>
|
|
<label className="flex items-start gap-2 cursor-pointer text-[11px]">
|
|
<input
|
|
type="radio"
|
|
name="pbsUploadToPbsCreate"
|
|
checked={pbsUploadToPbs}
|
|
onChange={() => setPbsUploadToPbs(true)}
|
|
className="mt-1"
|
|
/>
|
|
<div className="flex-1">
|
|
<div className="font-medium">{t("backup.keyfileManagement.yesUpload")}</div>
|
|
<div className="text-muted-foreground">{t("backup.keyfileManagement.yesUploadDescription")}</div>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
{pbsUploadToPbs ? (
|
|
<div className="space-y-2 pt-1 border-t border-blue-500/20">
|
|
<div>
|
|
<Label htmlFor="pbsRecPass" className="text-[11px]">{t("backup.fields.recoveryPassphrase")}</Label>
|
|
<Input
|
|
id="pbsRecPass"
|
|
type="password"
|
|
value={pbsRecoveryPass}
|
|
onChange={(e) => setPbsRecoveryPass(e.target.value)}
|
|
placeholder={t("backup.placeholders.recoveryPassphrase")}
|
|
className="font-mono mt-1 h-8 text-xs"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="pbsRecPass2" className="text-[11px]">{t("backup.fields.confirmPassphrase")}</Label>
|
|
<Input
|
|
id="pbsRecPass2"
|
|
type="password"
|
|
value={pbsRecoveryPass2}
|
|
onChange={(e) => setPbsRecoveryPass2(e.target.value)}
|
|
placeholder={t("backup.placeholders.typeItAgain")}
|
|
className="font-mono mt-1 h-8 text-xs"
|
|
/>
|
|
{pbsRecoveryPass && pbsRecoveryPass2 && pbsRecoveryPass !== pbsRecoveryPass2 && (
|
|
<p className="text-[11px] text-red-400 mt-1">{t("backup.errors.passphrasesDontMatch")}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="pt-1 border-t border-blue-500/20 text-[11px] text-amber-300 bg-amber-500/10 border border-amber-500/30 rounded px-3 py-2 leading-relaxed space-y-1">
|
|
<div className="font-semibold">{t("backup.keyfileManagement.keepOffsiteCopy")}</div>
|
|
<div>{t("backup.keyfileManagement.copyAfterCreateBefore")} <code className="font-mono text-[10.5px] bg-amber-500/20 rounded px-1">/usr/local/share/proxmenux/pbs-key.conf</code> {t("backup.keyfileManagement.copyAfterCreateAfter")}</div>
|
|
<div>{t("backup.keyfileManagement.withoutCopyWarning")}</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
{backend === "local" && (
|
|
<div className="space-y-2">
|
|
<Label htmlFor="localDest">{t("backup.fields.destinationDirectory")}</Label>
|
|
{mode === "attach" ? (
|
|
<>
|
|
<Input
|
|
id="localDest"
|
|
value={localDestDir}
|
|
onChange={(e) => setLocalDestDir(e.target.value)}
|
|
placeholder={t("backup.placeholders.autoFromPveStorage")}
|
|
className="font-mono"
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t("backup.destinations.localAttachHelpBefore")} <code className="font-mono">/dump</code> {t("backup.destinations.localAttachHelpMiddle")} <code className="font-mono">/var/lib/vz/dump</code>.
|
|
</p>
|
|
</>
|
|
) : (
|
|
<>
|
|
{(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={t("backup.placeholders.pickLocalDestination")} />
|
|
</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">{t("backup.profile.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={t("backup.destinations.saveAnotherLocalTitle")}
|
|
>
|
|
<Plus className="h-3.5 w-3.5 mr-1" />
|
|
{t("backup.actions.addAnother")}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
<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">
|
|
{t("backup.destinations.localPickHelpBefore")} <span className="font-mono">{destResp?.local?.effective || "/var/lib/vz/dump"}</span>.
|
|
</p>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
{backend === "borg" && (
|
|
<>
|
|
<div>
|
|
<Label>{t("backup.fields.borgRepository")}</Label>
|
|
{destResp?.borg?.length ? (
|
|
<div className="mt-1 flex items-center gap-2">
|
|
<Select value={borgRepoSelected} onValueChange={setBorgRepoSelected}>
|
|
<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">{t("backup.placeholders.pickBorgRepository")}</span>
|
|
}
|
|
return (
|
|
<span className="truncate font-mono text-left">
|
|
{sel.name}
|
|
<span className="text-muted-foreground"> — {sel.repository}</span>
|
|
</span>
|
|
)
|
|
})()}
|
|
</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={t("backup.destinations.saveAnotherBorgTitle")}
|
|
>
|
|
<Plus className="h-3.5 w-3.5 mr-1" />
|
|
{t("backup.actions.addAnother")}
|
|
</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>{t("backup.destinations.noBorgInWizard")}</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" />
|
|
{t("backup.destinations.addBorgRepository")}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{/* Encryption + passphrase aren't asked here — they
|
|
live on the borg destination. The runner reads
|
|
the saved sidecar at run time. */}
|
|
{(() => {
|
|
const dest = destResp?.borg?.find((r) => r.repository === borgRepoSelected)
|
|
if (!dest) return null
|
|
const mode = dest.encrypt_mode || "repokey"
|
|
return (
|
|
<p className="text-[11px] text-muted-foreground">
|
|
{t("backup.destinations.borgEncryptionInherited", { mode: mode === "none" ? t("backup.encryption.none") : mode })}
|
|
</p>
|
|
)
|
|
})()}
|
|
</>
|
|
)}
|
|
|
|
{/* Summary preview — mirrors the JobDetailModal styling so
|
|
what the operator sees at Create time is what they'll
|
|
see when they open the job later. */}
|
|
{(() => {
|
|
const retentionPairs: Array<[string, string]> = [
|
|
[t("backup.retention.last"), String(keepLast || "")],
|
|
[t("backup.retention.hourly"), String(keepHourly || "")],
|
|
[t("backup.retention.daily"), String(keepDaily || "")],
|
|
[t("backup.retention.weekly"), String(keepWeekly || "")],
|
|
[t("backup.retention.monthly"), String(keepMonthly || "")],
|
|
[t("backup.retention.yearly"), String(keepYearly || "")],
|
|
].filter(([, v]) => Number(v) > 0) as Array<[string, string]>
|
|
return (
|
|
<div className="rounded-md border border-border bg-card p-3 space-y-2 text-xs">
|
|
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">{t("backup.jobs.summary")}</div>
|
|
<div><span className="text-muted-foreground">{t("backup.fields.nameLabel")}</span> <span className="font-mono text-foreground">{jobId}</span></div>
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<span className="text-muted-foreground">{t("backup.fields.backendLabel")}</span>
|
|
<Badge variant="outline" className={`text-[10px] uppercase tracking-wide ${methodBadgeCls(backend)}`}>
|
|
{backend === "pbs" ? "PBS" : backend === "borg" ? "Borg" : t("backup.backends.local")}
|
|
</Badge>
|
|
<Badge variant="outline" className="text-[10px] text-blue-400 border-blue-400/40 bg-blue-500/5">
|
|
{mode === "attach" ? t("backup.status.attached") : t("backup.status.scheduled")}
|
|
</Badge>
|
|
</div>
|
|
{mode === "attach" && selectedPveJob && (
|
|
<>
|
|
<div><span className="text-muted-foreground">{t("backup.fields.pveJobLabel")}</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]">{t("backup.jobs.inheritedScheduleLabel")}</span>
|
|
<span className="text-foreground">{humanizeOnCalendar(selectedPveJob.schedule, t)}</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]">{t("backup.jobs.inheritedRetentionLabel")}</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]">{t("backup.fields.whenLabel")}</span>
|
|
<span className="text-foreground">{humanizeOnCalendar(onCalendar, t)}</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" /> {t("backup.fields.retentionLabel")}
|
|
</span>
|
|
{retentionPairs.length === 0 ? (
|
|
<span className="text-muted-foreground italic">{t("backup.retention.none")}</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]">{t("backup.fields.profileLabel")}</span>
|
|
<span className="text-foreground">{profileMode === "default" ? t("backup.profile.default") : t("backup.profile.custom")}</span>
|
|
<span className="text-muted-foreground">({t("backup.paths.count", { count: profileMode === "default" ? defaultPaths.length : customPaths.size })})</span>
|
|
</div>
|
|
</div>
|
|
)
|
|
})()}
|
|
|
|
{error && (
|
|
<div className="text-xs text-red-500 px-3 py-2 rounded-md border border-red-500/30 bg-red-500/10 whitespace-pre-wrap break-words">
|
|
{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}
|
|
>
|
|
{t("actions.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" />
|
|
{t("backup.actions.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)
|
|
}
|
|
>
|
|
{t("backup.actions.next")}
|
|
<ChevronRight className="h-4 w-4 ml-1" />
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
onClick={handleCreate}
|
|
disabled={!canSubmit || submitting}
|
|
className="bg-blue-600 hover:bg-blue-700 text-white"
|
|
>
|
|
{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 ? t("backup.actions.saveChanges") : t("backup.jobs.createJob")}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
|
|
{/* AddDestinationDialog spawned from inside the wizard. When the
|
|
operator saves a new PBS or Borg destination we refresh the
|
|
dest list and queue the freshly-saved repository so the next
|
|
destResp render auto-selects it in the dropdown. */}
|
|
<AddDestinationDialog
|
|
type={addingDestType}
|
|
onClose={() => setAddingDestType(null)}
|
|
onSaved={(repo) => {
|
|
if (repo && (addingDestType === "pbs" || addingDestType === "borg")) {
|
|
setPendingAutoSelectDest({ kind: addingDestType, repository: repo })
|
|
}
|
|
setAddingDestType(null)
|
|
mutateDest()
|
|
}}
|
|
/>
|
|
</Dialog>
|
|
)
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────
|
|
// Manual backup dialog (Sprint B).
|
|
//
|
|
// One-shot version of CreateJobDialog: same backends + profile + paths
|
|
// + backend-specific config, but NO schedule, NO retention, NO timer.
|
|
// Writes a `manual-<ts>` .env with ENABLED=0 + MANUAL_RUN=1 and fires
|
|
// the runner in the background. The resulting job appears in the
|
|
// scheduler list with a "manual" badge and can be deleted when the
|
|
// operator wants to clean up.
|
|
// ──────────────────────────────────────────────────────────────
|
|
|
|
function ManualBackupDialog({
|
|
open,
|
|
onClose,
|
|
onLaunched,
|
|
}: {
|
|
open: boolean
|
|
onClose: () => void
|
|
// The job_id of the just-launched manual run. The caller uses it to
|
|
// transition straight into the live-progress modal so the operator
|
|
// never has to chase a banner after pressing Run.
|
|
onLaunched: (jobId: string) => void
|
|
}) {
|
|
const t = useT()
|
|
const [step, setStep] = useState<1 | 2>(1)
|
|
const [backend, setBackend] = useState<"pbs" | "local" | "borg">("local")
|
|
const [profileMode, setProfileMode] = useState<"default" | "custom">("default")
|
|
const [customPaths, setCustomPaths] = useState<Set<string>>(new Set())
|
|
|
|
const [pbsRepository, setPbsRepository] = useState<string>("")
|
|
const [pbsBackupId, setPbsBackupId] = useState<string>("")
|
|
// 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)
|
|
// Absolute-path alternative to the file upload — mirrors the shell
|
|
// wizard's `_hb_pbs_import_dialog` when there is no PVE .enc match.
|
|
const [pbsImportPath, setPbsImportPath] = useState<string>("")
|
|
const [pbsImportBusy, setPbsImportBusy] = useState<boolean>(false)
|
|
const pbsEncrypt = pbsEncryptMode !== "none"
|
|
const [pbsRecoveryPass, setPbsRecoveryPass] = useState<string>("")
|
|
const [pbsRecoveryPass2, setPbsRecoveryPass2] = useState<string>("")
|
|
// 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)
|
|
// Binary "upload key to PBS?" toggle. `true` maps to escrow_mode='full'
|
|
// (passphrase required, envelope uploaded on every backup); `false`
|
|
// maps to escrow_mode='none' (keyfile stays local, operator handles
|
|
// the offsite copy). Defaults to 'No' — the keyfile is always
|
|
// downloadable from the Monitor, so the operator can grab it and
|
|
// save it offsite themselves without leaving a passphrase-wrapped
|
|
// copy on the PBS server.
|
|
const [pbsUploadToPbs, setPbsUploadToPbs] = useState<boolean>(false)
|
|
// Whether the host already has an escrow blob configured. When
|
|
// present, the passphrase input becomes optional ("leave blank to
|
|
// keep saved"). Refreshed after a successful setup call.
|
|
const { data: pbsRecoveryStatus, mutate: mutatePbsRecovery } = useSWR<{
|
|
has_keyfile: boolean; has_recovery: boolean; has_keyfile_passphrase?: boolean; escrow_mode?: "none" | "local" | "full"
|
|
}>(
|
|
open && backend === "pbs" ? "/api/host-backups/pbs-recovery/status" : null,
|
|
fetcher,
|
|
)
|
|
// Auto-discover a PVE-managed keyfile for the currently selected
|
|
// PBS repository. When one is found, the "Use an existing keyfile"
|
|
// radio uses it silently (matches the shell wizard's PVE auto-detect
|
|
// path — the operator doesn't have to upload the same file twice).
|
|
const { data: pbsPveDiscover } = useSWR<{
|
|
entries: Array<{ name: string; server: string; datastore: string; path: string; matches_repository: boolean }>
|
|
}>(
|
|
open && backend === "pbs" && pbsRepository && !pbsRecoveryStatus?.has_keyfile
|
|
? `/api/host-backups/pbs-encryption/discover-pve-keyfiles?repository=${encodeURIComponent(pbsRepository)}`
|
|
: null,
|
|
fetcher,
|
|
)
|
|
const pbsPveMatch = pbsPveDiscover?.entries?.find((e) => e.matches_repository)
|
|
const [localDestDir, setLocalDestDir] = useState<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("")
|
|
setPbsEncryptMode("none")
|
|
setPbsImportFile(null)
|
|
setPbsImportPath("")
|
|
setPbsImportBusy(false)
|
|
setPbsRecoveryPass("")
|
|
setPbsRecoveryPass2("")
|
|
setPbsRecoveryChange(false)
|
|
setPbsUploadToPbs(false)
|
|
setLocalDestDir("")
|
|
setBorgRepoSelected("")
|
|
setBorgPassphrase("")
|
|
setBorgEncryptMode("repokey")
|
|
setError(null)
|
|
setSubmitting(false)
|
|
}
|
|
}, [open])
|
|
|
|
useEffect(() => {
|
|
if (backend === "pbs" && !pbsRepository && destResp?.pbs?.length) {
|
|
setPbsRepository(destResp.pbs[0].repository)
|
|
}
|
|
if (backend === "borg" && !borgRepoSelected && destResp?.borg?.length) {
|
|
setBorgRepoSelected(destResp.borg[0].repository)
|
|
}
|
|
}, [backend, destResp, pbsRepository, borgRepoSelected])
|
|
|
|
const togglePath = (path: string) => {
|
|
setCustomPaths((prev) => {
|
|
const next = new Set(prev)
|
|
if (next.has(path)) next.delete(path)
|
|
else next.add(path)
|
|
return next
|
|
})
|
|
}
|
|
|
|
const defaultPaths = pathsResp?.paths ?? []
|
|
const canAdvanceFrom1 = profileMode === "default" || (profileMode === "custom" && customPaths.size > 0)
|
|
const backendValid =
|
|
backend === "pbs"
|
|
? !!pbsRepository
|
|
: backend === "local"
|
|
? true
|
|
// Borg destination carries its own passphrase + encryption.
|
|
// No need to gate on local state — the wizard no longer asks.
|
|
: !!borgRepoSelected
|
|
// Recovery gate for PBS encryption. Choosing "Encrypt" makes the
|
|
// recovery passphrase mandatory — same as the CLI. The "Run backup"
|
|
// button stays disabled until a matching passphrase pair is entered
|
|
// (or an already-saved escrow is reused). No accept-risk escape:
|
|
// encrypting without recovery would leave the keyfile only on this
|
|
// host, and a reinstall would render every encrypted backup
|
|
// unrecoverable.
|
|
// Passphrase requirement only kicks in when: encryption is on AND
|
|
// there's no keyfile installed yet (fresh setup) AND the operator
|
|
// opted to upload the key to PBS. Every other case (reuse existing
|
|
// keyfile, or "no upload" mode) doesn't need a passphrase.
|
|
const pbsRecoveryOk = !(backend === "pbs" && pbsEncrypt) ||
|
|
pbsRecoveryStatus?.has_keyfile ||
|
|
!pbsUploadToPbs ||
|
|
(pbsRecoveryPass !== "" && pbsRecoveryPass === pbsRecoveryPass2)
|
|
const canSubmit = canAdvanceFrom1 && backendValid && pbsRecoveryOk
|
|
|
|
async function handleRun() {
|
|
if (!canSubmit) return
|
|
setSubmitting(true)
|
|
setError(null)
|
|
// Unified encryption setup — mirrors CreateJobDialog. One atomic
|
|
// /pbs-encryption/import call covers keyfile install + escrow
|
|
// setup with source picked from what the operator provided.
|
|
const needsInstall =
|
|
backend === "pbs" &&
|
|
pbsEncrypt &&
|
|
!pbsRecoveryStatus?.has_keyfile
|
|
if (needsInstall) {
|
|
if (pbsEncryptMode === "existing" && !pbsPveMatch && !pbsImportFile && !pbsImportPath.trim()) {
|
|
setError(t("backup.errors.pickKeyfile"))
|
|
setSubmitting(false)
|
|
return
|
|
}
|
|
if (pbsUploadToPbs && pbsRecoveryPass !== pbsRecoveryPass2) {
|
|
setError(t("backup.errors.passphrasesDoNotMatch"))
|
|
setSubmitting(false)
|
|
return
|
|
}
|
|
setPbsImportBusy(true)
|
|
try {
|
|
let source: "generate" | "pve-storage" | "content" | "path"
|
|
if (pbsEncryptMode === "new") source = "generate"
|
|
else if (pbsPveMatch) source = "pve-storage"
|
|
else if (pbsImportFile) source = "content"
|
|
else source = "path"
|
|
const body: Record<string, unknown> = {
|
|
source,
|
|
escrow_mode: pbsUploadToPbs ? "full" : "none",
|
|
}
|
|
if (pbsUploadToPbs) body.escrow_passphrase = pbsRecoveryPass
|
|
if (source === "pve-storage") {
|
|
body.pve_storage_name = pbsPveMatch!.name
|
|
} else if (source === "content") {
|
|
const buf = new Uint8Array(await pbsImportFile!.arrayBuffer())
|
|
let bin = ""
|
|
for (let i = 0; i < buf.length; i++) bin += String.fromCharCode(buf[i])
|
|
body.content_b64 = btoa(bin)
|
|
} else if (source === "path") {
|
|
body.keyfile_path = pbsImportPath.trim()
|
|
}
|
|
await fetchApi("/api/host-backups/pbs-encryption/import", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
})
|
|
mutatePbsRecovery()
|
|
} catch (e) {
|
|
const err = e as Error & { body?: { tool_output?: string; tool_exit_code?: number } }
|
|
const detail = err.body?.tool_output
|
|
? `${err.message}\n\n${t("backup.errors.proxmoxBackupClientOutput")}:\n${err.body.tool_output}`
|
|
: err.message
|
|
setError(t("backup.errors.encryptionSetupFailed", { detail: detail || String(e) }))
|
|
setPbsImportBusy(false)
|
|
setSubmitting(false)
|
|
return
|
|
}
|
|
setPbsImportBusy(false)
|
|
}
|
|
// NOTE: recovery escrow is now bundled into the /pbs-encryption/import
|
|
// call above. No separate /pbs-recovery/setup pass is needed.
|
|
try {
|
|
const body: Record<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
|
|
body.pbs_encrypt_mode = pbsEncryptMode
|
|
} else if (backend === "local") {
|
|
if (localDestDir.trim()) body.local_dest_dir = localDestDir.trim()
|
|
} else if (backend === "borg") {
|
|
// Passphrase + encrypt_mode are inherited from the destination
|
|
// (borg-pass-<name>.txt sidecar + 4th field of borg-targets.txt).
|
|
body.borg_repo = borgRepoSelected
|
|
}
|
|
const resp = await fetchApi<{ status: string; job_id: string }>(
|
|
"/api/host-backups/manual-run",
|
|
{
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
},
|
|
)
|
|
onLaunched(resp.job_id)
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : String(e))
|
|
} finally {
|
|
setSubmitting(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
|
<DialogContent className="max-w-2xl max-h-[85vh] flex flex-col bg-accent [&_input]:bg-background [&_textarea]:bg-background [&_[role=combobox]]:bg-background">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<PlayCircle className="h-5 w-5 text-blue-500" />
|
|
{t("backup.manual.runOneShotBackup")}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{t("backup.jobs.stepOf", { step, total: 2 })} · {t("backup.manual.oneShotDescription")}
|
|
</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>{t("backup.fields.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"
|
|
? t("backup.backends.pbsShortDescription")
|
|
: b === "local"
|
|
? t("backup.backends.localShortDescription")
|
|
: t("backup.backends.borgShortDescription")
|
|
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-card"} hover:bg-white/5 transition-colors`}
|
|
>
|
|
<div className="flex items-center gap-2 font-medium text-sm">
|
|
<Icon className="h-4 w-4" />
|
|
{b === "pbs" ? "PBS" : b === "borg" ? "Borg" : t("backup.backends.local")}
|
|
</div>
|
|
<div className="text-xs text-muted-foreground mt-1">{desc}</div>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<Label>{t("backup.profile.title")}</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-card"} hover:bg-white/5 transition-colors`}
|
|
>
|
|
<div className="text-sm font-medium">{t("backup.profile.default")}</div>
|
|
<div className="text-xs text-muted-foreground mt-1">
|
|
{t("backup.profile.defaultDescriptionShort", { count: defaultPaths.length })}
|
|
</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-card"} hover:bg-white/5 transition-colors`}
|
|
>
|
|
<div className="text-sm font-medium">{t("backup.profile.custom")}</div>
|
|
<div className="text-xs text-muted-foreground mt-1">
|
|
{t("backup.profile.pickPaths")}
|
|
</div>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{profileMode === "custom" && (
|
|
<div>
|
|
<Label>{t("backup.profile.pathsToInclude", { selected: customPaths.size, total: 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>{t("backup.fields.pbsRepository")}</Label>
|
|
{destResp?.pbs?.length ? (
|
|
<div className="mt-1 flex items-center gap-2">
|
|
<Select value={pbsRepository} onValueChange={setPbsRepository}>
|
|
<SelectTrigger className="flex-1 min-w-0">
|
|
{/* Manual render: SelectValue's default mode
|
|
duplicates the item children and cuts
|
|
them in the middle on mobile. We render
|
|
"name — repository" ourselves with a
|
|
proper trailing ellipsis. */}
|
|
{(() => {
|
|
const sel = destResp.pbs.find((r) => r.repository === pbsRepository)
|
|
if (!sel) {
|
|
return <span className="truncate text-muted-foreground">{t("backup.placeholders.pickPbsRepository")}</span>
|
|
}
|
|
return (
|
|
<span className="truncate font-mono text-left">
|
|
{sel.name}
|
|
<span className="text-muted-foreground"> — {sel.repository}</span>
|
|
</span>
|
|
)
|
|
})()}
|
|
</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={t("backup.destinations.saveAnotherPbsTitle")}
|
|
>
|
|
<Plus className="h-3.5 w-3.5 mr-1" />
|
|
{t("backup.actions.addAnother")}
|
|
</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>{t("backup.destinations.noPbsInDialog")}</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" />
|
|
{t("backup.destinations.addPbsRepository")}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="manualPbsBackupId">{t("backup.fields.groupNameInPbs")}</Label>
|
|
<Input
|
|
id="manualPbsBackupId"
|
|
value={pbsBackupId}
|
|
onChange={(e) => setPbsBackupId(e.target.value)}
|
|
placeholder={t("backup.placeholders.leaveBlankForDefault")}
|
|
className="font-mono mt-1"
|
|
/>
|
|
</div>
|
|
<div className="pt-2 border-t border-border space-y-3">
|
|
<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" />
|
|
{t("backup.encryption.encryptThisBackup")}
|
|
</div>
|
|
<p className="text-[11px] text-muted-foreground mt-1">
|
|
{t("backup.encryption.usesSharedKeyfileAt")} <code className="font-mono">/usr/local/share/proxmenux/pbs-key.conf</code>.
|
|
</p>
|
|
</div>
|
|
</label>
|
|
|
|
|
|
{pbsEncrypt && !pbsRecoveryStatus?.has_keyfile && (
|
|
<div className="space-y-2 rounded-md border border-emerald-500/30 bg-emerald-500/5 p-3">
|
|
<div className="text-[11px] font-medium">
|
|
{t("backup.encryption.noKeyStored")}
|
|
</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">{t("backup.encryption.generateNewKeyfile")}</div>
|
|
<div className="text-muted-foreground">{t("backup.encryption.generateManualDescription")}</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">{t("backup.encryption.importExistingKeyfile")}</div>
|
|
<div className="text-muted-foreground">{t("backup.encryption.importManualDescription")}</div>
|
|
</div>
|
|
</label>
|
|
{pbsEncryptMode === "existing" && pbsPveMatch && (
|
|
<div className="pt-1 rounded-md border border-emerald-500/40 bg-emerald-500/5 p-2.5 text-[11px] space-y-1">
|
|
<div className="flex items-start gap-1.5">
|
|
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-400 mt-0.5 shrink-0" />
|
|
<div className="flex-1">
|
|
<div className="font-medium text-foreground">{t("backup.encryption.autoDetectedFromPveStorage", { storage: pbsPveMatch.name })}</div>
|
|
<div className="text-muted-foreground mt-0.5">
|
|
{t("backup.encryption.existingKeyAt")} <code className="font-mono text-[10.5px]">{pbsPveMatch.path}</code>
|
|
</div>
|
|
<div className="text-muted-foreground mt-0.5">{t("backup.encryption.pveKeyWillBeCopied")}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{pbsEncryptMode === "existing" && !pbsPveMatch && (
|
|
<div className="space-y-2 pt-1">
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="manualPbsImportFile" className="text-[11px] font-medium">
|
|
{t("backup.keyfileActions.uploadFromMachine")}
|
|
</Label>
|
|
<Input
|
|
id="manualPbsImportFile"
|
|
type="file"
|
|
accept=".conf,.key,application/json,text/plain"
|
|
onChange={(e) => setPbsImportFile(e.target.files?.[0] ?? null)}
|
|
disabled={pbsImportBusy || !!pbsImportPath.trim()}
|
|
className="h-8 text-[11px]"
|
|
/>
|
|
</div>
|
|
<div className="text-[10px] text-center text-muted-foreground">{t("backup.common.or")}</div>
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="manualPbsImportPath" className="text-[11px] font-medium">
|
|
{t("backup.fields.absolutePathOnHost")}
|
|
</Label>
|
|
<Input
|
|
id="manualPbsImportPath"
|
|
type="text"
|
|
value={pbsImportPath}
|
|
onChange={(e) => setPbsImportPath(e.target.value)}
|
|
disabled={pbsImportBusy || !!pbsImportFile}
|
|
placeholder={t("backup.placeholders.keyfilePath")}
|
|
className="h-8 text-[11px] font-mono"
|
|
/>
|
|
<p className="text-[10px] text-muted-foreground">
|
|
{t("backup.encryption.importKeyfileHelpBefore")} <code className="font-mono">/usr/local/share/proxmenux/pbs-key.conf</code> {t("backup.encryption.importKeyfileHelpAfter")}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{pbsEncrypt && (
|
|
<div className="pl-7 space-y-3 rounded-md border border-blue-500/30 bg-blue-500/5 p-3">
|
|
{pbsRecoveryStatus?.has_keyfile && !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">{t("backup.keyfileManagement.thisBackupReusesKeyfile")}</div>
|
|
<div className="text-muted-foreground mt-0.5">{t("backup.keyfileManagement.currentPbsUploadStays")}</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setPbsRecoveryChange(true)}
|
|
className="text-blue-400 hover:text-blue-300 underline underline-offset-2 shrink-0"
|
|
>
|
|
{t("backup.actions.change")}
|
|
</button>
|
|
</div>
|
|
)}
|
|
{pbsRecoveryStatus?.has_keyfile && pbsRecoveryChange && (
|
|
<>
|
|
<KeyfileActionsBar
|
|
mutateStatus={mutatePbsRecovery}
|
|
escrowMode={pbsRecoveryStatus?.escrow_mode}
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => setPbsRecoveryChange(false)}
|
|
className="text-[11px] text-muted-foreground hover:text-foreground underline underline-offset-2"
|
|
>
|
|
{t("backup.keyfileManagement.hideOptions")}
|
|
</button>
|
|
</>
|
|
)}
|
|
{!pbsRecoveryStatus?.has_keyfile && (
|
|
<>
|
|
<div>
|
|
<div className="text-[11px] font-medium text-foreground mb-2">{t("backup.keyfileManagement.uploadToPbs")}</div>
|
|
<div className="grid gap-1.5">
|
|
<label className="flex items-start gap-2 cursor-pointer text-[11px]">
|
|
<input
|
|
type="radio"
|
|
name="pbsUploadToPbsManual"
|
|
checked={!pbsUploadToPbs}
|
|
onChange={() => setPbsUploadToPbs(false)}
|
|
className="mt-1"
|
|
/>
|
|
<div className="flex-1">
|
|
<div className="font-medium">{t("backup.keyfileManagement.keepLocalOnly")}</div>
|
|
<div className="text-muted-foreground">{t("backup.keyfileManagement.keepLocalDescriptionBefore")} <code className="font-mono text-[10.5px]">/usr/local/share/proxmenux/pbs-key.conf</code>. {t("backup.keyfileManagement.keepLocalDescriptionAfter")}</div>
|
|
</div>
|
|
</label>
|
|
<label className="flex items-start gap-2 cursor-pointer text-[11px]">
|
|
<input
|
|
type="radio"
|
|
name="pbsUploadToPbsManual"
|
|
checked={pbsUploadToPbs}
|
|
onChange={() => setPbsUploadToPbs(true)}
|
|
className="mt-1"
|
|
/>
|
|
<div className="flex-1">
|
|
<div className="font-medium">{t("backup.keyfileManagement.yesUpload")}</div>
|
|
<div className="text-muted-foreground">{t("backup.keyfileManagement.yesUploadDescription")}</div>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
{pbsUploadToPbs ? (
|
|
<div className="space-y-2 pt-1 border-t border-blue-500/20">
|
|
<div>
|
|
<Label htmlFor="manualPbsRecPass" className="text-[11px]">{t("backup.fields.recoveryPassphrase")}</Label>
|
|
<Input
|
|
id="manualPbsRecPass"
|
|
type="password"
|
|
value={pbsRecoveryPass}
|
|
onChange={(e) => setPbsRecoveryPass(e.target.value)}
|
|
placeholder={t("backup.placeholders.recoveryPassphrase")}
|
|
className="font-mono mt-1 h-8 text-xs"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="manualPbsRecPass2" className="text-[11px]">{t("backup.fields.confirmPassphrase")}</Label>
|
|
<Input
|
|
id="manualPbsRecPass2"
|
|
type="password"
|
|
value={pbsRecoveryPass2}
|
|
onChange={(e) => setPbsRecoveryPass2(e.target.value)}
|
|
placeholder={t("backup.placeholders.typeItAgain")}
|
|
className="font-mono mt-1 h-8 text-xs"
|
|
/>
|
|
{pbsRecoveryPass && pbsRecoveryPass2 && pbsRecoveryPass !== pbsRecoveryPass2 && (
|
|
<p className="text-[11px] text-red-400 mt-1">{t("backup.errors.passphrasesDontMatch")}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="pt-1 border-t border-blue-500/20 text-[11px] text-amber-300 bg-amber-500/10 border border-amber-500/30 rounded px-3 py-2 leading-relaxed space-y-1">
|
|
<div className="font-semibold">{t("backup.keyfileManagement.keepOffsiteCopy")}</div>
|
|
<div>{t("backup.keyfileManagement.copyAfterManualBefore")} <code className="font-mono text-[10.5px] bg-amber-500/20 rounded px-1">/usr/local/share/proxmenux/pbs-key.conf</code> {t("backup.keyfileManagement.copyAfterCreateAfter")}</div>
|
|
<div>{t("backup.keyfileManagement.withoutCopyWarning")}</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
{backend === "local" && (
|
|
<div className="space-y-2">
|
|
<Label>{t("backup.fields.localDestination")}</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={t("backup.placeholders.pickLocalDestination")} />
|
|
</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">{t("backup.profile.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={t("backup.destinations.saveAnotherLocalTitle")}
|
|
>
|
|
<Plus className="h-3.5 w-3.5 mr-1" />
|
|
{t("backup.actions.addAnother")}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
<p className="text-xs text-muted-foreground">
|
|
{t("backup.destinations.localManualPickHelp")}
|
|
</p>
|
|
<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>{t("backup.fields.borgRepository")}</Label>
|
|
{destResp?.borg?.length ? (
|
|
<div className="mt-1 flex items-center gap-2">
|
|
<Select value={borgRepoSelected} onValueChange={setBorgRepoSelected}>
|
|
<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">{t("backup.placeholders.pickBorgRepository")}</span>
|
|
}
|
|
return (
|
|
<span className="truncate font-mono text-left">
|
|
{sel.name}
|
|
<span className="text-muted-foreground"> — {sel.repository}</span>
|
|
</span>
|
|
)
|
|
})()}
|
|
</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={t("backup.destinations.saveAnotherBorgTitle")}
|
|
>
|
|
<Plus className="h-3.5 w-3.5 mr-1" />
|
|
{t("backup.actions.addAnother")}
|
|
</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>{t("backup.destinations.noBorgInDialog")}</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" />
|
|
{t("backup.destinations.addBorgRepository")}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{(() => {
|
|
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">
|
|
{t("backup.destinations.borgEncryptionInherited", { mode: mode === "none" ? t("backup.encryption.none") : mode })}
|
|
</p>
|
|
)
|
|
})()}
|
|
</>
|
|
)}
|
|
|
|
{/* Summary — mirrors the styling of the JobDetailModal. */}
|
|
<div className="rounded-md border border-border bg-card p-3 space-y-2 text-xs">
|
|
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">{t("backup.jobs.summary")}</div>
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<span className="text-muted-foreground">{t("backup.fields.backendLabel")}</span>
|
|
<Badge variant="outline" className={`text-[10px] uppercase tracking-wide ${methodBadgeCls(backend)}`}>
|
|
{backend === "pbs" ? "PBS" : backend === "borg" ? "Borg" : t("backup.backends.local")}
|
|
</Badge>
|
|
<Badge variant="outline" className="text-[10px] text-purple-400 border-purple-400/40 bg-purple-500/5">
|
|
{t("backup.status.manualOneShot")}
|
|
</Badge>
|
|
</div>
|
|
<div className="flex items-center gap-1.5 flex-wrap">
|
|
<FileSearch className="h-3 w-3 text-green-500/80" />
|
|
<span className="text-green-500/90 uppercase tracking-wider text-[10px]">{t("backup.fields.profileLabel")}</span>
|
|
<span className="text-foreground">{profileMode === "default" ? t("backup.profile.default") : t("backup.profile.custom")}</span>
|
|
<span className="text-muted-foreground">({t("backup.paths.count", { count: profileMode === "default" ? defaultPaths.length : customPaths.size })})</span>
|
|
</div>
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="text-xs text-red-500 px-3 py-2 rounded-md border border-red-500/30 bg-red-500/10 whitespace-pre-wrap break-words">
|
|
{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}>
|
|
{t("actions.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" />
|
|
{t("backup.actions.back")}
|
|
</Button>
|
|
)}
|
|
{step < 2 ? (
|
|
<Button
|
|
onClick={() => setStep(2)}
|
|
disabled={!canAdvanceFrom1}
|
|
>
|
|
{t("backup.actions.next")}
|
|
<ChevronRight className="h-4 w-4 ml-1" />
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
onClick={handleRun}
|
|
disabled={!canSubmit || submitting}
|
|
className="bg-purple-500 hover:bg-purple-600 text-white"
|
|
>
|
|
{submitting ? (
|
|
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
|
) : (
|
|
<PlayCircle className="h-4 w-4 mr-2" />
|
|
)}
|
|
{t("backup.manual.runBackup")}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
|
|
{/* AddDestinationDialog spawned from inside the manual-backup
|
|
dialog. Same auto-select-on-save behavior as the wizard. */}
|
|
<AddDestinationDialog
|
|
type={addingDestType}
|
|
onClose={() => setAddingDestType(null)}
|
|
onSaved={(repo) => {
|
|
if (repo && (addingDestType === "pbs" || addingDestType === "borg")) {
|
|
setPendingAutoSelectDest({ kind: addingDestType, repository: repo })
|
|
}
|
|
setAddingDestType(null)
|
|
mutateDest()
|
|
}}
|
|
/>
|
|
</Dialog>
|
|
)
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────
|
|
// Backup destinations CRUD (Sprint D).
|
|
//
|
|
// Three persistence files live in $HB_STATE_DIR (= /usr/local/share/proxmenux/):
|
|
// - pbs-manual-configs.txt (manual PBS configs the shell also reads)
|
|
// - borg-targets.txt (Borg repos saved by the shell)
|
|
// - local-target.conf (single local target — default or override)
|
|
//
|
|
// Auto-discovered PBS storages from /etc/pve/storage.cfg show with a
|
|
// "proxmox" badge and are NOT deletable here (PVE owns those).
|
|
// ──────────────────────────────────────────────────────────────
|
|
|
|
// One entry in the unified destinations list. PBS / Borg / Local all
|
|
// flatten into this shape so the row component doesn't need to know
|
|
// which backend a given destination targets — only the color, the
|
|
// label and the action set.
|
|
type UnifiedDest =
|
|
| { id: string; kind: "local"; path: string; source: "default" | "custom"; removable: boolean; jobsUsing: string[] }
|
|
| {
|
|
id: string; kind: "pbs"; name: string; repository: string;
|
|
source: "proxmox" | "manual"; fingerprint?: string; removable: boolean
|
|
jobsUsing: string[]
|
|
}
|
|
| {
|
|
id: string; kind: "borg"; name: string; repository: string;
|
|
isSsh: boolean; ssh?: { user: string; host: string; remotePath: string };
|
|
sshKeyPath?: string;
|
|
// The destination's encryption mode + whether a passphrase is
|
|
// saved server-side; used to render a Lock / Unlock badge so the
|
|
// operator sees at a glance which Borg repos are encrypted.
|
|
encryptMode?: "none" | "repokey" | "keyfile" | "authenticated"
|
|
hasPassphrase?: boolean
|
|
removable: boolean
|
|
jobsUsing: string[]
|
|
}
|
|
|
|
interface CapacityInfo {
|
|
id: string
|
|
total?: number
|
|
available?: number
|
|
used?: number
|
|
is_usb?: boolean
|
|
remote?: boolean
|
|
error?: string
|
|
}
|
|
|
|
// Parse a Borg repository string into {user, host, remotePath} when
|
|
// it's an SSH URL — used for the capacity probe over ssh.
|
|
function parseBorgSsh(repo: string): { user: string; host: string; remotePath: string } | null {
|
|
const m = repo.match(/^ssh:\/\/([^@]+)@([^/]+)\/(.+)$/)
|
|
if (!m) return null
|
|
return { user: m[1], host: m[2], remotePath: `/${m[3]}` }
|
|
}
|
|
|
|
function DestinationsSection({
|
|
destinations,
|
|
onChanged,
|
|
}: {
|
|
destinations?: DestinationsResp
|
|
onChanged: () => void
|
|
}) {
|
|
const t = useT()
|
|
const [configuring, setConfiguring] = useState<boolean>(false)
|
|
const [editingDest, setEditingDest] = useState<EditingDest | null>(null)
|
|
const [confirmingDest, setConfirmingDest] = useState<UnifiedDest | null>(null)
|
|
const [busyKey, setBusyKey] = useState<string | null>(null)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
// Used by the Delete-destination confirm to tell the operator how
|
|
// many backups already live on this target (they're kept after the
|
|
// destination + jobs cascade, so the warning is informational).
|
|
const { data: localArchivesResp } = useSWR<{ archives: BackupArchive[] }>(
|
|
"/api/host-backups/archives",
|
|
fetcher,
|
|
{ refreshInterval: 60_000, revalidateOnFocus: false },
|
|
)
|
|
const { data: remoteArchivesResp } = useSWR<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 and the "PBS encryption
|
|
// key" management block.
|
|
const hasPbs = (destinations?.pbs?.length ?? 0) > 0
|
|
const { data: pbsRecoveryStatus, mutate: mutatePbsRecovery } = useSWR<{
|
|
has_keyfile: boolean; has_recovery: boolean; has_keyfile_passphrase?: boolean; escrow_mode?: "none" | "local" | "full"; keyfile_fingerprint?: string
|
|
}>(hasPbs ? "/api/host-backups/pbs-recovery/status" : null, fetcher)
|
|
const { data: pbsRecoveryAvailable } = useSWR<{
|
|
snapshots: Array<{ repo_name: string; repo_repository: string; backup_id: string; source_host: string; backup_time: number; snapshot: string }>
|
|
errors: Array<{ repo_name: string; error: string }>
|
|
}>(
|
|
hasPbs && pbsRecoveryStatus && !pbsRecoveryStatus.has_keyfile
|
|
? "/api/host-backups/pbs-recovery/available"
|
|
: null,
|
|
fetcher,
|
|
)
|
|
const [recoveringKeyfile, setRecoveringKeyfile] = useState<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
|
|
}
|
|
|
|
// Flatten PBS + Borg + Local into one list. Order: locals first
|
|
// (default always on top), then PBS, then Borg — matches the
|
|
// mental model of "what's already on this machine" before any
|
|
// remote target.
|
|
const items: UnifiedDest[] = (() => {
|
|
const out: UnifiedDest[] = []
|
|
for (const e of destinations?.local?.entries || []) {
|
|
out.push({
|
|
id: `local:${e.path}`,
|
|
kind: "local",
|
|
path: e.path,
|
|
source: e.source as "default" | "custom",
|
|
removable: e.removable,
|
|
jobsUsing: e.jobs_using || [],
|
|
})
|
|
}
|
|
for (const r of destinations?.pbs || []) {
|
|
out.push({
|
|
id: `pbs:${r.name}:${r.repository}`,
|
|
kind: "pbs",
|
|
name: r.name,
|
|
repository: r.repository,
|
|
source: r.source as "proxmox" | "manual",
|
|
fingerprint: r.fingerprint || undefined,
|
|
removable: r.source === "manual",
|
|
jobsUsing: r.jobs_using || [],
|
|
})
|
|
}
|
|
for (const r of destinations?.borg || []) {
|
|
const ssh = parseBorgSsh(r.repository)
|
|
out.push({
|
|
id: `borg:${r.name}`,
|
|
kind: "borg",
|
|
name: r.name,
|
|
repository: r.repository,
|
|
isSsh: !!ssh,
|
|
ssh: ssh ?? undefined,
|
|
sshKeyPath: (r as { ssh_key_path?: string }).ssh_key_path,
|
|
encryptMode: r.encrypt_mode,
|
|
hasPassphrase: r.has_passphrase,
|
|
removable: true,
|
|
jobsUsing: r.jobs_using || [],
|
|
})
|
|
}
|
|
return out
|
|
})()
|
|
|
|
// Build the capacity-probe payload + key for SWR. The key includes
|
|
// every destination's id so SWR re-fetches when the list changes
|
|
// (add / remove). 30 s refresh keeps the bars roughly live without
|
|
// hammering ssh / pbs every render.
|
|
const capacityTargets = items.map((it) => {
|
|
if (it.kind === "local") return { id: it.id, kind: "local", path: it.path }
|
|
if (it.kind === "pbs") return { id: it.id, kind: "pbs", name: it.name, repository: it.repository }
|
|
if (it.isSsh && it.ssh) {
|
|
return {
|
|
id: it.id, kind: "borg-ssh",
|
|
host: it.ssh.host, user: it.ssh.user,
|
|
remote_path: it.ssh.remotePath,
|
|
key_path: it.sshKeyPath || "",
|
|
}
|
|
}
|
|
return { id: it.id, kind: "borg-local", path: it.repository }
|
|
})
|
|
const capacityKey = items.length
|
|
? `/api/host-backups/destinations/capacity?keys=${items.map((i) => i.id).join(",")}`
|
|
: null
|
|
const { data: capacityResp } = useSWR<{ results: CapacityInfo[] }>(
|
|
capacityKey,
|
|
() =>
|
|
fetchApi("/api/host-backups/destinations/capacity", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ targets: capacityTargets }),
|
|
}),
|
|
{ refreshInterval: 30_000, revalidateOnFocus: false },
|
|
)
|
|
const capByEdid = new Map<string, CapacityInfo>(
|
|
(capacityResp?.results || []).map((r) => [r.id, r] as const),
|
|
)
|
|
|
|
async function removePbs(name: string, force = false) {
|
|
setBusyKey(`pbs:${name}`)
|
|
setError(null)
|
|
try {
|
|
await fetchApi(`/api/host-backups/destinations/pbs/${encodeURIComponent(name)}${force ? "?force=true" : ""}`, { method: "DELETE" })
|
|
onChanged()
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : String(e))
|
|
} finally {
|
|
setBusyKey(null)
|
|
}
|
|
}
|
|
async function removeBorg(name: string, force = false) {
|
|
setBusyKey(`borg:${name}`)
|
|
setError(null)
|
|
try {
|
|
await fetchApi(`/api/host-backups/destinations/borg/${encodeURIComponent(name)}${force ? "?force=true" : ""}`, { method: "DELETE" })
|
|
onChanged()
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : String(e))
|
|
} finally {
|
|
setBusyKey(null)
|
|
}
|
|
}
|
|
async function removeLocal(path: string, force = false) {
|
|
setBusyKey(`local:${path}`)
|
|
setError(null)
|
|
try {
|
|
const params = new URLSearchParams({ path })
|
|
if (force) params.set("force", "true")
|
|
await fetchApi(
|
|
`/api/host-backups/destinations/local?${params.toString()}`,
|
|
{ method: "DELETE" },
|
|
)
|
|
onChanged()
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : String(e))
|
|
} finally {
|
|
setBusyKey(null)
|
|
}
|
|
}
|
|
async function confirmDeleteDest() {
|
|
const it = confirmingDest
|
|
if (!it) return
|
|
const force = it.jobsUsing.length > 0
|
|
setConfirmingDest(null)
|
|
if (it.kind === "pbs") return removePbs(it.name, force)
|
|
if (it.kind === "borg") return removeBorg(it.name, force)
|
|
if (it.kind === "local") return removeLocal(it.path, force)
|
|
}
|
|
// Detach a USB drive — only the filesystem is unmounted; the path
|
|
// stays in local-target.conf so re-plugging the same disk picks it
|
|
// up again without re-adding the destination.
|
|
async function unmountUsb(mountpoint: string) {
|
|
setBusyKey(`umount:${mountpoint}`)
|
|
setError(null)
|
|
try {
|
|
await fetchApi("/api/host-backups/usb-drives/unmount", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ mountpoint }),
|
|
})
|
|
onChanged()
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : String(e))
|
|
} finally {
|
|
setBusyKey(null)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
<div className="flex justify-end">
|
|
<Button
|
|
size="sm"
|
|
className="h-8 px-3 bg-blue-500 hover:bg-blue-600 text-white"
|
|
onClick={() => setConfiguring(true)}
|
|
>
|
|
<Plus className="h-4 w-4 mr-1" />
|
|
{t("backup.destinations.configureDestination")}
|
|
</Button>
|
|
</div>
|
|
{error && (
|
|
<div className="text-xs text-red-500 px-3 py-2 rounded-md border border-red-500/30 bg-red-500/10 whitespace-pre-wrap break-words">
|
|
{error}
|
|
</div>
|
|
)}
|
|
{/* Recovery banner: only when there's no local keyfile AND at
|
|
least one escrow blob was found on a configured PBS. Click
|
|
opens a passphrase dialog and the keyfile is rebuilt from
|
|
the blob. Equivalent to hb_pbs_try_keyfile_recovery in the
|
|
shell — what runs after a fresh PVE install when the
|
|
operator points the new host at the same PBS. */}
|
|
{hasPbs && pbsRecoveryStatus && !pbsRecoveryStatus.has_keyfile && (pbsRecoveryAvailable?.snapshots?.length ?? 0) > 0 && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setRecoveringKeyfile(true)}
|
|
className="w-full flex items-start gap-2 px-3 py-2.5 rounded-md border border-emerald-500/40 bg-emerald-500/5 hover:bg-emerald-500/10 transition-colors text-left"
|
|
title={t("backup.recovery.copyFoundTitle")}
|
|
>
|
|
<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">
|
|
{t("backup.recovery.keyfileMissingTitle")}
|
|
</div>
|
|
<div className="text-[11px] text-muted-foreground mt-0.5">
|
|
{t("backup.recovery.encryptedCopyAvailable", { count: pbsRecoveryAvailable!.snapshots.length })}
|
|
</div>
|
|
</div>
|
|
<span className="text-xs text-emerald-300 shrink-0 self-center">{t("backup.recovery.recoverArrow")}</span>
|
|
</button>
|
|
)}
|
|
|
|
<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}`)
|
|
// Borg local-mode repos sit on top of a path — the same
|
|
// /mnt/proxmenux-backup-disk-* USB mountpoint a Local target
|
|
// would use. Expose Unmount for them too so the operator
|
|
// doesn't have to remember which destination type is on the
|
|
// disk they want to detach.
|
|
const borgLocalUsbPath = it.kind === "borg" && !it.isSsh && cap?.is_usb
|
|
? it.repository
|
|
: null
|
|
const umountTarget = it.kind === "local" ? it.path : borgLocalUsbPath
|
|
const unmountBusy = !!umountTarget && busyKey === `umount:${umountTarget}`
|
|
return (
|
|
<DestinationRow
|
|
key={it.id}
|
|
item={it}
|
|
capacity={cap}
|
|
busy={busy}
|
|
unmountBusy={unmountBusy}
|
|
onDelete={() => setConfirmingDest(it)}
|
|
onUnmount={
|
|
umountTarget && cap?.is_usb
|
|
? () => unmountUsb(umountTarget)
|
|
: undefined
|
|
}
|
|
onEdit={
|
|
// Only PBS-manual and Borg are editable from the UI.
|
|
// PBS-proxmox is managed by Datacenter → Storage and
|
|
// local destinations have no fields beyond the path.
|
|
it.kind === "pbs" && it.source === "manual"
|
|
? () => {
|
|
const [user, rest] = it.repository.includes("@")
|
|
? [it.repository.split("@").slice(0, -1).join("@"), it.repository.split("@").pop() || ""]
|
|
: ["root@pam", it.repository]
|
|
const [server, datastore] = rest.includes(":")
|
|
? [rest.split(":").slice(0, -1).join(":"), rest.split(":").pop() || ""]
|
|
: [rest, ""]
|
|
setEditingDest({
|
|
kind: "pbs",
|
|
name: it.name,
|
|
username: user,
|
|
server,
|
|
datastore,
|
|
fingerprint: it.fingerprint,
|
|
has_password: true,
|
|
})
|
|
}
|
|
: it.kind === "borg"
|
|
? () => setEditingDest({
|
|
kind: "borg",
|
|
name: it.name,
|
|
repository: it.repository,
|
|
ssh_key_path: it.sshKeyPath,
|
|
encrypt_mode: (destinations?.borg?.find((b) => b.name === it.name)?.encrypt_mode) || "repokey",
|
|
has_passphrase: !!destinations?.borg?.find((b) => b.name === it.name)?.has_passphrase,
|
|
})
|
|
: undefined
|
|
}
|
|
/>
|
|
)
|
|
})}
|
|
</div>
|
|
<ConfigureDestinationWizard
|
|
open={configuring}
|
|
onClose={() => setConfiguring(false)}
|
|
onSaved={() => {
|
|
setConfiguring(false)
|
|
onChanged()
|
|
}}
|
|
/>
|
|
{/* Edit-an-existing-destination dialog. Uses the same form as
|
|
Add, just hydrated with the saved values and with `name`
|
|
locked. The POST endpoint is upsert-by-name on the backend
|
|
so no separate route is needed. */}
|
|
<AddDestinationDialog
|
|
type={editingDest?.kind ?? null}
|
|
editing={editingDest}
|
|
onClose={() => setEditingDest(null)}
|
|
onSaved={() => {
|
|
setEditingDest(null)
|
|
onChanged()
|
|
}}
|
|
/>
|
|
<PbsKeyfileRecoveryDialog
|
|
open={recoveringKeyfile}
|
|
snapshots={pbsRecoveryAvailable?.snapshots || []}
|
|
onClose={() => setRecoveringKeyfile(false)}
|
|
onRecovered={() => {
|
|
setRecoveringKeyfile(false)
|
|
mutatePbsRecovery()
|
|
}}
|
|
/>
|
|
{/* Delete-destination confirm. Always opens (uniform UX) but
|
|
the body adapts: a plain "Remove?" when no jobs/backups
|
|
depend on the destination, or a warning summarizing the
|
|
cascade (jobs go, backups stay) when there's something at
|
|
stake. The destination + its sidecars are removed; backups
|
|
on disk / on the remote stay untouched so the operator can
|
|
decide what to do with them later. */}
|
|
{confirmingDest && (() => {
|
|
const it = confirmingDest
|
|
const jobs = it.jobsUsing
|
|
const backups = backupsCountFor(it)
|
|
const headline =
|
|
it.kind === "local" ? it.path
|
|
: it.kind === "pbs" ? `${it.name} — ${it.repository}`
|
|
: `${it.name} — ${it.repository}`
|
|
return (
|
|
<Dialog open={true} onOpenChange={(v) => { if (!v) setConfirmingDest(null) }}>
|
|
<DialogContent className="max-w-md bg-card border-border">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-base flex items-center gap-2">
|
|
<AlertTriangle className={`h-5 w-5 ${jobs.length > 0 ? "text-amber-500" : "text-red-500"}`} />
|
|
{t("backup.destinations.removeDestination")}
|
|
</DialogTitle>
|
|
<DialogDescription className="text-xs">
|
|
{jobs.length === 0 && backups === 0
|
|
? t("backup.destinations.removeDescriptionPlain")
|
|
: t("backup.destinations.removeDescriptionWithData")}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="text-sm font-mono px-3 py-2 rounded-md border border-border bg-card 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">
|
|
{t("backup.destinations.jobsWillBeDeleted", { count: jobs.length })}
|
|
</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">
|
|
{t("backup.destinations.backupsKeptBefore", { count: backups })} <span className="font-medium">{t("backup.destinations.kept")}</span>.
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
<div className="flex justify-end gap-2">
|
|
<Button variant="ghost" onClick={() => setConfirmingDest(null)}>
|
|
{t("actions.cancel")}
|
|
</Button>
|
|
<Button variant="destructive" onClick={confirmDeleteDest}>
|
|
<Trash2 className="h-4 w-4 mr-2" />
|
|
{jobs.length > 0 ? t("backup.destinations.removeAndDeleteJobs") : t("backup.destinations.removeDestination")}
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
})()}
|
|
</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,
|
|
onEdit,
|
|
}: {
|
|
item: UnifiedDest
|
|
capacity?: CapacityInfo
|
|
busy: boolean
|
|
unmountBusy: boolean
|
|
onDelete: () => void
|
|
onUnmount?: () => void
|
|
onEdit?: () => void
|
|
}) {
|
|
const t = useT()
|
|
const accent =
|
|
item.kind === "pbs" ? "bg-purple-500/10 text-purple-400 border-purple-500/20"
|
|
: item.kind === "borg" ? "bg-fuchsia-500/10 text-fuchsia-400 border-fuchsia-500/20"
|
|
: "bg-blue-500/10 text-blue-400 border-blue-500/20"
|
|
const iconColor =
|
|
item.kind === "pbs" ? "text-purple-400"
|
|
: item.kind === "borg" ? "text-fuchsia-400"
|
|
: "text-blue-400"
|
|
const Icon = item.kind === "pbs" ? Server : item.kind === "borg" ? Archive : HardDrive
|
|
const headline =
|
|
item.kind === "pbs" ? item.name
|
|
: item.kind === "borg" ? item.name
|
|
: item.path
|
|
const subline =
|
|
item.kind === "pbs" ? item.repository
|
|
: item.kind === "borg" ? item.repository
|
|
: null
|
|
const isUsb = !!capacity?.is_usb || (item.kind === "borg" && !item.isSsh && /\/(?:mnt|media)\//.test(item.repository))
|
|
const sourceLabel =
|
|
item.kind === "local" ? (item.source === "default" ? t("backup.destinations.builtInDefault") : t("backup.destinations.manuallyAdded"))
|
|
: item.kind === "pbs" ? (item.source === "proxmox" ? t("backup.destinations.datacenterStorage") : t("backup.destinations.manuallyAdded"))
|
|
// Borg targets are always operator-configured (no Datacenter
|
|
// auto-discovery path), so they all share the "manually added"
|
|
// wording for visual consistency with the Local custom row.
|
|
: t("backup.destinations.manuallyAdded")
|
|
|
|
const pct = capacity?.total && capacity.available !== undefined
|
|
? Math.min(100, Math.round(((capacity.total - capacity.available) / capacity.total) * 100))
|
|
: null
|
|
|
|
return (
|
|
<div className="rounded-lg border border-white/10 bg-card p-4">
|
|
{/* Top row split in two: a wrappable left side (icon + headline +
|
|
badges) and a fixed right side (Unmount + Remove). The right
|
|
side never falls to a new line even if `headline` is long,
|
|
because the outer container is a non-wrapping flex. */}
|
|
<div className="space-y-2 mb-3">
|
|
<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}`}>
|
|
{localizedBackendLabel(item.kind, t)}
|
|
</Badge>
|
|
{item.kind === "local" && item.source === "default" && (
|
|
<Badge variant="outline" className="text-[10px] uppercase tracking-wide border-border text-muted-foreground">
|
|
{t("backup.profile.default")}
|
|
</Badge>
|
|
)}
|
|
{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">
|
|
{t("backup.backends.local")}
|
|
</Badge>
|
|
)}
|
|
{/* Encryption indicator — icon-only chip for encrypted
|
|
repos. Plaintext repos get no chip (visually quiet
|
|
for the common case). The tooltip carries the mode +
|
|
saved-passphrase state for hover detail. */}
|
|
{item.encryptMode && item.encryptMode !== "none" && (
|
|
<Badge
|
|
variant="outline"
|
|
className="text-[10px] uppercase tracking-wide border-emerald-500/40 text-emerald-400 bg-emerald-500/5"
|
|
title={t("backup.encryption.encryptedModeTitle", { mode: item.encryptMode, passphrase: item.hasPassphrase ? t("backup.encryption.passphraseSaved") : t("backup.encryption.passphraseNotSaved") })}
|
|
>
|
|
{/* Icon sized to roughly the text-[10px] line-box of
|
|
the neighbouring text badges (BORG, USB, …) so the
|
|
pill itself ends up the same height — matching
|
|
visual weight without adding a label. */}
|
|
<Lock className="h-3.5 w-3.5" />
|
|
</Badge>
|
|
)}
|
|
</>
|
|
) : isUsb && (
|
|
// PBS / Local destinations: USB badge is additive (the kind
|
|
// badge alone doesn't say where the path lives).
|
|
<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>
|
|
)}
|
|
</div>
|
|
{/* Action chips — always pinned to the right, never wrap.
|
|
The outer flex container has no flex-wrap so even a very
|
|
long headline keeps these two buttons in place. Chip
|
|
style (square border + tinted bg) makes them read as
|
|
actionable rather than decorative icons. */}
|
|
<div className="shrink-0 flex items-start gap-1.5">
|
|
{onEdit && (
|
|
<button
|
|
type="button"
|
|
disabled={busy || unmountBusy}
|
|
onClick={onEdit}
|
|
title={t("backup.destinations.editTitle")}
|
|
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={t("backup.destinations.unmountTitle")}
|
|
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={t("backup.destinations.removeTitle")}
|
|
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" ? t("backup.destinations.builtIn") : t("backup.destinations.managedByPve")}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{subline && (
|
|
<p className="text-xs font-mono text-muted-foreground break-all pl-7" title={subline}>
|
|
{subline}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Capacity bar — matches the height of the <Progress> component
|
|
used on the Storage page (h-2). Bar colour comes from the
|
|
shared storage palette so a 100% PBS datastore flags red
|
|
here too, not just on the Storage page. */}
|
|
{capacity?.total && capacity.available !== undefined && (
|
|
<div className="mb-3 h-2 bg-muted rounded-full overflow-hidden">
|
|
<div
|
|
className={`h-full rounded-full ${getStorageUsageColor(pct ?? 0).bgClass}`}
|
|
style={{ width: `${pct}%` }}
|
|
/>
|
|
</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">{t("backup.fields.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">{t("backup.fields.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">{t("backup.fields.free")}</p>
|
|
<p
|
|
className={`font-medium ${
|
|
pct === null ? "" : getStorageUsageColor(pct).textClass
|
|
}`}
|
|
>
|
|
{formatBytes(capacity.available)}
|
|
</p>
|
|
</div>
|
|
)}
|
|
<div>
|
|
<p className="text-sm text-muted-foreground">{t("backup.fields.source")}</p>
|
|
<p className="font-medium text-xs">{sourceLabel}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{capacity?.error && (
|
|
<p className="mt-3 text-[11px] text-muted-foreground italic">
|
|
{t("backup.destinations.capacityUnavailable", { error: 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" /> {t("backup.destinations.probingCapacity")}
|
|
</p>
|
|
)}
|
|
{/* Keyfile actions live inside each PBS row — the encryption key
|
|
is host-wide but conceptually belongs to PBS usage, so the
|
|
Download / Upload / Delete controls sit next to the
|
|
destination(s) they support. */}
|
|
{item.kind === "pbs" && <PbsKeyfileActions pbsRepository={item.repository} />}
|
|
</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 t = useT()
|
|
const [picked, setPicked] = useState<"pbs" | "local" | "borg" | null>(null)
|
|
useEffect(() => {
|
|
if (!open) setPicked(null)
|
|
}, [open])
|
|
|
|
if (picked) {
|
|
// Hand off to the existing form. Closing or saving collapses the
|
|
// whole wizard so the operator lands back in the destinations list.
|
|
return (
|
|
<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" />
|
|
{t("backup.destinations.configureDestination")}
|
|
</DialogTitle>
|
|
<DialogDescription className="text-xs">
|
|
{t("backup.destinations.configureDescription")}
|
|
</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: t("backup.backends.pbsDescription") },
|
|
{ type: "local", label: t("backup.backends.local"), accent: "border-blue-500/40 hover:border-blue-400 text-blue-400", blurb: t("backup.backends.localDescription") },
|
|
{ type: "borg", label: "Borg", accent: "border-fuchsia-500/40 hover:border-fuchsia-400 text-fuchsia-400", blurb: t("backup.backends.borgDescription") },
|
|
] as const).map((opt) => (
|
|
<button
|
|
key={opt.type}
|
|
type="button"
|
|
onClick={() => setPicked(opt.type)}
|
|
className={`text-left rounded-lg border-2 p-4 transition-colors bg-card 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}>{t("actions.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.
|
|
// Pre-fill payload for editing an existing destination. `name` stays
|
|
// fixed (it's the key the sidecar / state files are scoped to);
|
|
// everything else can be re-typed. PBS password and Borg passphrase
|
|
// can be left blank to keep the saved one.
|
|
interface EditingDest {
|
|
kind: "pbs" | "borg"
|
|
name: string
|
|
// PBS fields
|
|
server?: string
|
|
datastore?: string
|
|
username?: string
|
|
fingerprint?: string
|
|
has_password?: boolean
|
|
// Borg fields
|
|
repository?: string
|
|
ssh_key_path?: string
|
|
encrypt_mode?: "none" | "repokey" | "keyfile" | "authenticated"
|
|
has_passphrase?: boolean
|
|
}
|
|
|
|
function AddDestinationDialog({
|
|
type,
|
|
editing,
|
|
onClose,
|
|
onSaved,
|
|
}: {
|
|
type: "pbs" | "borg" | "local" | null
|
|
editing?: EditingDest | null
|
|
onClose: () => void
|
|
// The repository string of the just-saved destination, so callers
|
|
// can auto-select it in a dropdown (the wizard does this when an
|
|
// operator adds a PBS / Borg repo inline). Undefined for `local`
|
|
// and for the legacy callers that don't care.
|
|
onSaved: (repository?: string) => void
|
|
}) {
|
|
const t = useT()
|
|
const [name, setName] = useState("")
|
|
// PBS fields
|
|
const [server, setServer] = useState("")
|
|
const [datastore, setDatastore] = useState("")
|
|
const [username, setUsername] = useState("root@pam")
|
|
const [password, setPassword] = useState("")
|
|
const [fingerprint, setFingerprint] = useState("")
|
|
// Borg fields (local mode)
|
|
const [borgRepo, setBorgRepo] = useState("")
|
|
// Borg SSH mode
|
|
const [borgMode, setBorgMode] = useState<"local" | "ssh">("local")
|
|
const [borgSshUser, setBorgSshUser] = useState("borg")
|
|
const [borgSshHost, setBorgSshHost] = useState("")
|
|
// Custom SSH port for NAS-style hosts that expose SSH on non-22.
|
|
// Kept as string in state so the input can start empty; converted to
|
|
// integer at submit time. Empty / "22" means "use the default port"
|
|
// and is omitted from the ssh:// URL by the backend.
|
|
const [borgSshPort, setBorgSshPort] = useState("22")
|
|
const [borgSshRemotePath, setBorgSshRemotePath] = useState("")
|
|
const [borgSshKeyPath, setBorgSshKeyPath] = useState("/root/.ssh/proxmenux_borg")
|
|
const [generatedKey, setGeneratedKey] = useState<{ public_key: string; authorized_keys_line: string } | null>(null)
|
|
const [generatingKey, setGeneratingKey] = useState(false)
|
|
// Borg encryption + passphrase live on the destination, not on each
|
|
// job. Two-mode UI: encrypted (repokey, the default) or none. Other
|
|
// borg modes (keyfile / authenticated) stay supported by the backend
|
|
// for legacy shell-created configs.
|
|
const [borgEncryptionEnabled, setBorgEncryptionEnabled] = useState(true)
|
|
const [borgPassphrase, setBorgPassphraseLocal] = useState("")
|
|
const [borgPassphrase2, setBorgPassphrase2] = useState("")
|
|
// Local field
|
|
const [localPath, setLocalPath] = useState("")
|
|
|
|
const [submitting, setSubmitting] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
// Read the current destinations so the UsbPicker can hide any USB
|
|
// mountpoint already in use as a Local or Borg-local destination —
|
|
// no point offering the same disk twice.
|
|
const { data: existingDest } = useSWR<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).
|
|
useEffect(() => {
|
|
setError(null)
|
|
setSubmitting(false)
|
|
setGeneratedKey(null)
|
|
setGeneratingKey(false)
|
|
setPassword("")
|
|
setBorgPassphraseLocal("")
|
|
setBorgPassphrase2("")
|
|
if (editing && editing.kind === "pbs") {
|
|
setName(editing.name)
|
|
setServer(editing.server || "")
|
|
setDatastore(editing.datastore || "")
|
|
setUsername(editing.username || "root@pam")
|
|
setFingerprint(editing.fingerprint || "")
|
|
setBorgRepo(""); setBorgMode("local"); setBorgSshUser("borg")
|
|
setBorgSshHost(""); setBorgSshPort("22"); setBorgSshRemotePath("")
|
|
setBorgSshKeyPath("/root/.ssh/proxmenux_borg")
|
|
setBorgEncryptionEnabled(true); setLocalPath("")
|
|
return
|
|
}
|
|
if (editing && editing.kind === "borg") {
|
|
const repo = editing.repository || ""
|
|
// Match ssh://user@host[:port]/path — the port group is optional.
|
|
// Group 3 captures the numeric port when present, group 4 the path.
|
|
const ssh = repo.match(/^ssh:\/\/([^@]+)@([^/:]+)(?::(\d+))?\/(.+)$/)
|
|
setName(editing.name)
|
|
if (ssh) {
|
|
setBorgMode("ssh")
|
|
setBorgSshUser(ssh[1])
|
|
setBorgSshHost(ssh[2])
|
|
// Prefer the port already parsed by the backend (ssh_port); fall
|
|
// back to the URL match; default 22 when neither is set.
|
|
const port = editing.ssh_port ?? (ssh[3] ? Number(ssh[3]) : 22)
|
|
setBorgSshPort(String(port || 22))
|
|
setBorgSshRemotePath(`/${ssh[4]}`)
|
|
setBorgSshKeyPath(editing.ssh_key_path || "/root/.ssh/proxmenux_borg")
|
|
setBorgRepo("")
|
|
} else {
|
|
setBorgMode("local")
|
|
setBorgRepo(repo)
|
|
setBorgSshUser("borg"); setBorgSshHost(""); setBorgSshPort("22"); setBorgSshRemotePath("")
|
|
setBorgSshKeyPath("/root/.ssh/proxmenux_borg")
|
|
}
|
|
const mode = editing.encrypt_mode || "repokey"
|
|
setBorgEncryptionEnabled(mode !== "none")
|
|
setServer(""); setDatastore(""); setUsername("root@pam"); setFingerprint("")
|
|
setLocalPath("")
|
|
return
|
|
}
|
|
// Add-new path: full reset.
|
|
setName("")
|
|
setServer("")
|
|
setDatastore("")
|
|
setUsername("root@pam")
|
|
setFingerprint("")
|
|
setBorgRepo("")
|
|
setBorgMode("local")
|
|
setBorgSshUser("borg")
|
|
setBorgSshHost("")
|
|
setBorgSshPort("22")
|
|
setBorgSshRemotePath("")
|
|
setBorgSshKeyPath("/root/.ssh/proxmenux_borg")
|
|
setBorgEncryptionEnabled(true)
|
|
setLocalPath("")
|
|
}, [type, editing])
|
|
|
|
const isEditing = !!editing
|
|
const nameValid = /^[a-zA-Z0-9_-]+$/.test(name)
|
|
const borgPathValid =
|
|
borgMode === "local"
|
|
? !!borgRepo.trim()
|
|
: !!(borgSshUser.trim() && borgSshHost.trim() && borgSshRemotePath.trim())
|
|
// In edit mode the saved passphrase / password is reused when the
|
|
// operator leaves the inputs blank, so validation relaxes.
|
|
const borgPassphraseValid =
|
|
!borgEncryptionEnabled
|
|
|| (isEditing && !borgPassphrase && editing?.has_passphrase)
|
|
|| (borgPassphrase.length > 0 && borgPassphrase === borgPassphrase2)
|
|
const borgValid = borgPathValid && borgPassphraseValid
|
|
const canSubmit =
|
|
type === "pbs"
|
|
? nameValid && server.trim() && datastore.trim() &&
|
|
(!!password || (isEditing && !!editing?.has_password))
|
|
: type === "borg"
|
|
? nameValid && borgValid
|
|
: type === "local"
|
|
? localPath.trim().startsWith("/")
|
|
: false
|
|
|
|
async function generateBorgKey() {
|
|
setGeneratingKey(true)
|
|
setError(null)
|
|
try {
|
|
const resp = await fetchApi<{ public_key: string; authorized_keys_line: string }>(
|
|
"/api/host-backups/ssh-keys/generate",
|
|
{
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
key_path: borgSshKeyPath.trim() || "/root/.ssh/proxmenux_borg",
|
|
remote_path: borgSshRemotePath.trim(),
|
|
}),
|
|
}
|
|
)
|
|
setGeneratedKey(resp)
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : String(e))
|
|
} finally {
|
|
setGeneratingKey(false)
|
|
}
|
|
}
|
|
|
|
async function handleSave() {
|
|
if (!canSubmit) return
|
|
setSubmitting(true)
|
|
setError(null)
|
|
try {
|
|
let savedRepo: string | undefined
|
|
if (type === "pbs") {
|
|
const resp = await fetchApi<{ repository?: string }>("/api/host-backups/destinations/pbs", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
name,
|
|
server: server.trim(),
|
|
datastore: datastore.trim(),
|
|
username: username.trim() || "root@pam",
|
|
password,
|
|
fingerprint: fingerprint.trim() || undefined,
|
|
}),
|
|
})
|
|
savedRepo = resp?.repository
|
|
} else if (type === "borg") {
|
|
const body: Record<string, unknown> = {
|
|
name,
|
|
mode: borgMode,
|
|
encrypt_mode: borgEncryptionEnabled ? "repokey" : "none",
|
|
}
|
|
if (borgEncryptionEnabled) {
|
|
body.passphrase = borgPassphrase
|
|
}
|
|
if (borgMode === "local") {
|
|
body.repo = borgRepo.trim()
|
|
} else {
|
|
body.ssh_user = borgSshUser.trim()
|
|
body.ssh_host = borgSshHost.trim()
|
|
body.ssh_remote_path = borgSshRemotePath.trim()
|
|
if (borgSshKeyPath.trim()) body.ssh_key_path = borgSshKeyPath.trim()
|
|
// Only send port when it's non-default; the backend leaves 22
|
|
// out of the persisted URL so existing targets stay identical.
|
|
const portNum = parseInt(borgSshPort.trim(), 10)
|
|
if (Number.isFinite(portNum) && portNum > 0 && portNum !== 22) {
|
|
body.ssh_port = portNum
|
|
}
|
|
}
|
|
const resp = await fetchApi<{ repo?: string }>("/api/host-backups/destinations/borg", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
})
|
|
savedRepo = resp?.repo
|
|
} else if (type === "local") {
|
|
await fetchApi("/api/host-backups/destinations/local", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ path: localPath.trim() }),
|
|
})
|
|
}
|
|
onSaved(savedRepo)
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : String(e))
|
|
} finally {
|
|
setSubmitting(false)
|
|
}
|
|
}
|
|
|
|
const destinationTitle = type === "local"
|
|
? t(isEditing ? "backup.destinations.editLocalDestinationTitle" : "backup.destinations.addLocalDestinationTitle")
|
|
: t(isEditing ? "backup.destinations.editDestinationTitle" : "backup.destinations.addDestinationTitle", { type: type === "pbs" ? "PBS" : type === "borg" ? "Borg" : t("backup.backends.local") })
|
|
|
|
return (
|
|
<Dialog open={type !== null} onOpenChange={(v) => { if (!v) onClose() }}>
|
|
<DialogContent className="max-w-lg max-h-[85vh] flex flex-col bg-accent [&_input]:bg-background [&_textarea]:bg-background [&_[role=combobox]]:bg-background">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
{isEditing ? (
|
|
<Pencil className="h-5 w-5 text-emerald-400" />
|
|
) : (
|
|
<Plus className="h-5 w-5 text-blue-500" />
|
|
)}
|
|
{destinationTitle}
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="overflow-y-auto -mr-2 pr-2 space-y-3">
|
|
{type === "pbs" && (
|
|
<>
|
|
<div>
|
|
<Label htmlFor="pbsName">{t("backup.fields.name")}</Label>
|
|
<Input id="pbsName" value={name} onChange={(e) => setName(e.target.value)} className="font-mono mt-1" placeholder="moje-pbs" disabled={isEditing} />
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
{isEditing ? t("backup.destinations.nameCredentialsLocked") : t("backup.destinations.shortIdentifierHelp")}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="pbsServer">{t("backup.fields.serverHostOrIp")}</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">{t("backup.fields.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">{t("backup.fields.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">{t("backup.destinations.pbsUserHelpBefore")} <code className="font-mono">proxmox-backup-client</code>. {t("backup.destinations.defaultUserBefore")} <code className="font-mono">root@pam</code>.</p>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="pbsPass">{t("backup.fields.password")}</Label>
|
|
<Input
|
|
id="pbsPass"
|
|
type="password"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
className="font-mono mt-1"
|
|
placeholder={isEditing && editing?.has_password ? t("backup.placeholders.unchangedTypeToReplace") : ""}
|
|
/>
|
|
{isEditing && editing?.has_password && (
|
|
<p className="text-xs text-muted-foreground mt-1">{t("backup.destinations.leavePasswordBlank")}</p>
|
|
)}
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="pbsFp">{t("backup.fields.fingerprintOptional")}</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">{t("backup.destinations.fingerprintHelp")}</p>
|
|
</div>
|
|
</>
|
|
)}
|
|
{type === "borg" && (
|
|
<>
|
|
<div>
|
|
<Label htmlFor="borgName">{t("backup.fields.name")}</Label>
|
|
<Input id="borgName" value={name} onChange={(e) => setName(e.target.value)} className="font-mono mt-1" placeholder="usb-borg" disabled={isEditing} />
|
|
{isEditing && (
|
|
<p className="text-xs text-muted-foreground mt-1">{t("backup.destinations.namePassphraseLocked")}</p>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<Label>{t("backup.destinations.whereIsBorgRepo")}</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-card"} hover:bg-white/5 transition-colors`}
|
|
>
|
|
<div className="font-medium flex items-center gap-1"><HardDrive className="h-3.5 w-3.5" /> {t("backup.destinations.localUsb")}</div>
|
|
<div className="text-[11px] text-muted-foreground mt-0.5">{t("backup.destinations.localUsbDescription")}</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-card"} hover:bg-white/5 transition-colors`}
|
|
>
|
|
<div className="font-medium flex items-center gap-1"><Server className="h-3.5 w-3.5" /> {t("backup.destinations.remoteSsh")}</div>
|
|
<div className="text-[11px] text-muted-foreground mt-0.5">{t("backup.destinations.remoteSshDescription")}</div>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{borgMode === "local" ? (
|
|
<div className="space-y-3">
|
|
<div>
|
|
<Label htmlFor="borgRepoIn">{t("backup.fields.repositoryPath")}</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">
|
|
{t("backup.destinations.absolutePathHelp")}
|
|
</p>
|
|
</div>
|
|
<UsbPicker onPick={(p) => setBorgRepo(p)} excludePaths={usedPaths} />
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div>
|
|
<Label htmlFor="borgSshUser">{t("backup.fields.sshUser")}</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">
|
|
{t("backup.destinations.sshUserHelpBefore")} <code className="font-mono">borg serve</code>. {t("backup.destinations.sshUserHelpAfter")} <code className="font-mono">borg</code>, {t("backup.destinations.not")} <code className="font-mono">root</code>.
|
|
</p>
|
|
</div>
|
|
<div className="grid grid-cols-[1fr_100px] gap-3">
|
|
<div>
|
|
<Label htmlFor="borgSshHost">{t("backup.fields.sshHostOrIp")}</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="borgSshPort">{t("backup.fields.sshPort")}</Label>
|
|
<Input
|
|
id="borgSshPort"
|
|
value={borgSshPort}
|
|
onChange={(e) => setBorgSshPort(e.target.value.replace(/[^\d]/g, ""))}
|
|
className="font-mono mt-1"
|
|
placeholder="22"
|
|
inputMode="numeric"
|
|
maxLength={5}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="borgSshPath">{t("backup.fields.remoteRepositoryPath")}</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">{t("backup.fields.sshKeyPath")}</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">
|
|
{t("backup.destinations.sshKeyHelpBefore")}
|
|
{" "}{t("backup.destinations.sshKeyHelpMiddle")} <span className="font-medium text-foreground">{t("backup.actions.generateKey")}</span> {t("backup.destinations.sshKeyHelpAfter")}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="rounded-md border border-border bg-card p-3 space-y-2">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<span className="text-xs font-medium">{t("backup.destinations.generateNewSshKey")}</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 ? t("backup.actions.regenerate") : t("backup.actions.generateKey")}
|
|
</Button>
|
|
</div>
|
|
{generatedKey ? (
|
|
<>
|
|
<p className="text-[11px] text-muted-foreground">
|
|
{t("backup.destinations.appendAuthorizedKeyBefore")} <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">
|
|
{t("backup.destinations.createsSshKeyBefore")} <code className="font-mono">borg serve</code> {t("backup.destinations.createsSshKeyAfter")}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Encryption + passphrase live on the destination, not on
|
|
each job. Two-mode UI: encrypted (repokey, the borg
|
|
recommended default) or none. The other borg modes
|
|
(keyfile, authenticated) are still honored by the
|
|
backend for legacy shell-created configs but not
|
|
exposed here. */}
|
|
<div className="space-y-2 pt-2 border-t border-border">
|
|
<Label>{t("backup.fields.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-card hover:bg-white/5"}`}
|
|
>
|
|
<div className="font-medium">{t("backup.encryption.encryptedRepokey")}</div>
|
|
<div className="text-[11px] text-muted-foreground">{t("backup.encryption.encryptedRepokeyDescription")}</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-card hover:bg-white/5"}`}
|
|
>
|
|
<div className="font-medium">{t("backup.encryption.noEncryption")}</div>
|
|
<div className="text-[11px] text-muted-foreground">{t("backup.encryption.noEncryptionDescription")}</div>
|
|
</button>
|
|
</div>
|
|
{borgEncryptionEnabled && (
|
|
<div className="space-y-2 pt-1">
|
|
<div>
|
|
<Label htmlFor="borgPass">{t("backup.fields.passphrase")}</Label>
|
|
<Input
|
|
id="borgPass"
|
|
type="password"
|
|
value={borgPassphrase}
|
|
onChange={(e) => setBorgPassphraseLocal(e.target.value)}
|
|
placeholder={isEditing && editing?.has_passphrase
|
|
? t("backup.placeholders.unchangedTypeToReplace")
|
|
: t("backup.placeholders.longRandomStoreSafe")}
|
|
className="font-mono mt-1"
|
|
/>
|
|
{isEditing && editing?.has_passphrase && (
|
|
<p className="text-[11px] text-muted-foreground mt-1">{t("backup.destinations.leavePassphraseBlank")}</p>
|
|
)}
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="borgPass2">{t("backup.fields.confirmPassphrase")}</Label>
|
|
<Input
|
|
id="borgPass2"
|
|
type="password"
|
|
value={borgPassphrase2}
|
|
onChange={(e) => setBorgPassphrase2(e.target.value)}
|
|
placeholder={t("backup.placeholders.typeItAgain")}
|
|
className="font-mono mt-1"
|
|
/>
|
|
{borgPassphrase && borgPassphrase2 && borgPassphrase !== borgPassphrase2 && (
|
|
<p className="text-[11px] text-red-400 mt-1">{t("backup.errors.passphrasesDontMatch")}</p>
|
|
)}
|
|
</div>
|
|
<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">{t("backup.encryption.borgPassphraseOnlyWay")}</span> {t("backup.encryption.borgPassphraseSavedAt")} <code className="font-mono">borg-pass-{name || "<name>"}.txt</code> (chmod 0600) {t("backup.encryption.borgPassphraseSavedAfter")}
|
|
</p>
|
|
<p>
|
|
{t("backup.encryption.borgPassphraseLossBefore")} <span className="font-semibold">{t("backup.encryption.unrecoverable")}</span>.
|
|
</p>
|
|
<p className="font-medium">{t("backup.encryption.savePassphraseNow")}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
{type === "local" && (
|
|
<div className="space-y-3">
|
|
<div>
|
|
<Label htmlFor="localPath">{t("backup.fields.directoryPath")}</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">
|
|
{t("backup.destinations.localPathHelpBefore")} <code className="font-mono">tar.zst</code> {t("backup.destinations.localPathHelpAfter")}
|
|
</p>
|
|
</div>
|
|
<UsbPicker onPick={(p) => setLocalPath(p)} excludePaths={usedPaths} />
|
|
</div>
|
|
)}
|
|
{error && (
|
|
<div className="text-xs text-red-500 px-3 py-2 rounded-md border border-red-500/30 bg-red-500/10 whitespace-pre-wrap break-words">
|
|
{error}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="flex justify-end gap-2 pt-3 border-t border-border">
|
|
<Button variant="ghost" onClick={onClose} disabled={submitting}>{t("actions.cancel")}</Button>
|
|
<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" />}
|
|
{t("actions.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.
|
|
// ──────────────────────────────────────────────────────────────
|
|
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[]
|
|
}) {
|
|
const t = useT()
|
|
const { data, mutate, isLoading } = useSWR<{ drives: UsbDrive[] }>(
|
|
"/api/host-backups/usb-drives",
|
|
fetcher,
|
|
{ refreshInterval: 0 },
|
|
)
|
|
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)
|
|
})
|
|
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-card 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" />
|
|
{t("backup.usb.drivesDetected", { count: drives.length })}
|
|
{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">{t("actions.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" /> {t("backup.usb.scanning")}
|
|
</div>
|
|
) : drives.length === 0 ? (
|
|
<p className="text-[11px] text-muted-foreground italic">
|
|
{t("backup.usb.noneDetected")}
|
|
</p>
|
|
) : (
|
|
<div className="space-y-1.5">
|
|
{drives.map((d) => {
|
|
const busy = busyKey === d.path_or_device
|
|
const stateBadge =
|
|
d.state === "mounted"
|
|
? { label: t("backup.usb.mounted"), cls: "border-green-500/40 text-green-400 bg-green-500/5" }
|
|
: d.state === "unmounted"
|
|
? { label: t("backup.usb.unmounted"), cls: "border-amber-500/40 text-amber-400 bg-amber-500/5" }
|
|
: { label: t("backup.usb.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)}
|
|
>
|
|
{t("backup.actions.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}
|
|
{t("backup.usb.mountAndUse")}
|
|
</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("") }}
|
|
>
|
|
{t("backup.usb.formatAndUse")}
|
|
</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" />
|
|
{t("backup.usb.formatDrive")}
|
|
</DialogTitle>
|
|
<DialogDescription className="text-xs">
|
|
{t("backup.usb.formatDescription")}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
{formatTarget && (
|
|
<div className="space-y-2">
|
|
<div className="text-xs font-mono px-3 py-2 rounded-md border border-border bg-card break-all">
|
|
{formatTarget.path_or_device}
|
|
{formatTarget.size && <span className="text-muted-foreground"> · {formatTarget.size}</span>}
|
|
</div>
|
|
<Label htmlFor="formatConfirmInput" className="text-xs">
|
|
{t("backup.usb.typeDeviceToConfirm")}
|
|
</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}
|
|
>
|
|
{t("actions.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" />
|
|
)}
|
|
{t("backup.usb.wipeAndFormat")}
|
|
</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 t = useT()
|
|
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">
|
|
{t("backup.extraPaths.descriptionBefore")} <span className="font-medium text-foreground">{t("backup.extraPaths.everyBackup")}</span> {t("backup.extraPaths.descriptionAfter", { count: paths.length })}
|
|
</p>
|
|
|
|
<div className="flex flex-col sm:flex-row gap-2">
|
|
<Input
|
|
value={newPath}
|
|
onChange={(e) => setNewPath(e.target.value)}
|
|
placeholder={t("backup.placeholders.extraPathExamples")}
|
|
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" />}
|
|
{t("backup.actions.addPath")}
|
|
</Button>
|
|
</div>
|
|
<p className="text-[11px] text-muted-foreground">
|
|
{t("backup.extraPaths.absolutePathHintBefore")} <code className="font-mono">/hola</code> {t("backup.extraPaths.absolutePathHintMiddle")} <code className="font-mono">/root/hola</code> {t("backup.extraPaths.absolutePathHintAfter")}
|
|
</p>
|
|
|
|
{error && (
|
|
<div className="text-xs text-red-500 px-3 py-2 rounded-md border border-red-500/30 bg-red-500/10 whitespace-pre-wrap break-words">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{paths.length === 0 ? (
|
|
<div className="text-xs text-muted-foreground py-3 text-center">
|
|
{t("backup.extraPaths.empty")}
|
|
</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-card"
|
|
>
|
|
<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">
|
|
{t("backup.extraPaths.missingOnDisk")}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
disabled={busyPath === p.path}
|
|
onClick={() => removePath(p.path)}
|
|
title={t("backup.extraPaths.removeTitle")}
|
|
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"
|
|
>
|
|
{busyPath === p.path ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Trash2 className="h-3.5 w-3.5" />}
|
|
</button>
|
|
</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
|
|
}) {
|
|
const t = useT()
|
|
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">{t("backup.configuration.title")}</CardTitle>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-6">
|
|
{/* Destinations — list + add/remove of PBS / Borg / Local targets.
|
|
USB drives are no longer a separate section: they surface in
|
|
this list when mounted, and the add-destination wizard can
|
|
mount / format them inline. */}
|
|
<section className="space-y-2">
|
|
<h3 className="text-sm font-medium">{t("backup.destinations.title")}</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">{t("backup.extraPaths.title")}</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 t = useT()
|
|
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(t("backup.usb.unmountConfirm", { path: d.path_or_device }))) 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">
|
|
{t("backup.usb.description")} <code className="font-mono">ext4</code>.
|
|
</p>
|
|
|
|
{error && (
|
|
<div className="text-xs text-red-500 px-3 py-2 rounded-md border border-red-500/30 bg-red-500/10 whitespace-pre-wrap break-words">
|
|
{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" />
|
|
{t("backup.usb.scanning")}
|
|
</div>
|
|
) : drives.length === 0 ? (
|
|
<div className="text-xs text-muted-foreground py-3 text-center">
|
|
{t("backup.usb.empty")}
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{drives.map((d) => {
|
|
const isBusy = busyKey === d.path_or_device
|
|
const stateBadge =
|
|
d.state === "mounted"
|
|
? { label: t("backup.usb.stateMounted"), cls: "text-emerald-400 border-emerald-400/40" }
|
|
: d.state === "unmounted"
|
|
? { label: t("backup.usb.stateUnmounted"), cls: "text-amber-500 border-amber-500/40" }
|
|
: { label: t("backup.usb.stateEmpty"), 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-card"
|
|
>
|
|
<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">{t("backup.fields.label")}:</span> <code className="font-mono">{d.label}</code></span>}
|
|
{d.size && <span><span className="text-muted-foreground/70">{t("backup.fields.size")}:</span> <code className="font-mono">{d.size}</code></span>}
|
|
{d.fstype && <span><span className="text-muted-foreground/70">{t("backup.fields.filesystemShort")}:</span> <code className="font-mono">{d.fstype}</code></span>}
|
|
{d.uuid && <span className="hidden sm:inline" title={t("backup.usb.uuidTitle", { 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">{t("backup.actions.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">{t("backup.actions.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={t("backup.usb.formatTitle")}
|
|
>
|
|
<AlertTriangle className="h-3.5 w-3.5" />
|
|
<span className="ml-1 text-xs">{t("backup.actions.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" />
|
|
{t("backup.usb.formatDriveQuestion")}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{t("backup.usb.formatWarning")}
|
|
</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">{t("backup.fields.target")}</div>
|
|
<div className="font-mono text-base">{formatTarget.path_or_device}</div>
|
|
<div className="text-xs text-muted-foreground">{t("backup.fields.size")}: {formatTarget.size || "?"}</div>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="formatConfirm">{t("backup.usb.typeDeviceExactly")}</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">
|
|
{t("backup.usb.repartitionHint")}
|
|
</p>
|
|
</div>
|
|
)}
|
|
<div className="flex justify-end gap-2 mt-2">
|
|
<Button variant="ghost" onClick={() => { setFormatTarget(null); setFormatTyped("") }} disabled={busyKey === formatTarget?.path_or_device}>
|
|
{t("actions.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" />
|
|
)}
|
|
{t("backup.usb.wipeAndFormat")}
|
|
</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 t = useT()
|
|
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])
|
|
|
|
// 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])
|
|
|
|
// 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(t("backup.errors.runFailed", { error: 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(t("backup.errors.toggleFailed", { error: 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: t("backup.taskStates.running"), cls: "bg-blue-500/10 border-blue-500/40 text-blue-300" }
|
|
: lastResult === "ok"
|
|
? { label: t("backup.status.ok"), cls: "bg-emerald-500/10 border-emerald-500/40 text-emerald-300" }
|
|
: lastResult
|
|
? { label: lastResult, cls: "bg-red-500/10 border-red-500/40 text-red-300" }
|
|
: null
|
|
|
|
return (
|
|
<>
|
|
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
|
{/* Sized down to 3xl now that the log tail lives in the
|
|
archive's inspect modal — the job dialog only needs to fit
|
|
the schedule, retention chips, paths grid and destination.
|
|
overflow-hidden + the inner ScrollArea keep any long repo
|
|
or path string from forcing a horizontal scrollbar. */}
|
|
<DialogContent className="max-w-3xl bg-card border-border overflow-hidden">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2 flex-wrap text-base pr-8">
|
|
<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>
|
|
{/* Encryption badge — mirrors the InspectModal one
|
|
so the operator sees the lock in both the summary
|
|
list and the job detail view. */}
|
|
{(detail.pbs_encrypt || (detail.borg_encrypt_mode && detail.borg_encrypt_mode !== "none")) && (
|
|
<Badge
|
|
variant="outline"
|
|
className="text-emerald-400 border-emerald-500/40 bg-emerald-500/10"
|
|
title={t("backup.archives.encrypted")}
|
|
>
|
|
<Lock className="h-3.5 w-3.5" />
|
|
</Badge>
|
|
)}
|
|
{detail.attached && (
|
|
<Badge variant="outline" className="text-[10px] text-blue-400 border-blue-400/40 bg-blue-500/5">
|
|
{t("backup.status.attached")}
|
|
</Badge>
|
|
)}
|
|
{!detail.enabled && (
|
|
<Badge variant="outline" className="text-[10px] text-amber-500 border-amber-500/40 bg-amber-500/5">
|
|
{t("status.disabled")}
|
|
</Badge>
|
|
)}
|
|
</>
|
|
)}
|
|
</DialogTitle>
|
|
<DialogDescription className="text-xs">
|
|
{t("backup.jobs.detailDescription")}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
{actionError && (
|
|
<div className="text-xs text-red-500 px-3 py-2 rounded-md border border-red-500/30 bg-red-500/10 whitespace-pre-wrap break-words">
|
|
{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" />
|
|
{t("backup.jobs.loadingJob")}
|
|
</div>
|
|
) : (
|
|
<ScrollArea className="max-h-[60vh] pr-2">
|
|
{(() => {
|
|
// One accent color per section. The destination color is
|
|
// bound to the job's backend (purple/blue/fuchsia) so the
|
|
// section feels like a continuation of the method badge in
|
|
// the title; the other sections use neutral accent colors
|
|
// that match their semantic (time → amber, content → emerald).
|
|
const destAccent =
|
|
detail.method === "pbs"
|
|
? "text-purple-400"
|
|
: detail.method === "borg"
|
|
? "text-fuchsia-400"
|
|
: "text-blue-400"
|
|
return (
|
|
<div className="space-y-4 text-sm">
|
|
{/* ─── Last run ─────────────────────────── */}
|
|
{/* The job modal shows only the verdict + timestamp.
|
|
The actual log lives in the archive's inspect
|
|
modal where it belongs (one log per .tar.zst). */}
|
|
<section className="space-y-2">
|
|
<h4 className="text-xs font-semibold uppercase tracking-wide flex items-center gap-1.5 text-green-500">
|
|
<Clock className="h-3.5 w-3.5" /> {t("backup.jobs.lastRun")}
|
|
</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">{t("backup.jobs.neverRun")}</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 : t("backup.jobs.waitingForRunner")}
|
|
</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
|
|
? t("backup.jobs.liveLogSize", { size: formatBytes(liveLog?.size ?? 0) })
|
|
: t("backup.jobs.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" /> {t("backup.schedule.title")}
|
|
</h4>
|
|
<Field
|
|
icon={<Calendar className="h-3 w-3 text-green-500/80" />}
|
|
label={t("backup.fields.when")}
|
|
value={humanizeOnCalendar(detail.on_calendar, t)}
|
|
labelClassName="text-green-500/90"
|
|
/>
|
|
{detail.next_run && (
|
|
<Field
|
|
icon={<Clock className="h-3 w-3 text-green-500/80" />}
|
|
label={t("backup.fields.nextRun")}
|
|
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" /> {t("backup.profile.title")}
|
|
</h4>
|
|
{/* Profile mode rendered as a Badge to match the
|
|
jobs list card. Same color scheme: cyan for
|
|
`custom`, muted for `default`. */}
|
|
<div className="flex items-center gap-2 flex-wrap text-xs">
|
|
<span className="text-[10px] uppercase tracking-wider text-green-500/90 inline-flex items-center gap-1">
|
|
<Server className="h-3 w-3 text-green-500/80" />
|
|
{t("backup.fields.mode")}
|
|
</span>
|
|
{detail.profile_mode ? (
|
|
<Badge
|
|
variant="outline"
|
|
className={`text-[10px] uppercase tracking-wide ${
|
|
detail.profile_mode === "custom"
|
|
? "border-cyan-500/40 text-cyan-400 bg-cyan-500/5"
|
|
: "border-border text-muted-foreground bg-card"
|
|
}`}
|
|
>
|
|
{detail.profile_mode === "custom"
|
|
? t("backup.profile.custom")
|
|
: t("backup.profile.default")}
|
|
</Badge>
|
|
) : (
|
|
<span className="text-muted-foreground font-mono">—</span>
|
|
)}
|
|
</div>
|
|
{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" /> {t("backup.destinations.single")}
|
|
</h4>
|
|
<Field icon={<HardDrive className={`h-3 w-3 ${destAccent} opacity-80`} />} label={t("backup.fields.target")} value={detail.destination || "—"} mono />
|
|
{detail.method === "pbs" && (
|
|
<>
|
|
{detail.pbs_repository && (
|
|
<Field icon={<Server className={`h-3 w-3 ${destAccent} opacity-80`} />} label={t("backup.fields.repository")} value={detail.pbs_repository} mono />
|
|
)}
|
|
{detail.pbs_backup_id && (
|
|
<Field icon={<Archive className={`h-3 w-3 ${destAccent} opacity-80`} />} label={t("backup.fields.backupId")} value={detail.pbs_backup_id} mono />
|
|
)}
|
|
</>
|
|
)}
|
|
{detail.method === "borg" && (
|
|
<>
|
|
{detail.borg_repo && (
|
|
<Field icon={<Server className={`h-3 w-3 ${destAccent} opacity-80`} />} label={t("backup.fields.repo")} value={detail.borg_repo} mono />
|
|
)}
|
|
<Field icon={<FileSearch className={`h-3 w-3 ${destAccent} opacity-80`} />} label={t("backup.fields.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={t("backup.fields.directoryShort")} value={detail.local_dest_dir} mono />
|
|
)}
|
|
{detail.pve_storage && (
|
|
<Field icon={<DatabaseBackup className={`h-3 w-3 ${destAccent} opacity-80`} />} label={t("backup.fields.pveStorage")} value={detail.pve_storage} mono />
|
|
)}
|
|
</section>
|
|
</div>
|
|
)
|
|
})()}
|
|
</ScrollArea>
|
|
)}
|
|
|
|
{detail && (
|
|
// 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
|
|
? t("backup.jobs.runAttachedTitle")
|
|
: t("backup.jobs.runNowTitle")}
|
|
>
|
|
{running ? (
|
|
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
|
|
) : (
|
|
<PlayCircle className="h-3.5 w-3.5 mr-1" />
|
|
)}
|
|
{running ? t("backup.jobs.running") : t("backup.actions.runNow")}
|
|
</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" />
|
|
{t("backup.actions.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 ? t("backup.actions.disable") : t("backup.actions.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" />
|
|
{t("backup.actions.delete")}
|
|
</Button>
|
|
</div>
|
|
</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" />
|
|
{t("backup.jobs.disableJob")}
|
|
</DialogTitle>
|
|
<DialogDescription className="text-xs">
|
|
{t("backup.jobs.disableJobDescription")}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="text-sm font-mono px-3 py-2 rounded-md border border-border bg-card break-all">
|
|
{detail?.id}
|
|
</div>
|
|
<div className="flex justify-end gap-2">
|
|
<Button variant="ghost" onClick={() => setShowDisableConfirm(false)}>
|
|
{t("actions.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" />
|
|
{t("backup.actions.disable")}
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
</>
|
|
)
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────
|
|
// 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 t = useT()
|
|
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: t("backup.taskStates.running"), cls: "bg-blue-500/10 border-blue-500/40 text-blue-300" }
|
|
: detail?.last_result === "ok"
|
|
? { label: t("backup.status.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">
|
|
{t("backup.jobs.manualOneShot")}
|
|
</Badge>
|
|
</>
|
|
)}
|
|
</DialogTitle>
|
|
<DialogDescription className="text-xs">
|
|
{t("backup.jobs.manualOneShotDescription")}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
{actionError && (
|
|
<div className="text-xs text-red-500 px-3 py-2 rounded-md border border-red-500/30 bg-red-500/10 whitespace-pre-wrap break-words">
|
|
{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" />
|
|
{t("backup.jobs.loadingJob")}
|
|
</div>
|
|
) : (
|
|
<ScrollArea className="max-h-[60vh] pr-2">
|
|
<div className="space-y-4 text-sm">
|
|
{/* Status + live log — manual one-shot has no concept
|
|
of "last run" (the modal IS the run), so we use a
|
|
neutral "Status" header here. */}
|
|
<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" /> {t("backup.fields.status")}
|
|
</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">{t("backup.jobs.starting")}</span>
|
|
)}
|
|
{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 : t("backup.jobs.waitingForRunner")}
|
|
</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 ? t("backup.jobs.liveLogSize", { size: formatBytes(liveLog?.size ?? 0) }) : t("backup.jobs.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">
|
|
{t("backup.logs.tail")} · {detail.last_log_size > 0 ? formatBytes(detail.last_log_size) : "—"}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</section>
|
|
|
|
{/* Destination — only the fields that apply to the
|
|
picked backend. No Schedule/Retention/Profile here
|
|
because manual jobs don't carry those. */}
|
|
<section className="space-y-1">
|
|
<h4 className={`text-xs font-semibold uppercase tracking-wide flex items-center gap-1.5 ${destAccent}`}>
|
|
<HardDrive className="h-3.5 w-3.5" /> {t("backup.destinations.single")}
|
|
</h4>
|
|
{detail.method === "pbs" && (
|
|
<>
|
|
{detail.pbs_repository && (
|
|
<Field icon={<Server className={`h-3 w-3 ${destAccent} opacity-80`} />} label={t("backup.fields.repository")} value={detail.pbs_repository} mono />
|
|
)}
|
|
{detail.pbs_backup_id && (
|
|
<Field icon={<Archive className={`h-3 w-3 ${destAccent} opacity-80`} />} label={t("backup.fields.backupId")} value={detail.pbs_backup_id} mono />
|
|
)}
|
|
</>
|
|
)}
|
|
{detail.method === "borg" && (
|
|
<>
|
|
{detail.borg_repo && (
|
|
<Field icon={<Server className={`h-3 w-3 ${destAccent} opacity-80`} />} label={t("backup.fields.repo")} value={detail.borg_repo} mono />
|
|
)}
|
|
<Field icon={<FileSearch className={`h-3 w-3 ${destAccent} opacity-80`} />} label={t("backup.fields.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={t("backup.fields.directoryShort")} value={detail.local_dest_dir} mono />
|
|
)}
|
|
</section>
|
|
</div>
|
|
</ScrollArea>
|
|
)}
|
|
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
)
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────
|
|
// PbsKeyfileRecoveryDialog
|
|
// ──────────────────────────────────────────────────────────────
|
|
// Drops the missing PBS keyfile back onto disk by pulling the escrow
|
|
// blob from a 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.
|
|
// ──────────────────────────────────────────────────────────────
|
|
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 t = useT()
|
|
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" />
|
|
{t("backup.keyfileRecovery.title")}
|
|
</DialogTitle>
|
|
<DialogDescription className="text-xs">
|
|
{t("backup.keyfileRecovery.descriptionBefore")} <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">{t("backup.keyfileRecovery.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">
|
|
{t("backup.keyfileRecovery.pickSourceHost")}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{selected && (
|
|
<div className="text-xs space-y-1 px-3 py-2 rounded-md border border-border bg-card">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-muted-foreground">{t("backup.fields.sourceHost")}:</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">{t("backup.keyfileRecovery.passphrase")}</Label>
|
|
<Input
|
|
id="recoveryPass"
|
|
type="password"
|
|
value={passphrase}
|
|
onChange={(e) => setPassphrase(e.target.value)}
|
|
placeholder={t("backup.placeholders.keyfileRecoveryPassphrase")}
|
|
className="font-mono mt-1"
|
|
autoFocus
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" && passphrase && !busy) handleRecover()
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="text-xs text-red-500 px-3 py-2 rounded-md border border-red-500/30 bg-red-500/10 whitespace-pre-wrap break-words">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex justify-end gap-2">
|
|
<Button variant="ghost" onClick={onClose} disabled={busy}>
|
|
{t("actions.cancel")}
|
|
</Button>
|
|
<Button
|
|
className="bg-green-600 hover:bg-green-700 text-white"
|
|
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" />
|
|
)}
|
|
{t("backup.actions.recoverKeyfile")}
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────
|
|
// 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>
|
|
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[]
|
|
}
|
|
|
|
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 t = useT()
|
|
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(t("backup.errors.localArchivePathMissing")); return }
|
|
body.path = path
|
|
} else {
|
|
if (!repo_name || !snapshot) { setError(t("backup.errors.repoNameAndSnapshotRequired")); 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" />
|
|
{t("backup.contents.title")}
|
|
</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" />
|
|
{t("backup.contents.extracting")}
|
|
</div>
|
|
)}
|
|
|
|
{error && !loading && (() => {
|
|
const kf = parseKeyfileError(error)
|
|
return kf ? (
|
|
<div className="px-6"><KeyfileErrorBlock err={kf} /></div>
|
|
) : (
|
|
<div className="text-sm text-red-400 px-3 py-3 mx-6 rounded-md border border-red-500/30 bg-red-500/10">
|
|
{error}
|
|
</div>
|
|
)
|
|
})()}
|
|
|
|
{data && !loading && (
|
|
<ScrollArea className="flex-1 min-h-0 px-6">
|
|
<div className="space-y-4 pr-2">
|
|
<ContentsSection icon={Info} title={t("backup.contents.manifest")} iconColor="text-blue-400">
|
|
{manifest ? (
|
|
<ManifestGrid manifest={manifest} />
|
|
) : data.manifest_missing ? (
|
|
<div className="text-xs text-muted-foreground italic">
|
|
{t("backup.contents.manifestMissingBefore")} <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">{t("backup.contents.noManifest")}</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={t("backup.contents.restorePlan")} iconColor="text-emerald-400">
|
|
<PlanSummary plan={plan} />
|
|
</ContentsSection>
|
|
)}
|
|
{storageEntries.length > 0 && (
|
|
<ContentsSection icon={HardDrive} title={t("backup.contents.storage")} iconColor="text-amber-400">
|
|
<StorageSection storage={plan.storage} />
|
|
</ContentsSection>
|
|
)}
|
|
{networkIfaces.length > 0 && (
|
|
<ContentsSection icon={Network} title={t("backup.contents.network")} iconColor="text-purple-400">
|
|
<NetworkSection network={plan.network} />
|
|
</ContentsSection>
|
|
)}
|
|
{driverList.length > 0 && (
|
|
<ContentsSection icon={Cpu} title={t("backup.contents.driversToReinstall")} iconColor="text-cyan-400">
|
|
<DriversSection drivers={plan.drivers} />
|
|
</ContentsSection>
|
|
)}
|
|
</>
|
|
)
|
|
})()}
|
|
|
|
{data.rollback_plan && (
|
|
<ContentsSection icon={History} title={t("backup.contents.rollbackPlan")} iconColor="text-blue-400">
|
|
<RollbackPlanView plan={data.rollback_plan} />
|
|
</ContentsSection>
|
|
)}
|
|
|
|
{files && files.length > 0 && (
|
|
<ContentsSection
|
|
icon={Package}
|
|
title={t("backup.contents.filesTitle", { count: data.files_total_count ?? files.length, plus: 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={t("backup.contents.metadataFiles")} 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-card p-2 text-foreground/80">
|
|
{content}
|
|
</pre>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</ContentsSection>
|
|
)}
|
|
|
|
{data.plan_error && !data.manifest_missing && (
|
|
<div className="text-[11px] text-amber-400 italic">
|
|
{t("backup.contents.restorePlanUnavailable", { error: 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-card 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 }) {
|
|
const t = useT()
|
|
// 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(t("backup.manifest.sourceHost"), src.hostname || manifest.hostname)
|
|
push(t("backup.manifest.createdAt"), manifest.created_at || meta.created_at)
|
|
push("Kernel", src.kernel || manifest.kernel)
|
|
push(t("backup.manifest.proxmoxVersion"), src.pve_version || manifest.pve_version)
|
|
push(t("backup.manifest.bootMode"), src.boot_mode)
|
|
push("CPU", src.cpu_model)
|
|
push(t("backup.manifest.memory"), src.memory_kb ? `${(src.memory_kb / 1024 / 1024).toFixed(1)} GB` : undefined)
|
|
push(t("backup.manifest.roles"), Array.isArray(src.roles) && src.roles.length ? src.roles.join(", ") : undefined)
|
|
push(t("backup.manifest.gpus"), Array.isArray(hw.gpu) && hw.gpu.length ? t("backup.manifest.deviceCount", { count: hw.gpu.length }) : undefined)
|
|
push(t("backup.manifest.nics"), Array.isArray(hw.nic) && hw.nic.length ? t("backup.manifest.interfaceCount", { count: hw.nic.length }) : undefined)
|
|
push(t("backup.manifest.vmsAtBackup"), Array.isArray(guests.vms) ? guests.vms.length : undefined)
|
|
push(t("backup.manifest.lxcsAtBackup"), Array.isArray(guests.lxcs) ? guests.lxcs.length : undefined)
|
|
push(t("backup.manifest.compression"), meta.compression)
|
|
push(t("backup.manifest.encrypted"), meta.encrypted === true ? t("backup.common.yes") : meta.encrypted === false ? t("backup.common.no") : undefined)
|
|
push(t("backup.manifest.pathsArchived"), Array.isArray(meta.paths_archived) ? t("backup.manifest.pathCount", { count: meta.paths_archived.length }) : undefined)
|
|
push(t("backup.manifest.builtBy"), 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 t = useT()
|
|
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">{t(`backup.plan.status.${status}`)}</span>
|
|
</div>
|
|
{blockers.length > 0 && (
|
|
<div>
|
|
<div className="text-[10px] uppercase tracking-wider text-red-400 mb-1">{t("backup.plan.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">{t("backup.plan.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">{t("backup.plan.noIssues")}</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function StorageSection({ storage }: { storage: any }) {
|
|
const t = useT()
|
|
const entries: any[] = storage.entries || storage.storages || []
|
|
if (entries.length === 0) {
|
|
return <div className="text-xs text-muted-foreground italic">{t("backup.contents.noStorageEntries")}</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 t = useT()
|
|
const ifaces: any[] = network.interfaces || network.remap || []
|
|
if (ifaces.length === 0) {
|
|
return <div className="text-xs text-muted-foreground italic">{t("backup.contents.noNetworkChanges")}</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 t = useT()
|
|
const list: any[] = drivers.plan || drivers.components || drivers.entries || []
|
|
if (list.length === 0) {
|
|
return <div className="text-xs text-muted-foreground italic">{t("backup.contents.noDrivers")}</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 t = useT()
|
|
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={t("backup.placeholders.filterPaths")}
|
|
className="h-8 text-xs"
|
|
/>
|
|
<div className="max-h-72 overflow-auto rounded border border-border bg-card">
|
|
<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">
|
|
{t("backup.contents.filesShowing", {
|
|
shown: Math.min(filtered.length, 2000),
|
|
filtered: filtered.length,
|
|
filteredLabel: query ? t("backup.contents.filtered") : "",
|
|
total: filtered.length !== files.length ? t("backup.contents.totalFiles", { count: files.length }) : "",
|
|
})}
|
|
{truncated && <span className="text-amber-400">{t("backup.contents.filesTruncated")}</span>}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────
|
|
// 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 }) {
|
|
const t = useT()
|
|
// 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.
|
|
const hasRollback = (plan.vms_to_restore?.length || 0) > 0
|
|
|| (plan.lxcs_to_restore?.length || 0) > 0
|
|
|| (plan.components_to_reinstall?.length || 0) > 0
|
|
|
|
if (!hasRollback) {
|
|
return (
|
|
<div className="text-xs text-muted-foreground italic">
|
|
{t("backup.rollback.noDifferences")}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// 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>
|
|
)
|
|
// 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.
|
|
const Row = ({ label, items }: { label: string; items: (string | number)[] }) => (
|
|
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1.5">
|
|
<span className="text-muted-foreground shrink-0">{label}</span>
|
|
{items.map((id) => <Pill key={id}>{id}</Pill>)}
|
|
</div>
|
|
)
|
|
|
|
return (
|
|
<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">
|
|
{t("backup.rollback.configsIncluded")}
|
|
</div>
|
|
{plan.vms_to_restore.length > 0 && (
|
|
<Row label={t("backup.rollback.vmConfigs")} items={plan.vms_to_restore} />
|
|
)}
|
|
{plan.lxcs_to_restore.length > 0 && (
|
|
<Row label={t("backup.rollback.lxcConfigs")} items={plan.lxcs_to_restore} />
|
|
)}
|
|
{plan.components_to_reinstall.length > 0 && (
|
|
<Row label={t("backup.rollback.components")} items={plan.components_to_reinstall} />
|
|
)}
|
|
<div className="text-[10px] text-muted-foreground pt-1">
|
|
{t("backup.rollback.pveOnlyHint")}
|
|
</div>
|
|
</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,
|
|
crossKernel,
|
|
hydration,
|
|
display_id,
|
|
onLaunch,
|
|
}: {
|
|
open: boolean
|
|
onClose: () => void
|
|
stagingPath: string
|
|
pathsAvailable: string[]
|
|
rollbackPlan?: RollbackPlan
|
|
crossKernel?: {
|
|
direction: "same" | "bk_older" | "bk_newer"
|
|
backupKernel: string
|
|
targetKernel: string
|
|
blockedPaths: string[]
|
|
}
|
|
hydration?: {
|
|
applies: boolean
|
|
actions: string[]
|
|
}
|
|
display_id?: string
|
|
onLaunch: (mode: "full" | "custom", paths: string[], rollbackExecute?: boolean) => void
|
|
}) {
|
|
const t = useT()
|
|
// 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}/`))
|
|
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) => {
|
|
if (isPathBlocked(p)) return
|
|
setSelected((prev) => {
|
|
const next = new Set(prev)
|
|
if (next.has(p)) next.delete(p)
|
|
else next.add(p)
|
|
return next
|
|
})
|
|
}
|
|
|
|
const toggleAll = () => {
|
|
const selectable = filteredPaths.filter((p) => !isPathBlocked(p))
|
|
if (selected.size === selectable.length) {
|
|
setSelected(new Set())
|
|
} else {
|
|
setSelected(new Set(selectable))
|
|
}
|
|
}
|
|
|
|
const filteredPaths = filter
|
|
? pathsAvailable.filter((p) => p.toLowerCase().includes(filter.toLowerCase()))
|
|
: pathsAvailable
|
|
const selectableCount = filteredPaths.filter((p) => !isPathBlocked(p)).length
|
|
|
|
const launch = (mode: "full" | "custom") => {
|
|
if (mode === "custom" && selected.size === 0) {
|
|
setError(t("backup.restore.pickPathError"))
|
|
return
|
|
}
|
|
setError(null)
|
|
onLaunch(mode, Array.from(selected), mode === "full" && hasDestructive && destructiveAck)
|
|
}
|
|
|
|
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" />
|
|
{t("backup.restore.chooseModeTitle")}
|
|
</DialogTitle>
|
|
<DialogDescription className="text-xs font-mono break-all">
|
|
{display_id}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
{step === "choose" && (
|
|
<div className="space-y-3">
|
|
{/* Post-restore Monitor hint — the Backups tab renders a live
|
|
progress card driven by /var/lib/proxmenux/restore-state.json,
|
|
so the operator has a place to watch component reinstalls,
|
|
sanity warnings, and the rollback delta after the reboot. */}
|
|
<div className="rounded-md border border-blue-500/40 bg-blue-500/5 p-3 text-[11px] text-muted-foreground flex items-start gap-2">
|
|
<History className="h-3.5 w-3.5 text-blue-400 shrink-0 mt-0.5" />
|
|
<div>
|
|
{t("backup.restore.afterRebootHint")}
|
|
</div>
|
|
</div>
|
|
|
|
{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" />
|
|
{t("backup.restore.crossKernelTitle")}
|
|
</div>
|
|
<div className="text-[11px] text-muted-foreground mt-2 space-y-1.5">
|
|
<div>
|
|
{t("backup.restore.backupKernel")} <code className="font-mono">{crossKernel?.backupKernel}</code> {t("backup.restore.olderThanTargetKernel")} <code className="font-mono">{crossKernel?.targetKernel}</code>.
|
|
</div>
|
|
<div>
|
|
{t("backup.restore.crossKernelDescription")}
|
|
</div>
|
|
{blockedPrefixes.length > 0 && (
|
|
<details className="mt-1.5">
|
|
<summary className="cursor-pointer text-amber-400/90 hover:text-amber-300">
|
|
{t("backup.restore.pathsSkipped", { count: 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" />
|
|
{t("backup.restore.operatorConfigTitle")}
|
|
</div>
|
|
<div className="text-[11px] text-muted-foreground mt-2 space-y-1.5">
|
|
<div>
|
|
{t("backup.restore.operatorConfigDescription")}
|
|
</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>
|
|
)}
|
|
|
|
{/* Surface destructive deltas BEFORE the operator picks
|
|
Complete. Custom-by-paths can't trigger these
|
|
(paths-only restore doesn't touch the guest list or
|
|
components_status.json), so the warning lives here. */}
|
|
{rollbackPlan && hasDestructive && (
|
|
<div className="rounded-md border border-red-500/50 bg-red-500/5 p-3 space-y-3">
|
|
<div className="text-[11px] font-semibold uppercase tracking-wider text-red-400 flex items-center gap-1.5">
|
|
<AlertTriangle className="h-3.5 w-3.5" />
|
|
{t("backup.restore.destructiveTitle")}
|
|
</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">
|
|
{t("backup.restore.ackBefore")} <strong className="text-red-400">{t("backup.restore.permanentlyDelete")}</strong> {t("backup.restore.ackAfter")}
|
|
</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">{t("backup.restore.completeTitle")}</div>
|
|
<div className="text-[11px] text-muted-foreground mt-1">
|
|
{t("backup.restore.completeDescriptionBefore")} <em>{t("backup.restore.completeTitle")}</em> {t("backup.restore.completeDescriptionAfter")}
|
|
{hasDestructive && !destructiveAck && (
|
|
<span className="block mt-1 text-red-400">{t("backup.restore.tickAckToEnable")}</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">
|
|
{t("backup.restore.customByPathsTitle")}
|
|
{pathsAvailable.length > 0 && (
|
|
<span className="ml-2 text-[10px] text-muted-foreground font-normal">
|
|
{t("backup.restore.pathsInBackup", { count: pathsAvailable.length })}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="text-[11px] text-muted-foreground mt-1">
|
|
{pathsAvailable.length === 0
|
|
? t("backup.restore.noRestorablePaths")
|
|
: t("backup.restore.customByPathsDescription")}
|
|
</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">
|
|
{t("backup.restore.customPickDescriptionBefore")} <code className="font-mono">_rs_run_custom_restore</code> {t("backup.restore.customPickDescriptionMiddle")} <code className="font-mono">HB_PRESELECTED_PATHS</code> {t("backup.restore.customPickDescriptionAfter")}
|
|
</p>
|
|
<div className="flex items-center gap-2">
|
|
<Input
|
|
type="search"
|
|
value={filter}
|
|
onChange={(e) => setFilter(e.target.value)}
|
|
placeholder={t("backup.placeholders.filterPaths")}
|
|
className="h-8 text-xs"
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={toggleAll}
|
|
className="h-8 text-xs whitespace-nowrap"
|
|
>
|
|
{selected.size === selectableCount && selectableCount > 0 ? t("backup.actions.clear") : t("backup.actions.all")}
|
|
</Button>
|
|
</div>
|
|
{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">
|
|
{t("backup.restore.blockedPathsHint")}
|
|
</div>
|
|
)}
|
|
<div className="rounded-md border border-border bg-card p-1 max-h-72 overflow-auto">
|
|
<ul className="divide-y divide-border/40">
|
|
{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 ? t("backup.restore.skippedPathTitle") : 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">{t("backup.restore.skipped")}</span>
|
|
)}
|
|
</label>
|
|
</li>
|
|
)
|
|
})}
|
|
{filteredPaths.length === 0 && (
|
|
<li className="text-[11px] text-muted-foreground italic px-2 py-2">{t("backup.restore.noPathsMatch")}</li>
|
|
)}
|
|
</ul>
|
|
</div>
|
|
<div className="text-[11px] text-muted-foreground">
|
|
{t("backup.restore.selectedCount", {
|
|
selected: selected.size,
|
|
total: isBkOlder ? selectableCount : pathsAvailable.length,
|
|
shown: filter && filteredPaths.length !== pathsAvailable.length ? t("backup.restore.shownCount", { count: filteredPaths.length }) : "",
|
|
})}
|
|
</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")}>
|
|
{t("backup.actions.back")}
|
|
</Button>
|
|
)}
|
|
<Button variant="ghost" onClick={onClose}>
|
|
{t("actions.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" />
|
|
{t("backup.actions.restoreSelected")}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|