mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-08-06 15:56:23 +00:00
Localize runtime labels for SMART/NVMe details, backup target badges, firewall interface types, settings units, and generated health status copy while keeping the English and Slovak catalogs in key parity.
8987 lines
412 KiB
TypeScript
8987 lines
412 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-background/40 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-background/40"
|
|
}`}
|
|
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-background/40 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-background/40 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-background/40">
|
|
<div className="flex items-center gap-2">
|
|
<Loader2 className={`h-3.5 w-3.5 ${exportTask.state === "completed" || exportTask.state === "failed" ? "" : "animate-spin"}`} />
|
|
<span className="font-medium">{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-background/40 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
|
|
// 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">
|
|
<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-background/40"
|
|
} 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-background/40"} 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-background/40"
|
|
} ${backend === "borg" ? "opacity-60 cursor-not-allowed" : "hover:bg-white/5"}`}
|
|
>
|
|
<div className="text-sm font-medium flex items-center gap-2">
|
|
<Server className="h-4 w-4" />
|
|
{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-background/40"} hover:bg-white/5 transition-colors`}
|
|
>
|
|
<div className="flex items-center justify-between gap-2 flex-wrap">
|
|
<span className="font-mono text-xs">{j.id}</span>
|
|
{!j.enabled && (
|
|
<Badge variant="outline" className="text-[10px] text-amber-500 border-amber-500/40">
|
|
{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-background/40 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-background/40 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-background/40"} 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-background/40"} 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-background/40 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">
|
|
<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-background/40"} hover:bg-white/5 transition-colors`}
|
|
>
|
|
<div className="flex items-center gap-2 font-medium text-sm">
|
|
<Icon className="h-4 w-4" />
|
|
{b === "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-background/40"} 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-background/40"} 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-background/40 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-background/40 break-all">
|
|
{headline}
|
|
</div>
|
|
{(jobs.length > 0 || backups > 0) && (
|
|
<div className="text-xs space-y-1.5 text-foreground">
|
|
{jobs.length > 0 && (
|
|
<div className="px-3 py-2 rounded-md border border-amber-500/40 bg-amber-500/5">
|
|
<div className="font-medium text-amber-400">
|
|
{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-background/40 hover:bg-white/5 ${opt.accent}`}
|
|
>
|
|
<div className="flex items-center gap-2 mb-1">
|
|
{opt.type === "pbs" ? <Server className="h-4 w-4" /> :
|
|
opt.type === "local" ? <HardDrive className="h-4 w-4" /> :
|
|
<Archive className="h-4 w-4" />}
|
|
<span className="font-semibold uppercase">{opt.label}</span>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">{opt.blurb}</p>
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="flex justify-end">
|
|
<Button variant="ghost" onClick={onClose}>{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("")
|
|
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(""); setBorgSshRemotePath("")
|
|
setBorgSshKeyPath("/root/.ssh/proxmenux_borg")
|
|
setBorgEncryptionEnabled(true); setLocalPath("")
|
|
return
|
|
}
|
|
if (editing && editing.kind === "borg") {
|
|
const repo = editing.repository || ""
|
|
const ssh = repo.match(/^ssh:\/\/([^@]+)@([^/]+)\/(.+)$/)
|
|
setName(editing.name)
|
|
if (ssh) {
|
|
setBorgMode("ssh")
|
|
setBorgSshUser(ssh[1])
|
|
setBorgSshHost(ssh[2])
|
|
setBorgSshRemotePath(`/${ssh[3]}`)
|
|
setBorgSshKeyPath(editing.ssh_key_path || "/root/.ssh/proxmenux_borg")
|
|
setBorgRepo("")
|
|
} else {
|
|
setBorgMode("local")
|
|
setBorgRepo(repo)
|
|
setBorgSshUser("borg"); setBorgSshHost(""); setBorgSshRemotePath("")
|
|
setBorgSshKeyPath("/root/.ssh/proxmenux_borg")
|
|
}
|
|
const mode = editing.encrypt_mode || "repokey"
|
|
setBorgEncryptionEnabled(mode !== "none")
|
|
setServer(""); setDatastore(""); setUsername("root@pam"); setFingerprint("")
|
|
setLocalPath("")
|
|
return
|
|
}
|
|
// Add-new path: full reset.
|
|
setName("")
|
|
setServer("")
|
|
setDatastore("")
|
|
setUsername("root@pam")
|
|
setFingerprint("")
|
|
setBorgRepo("")
|
|
setBorgMode("local")
|
|
setBorgSshUser("borg")
|
|
setBorgSshHost("")
|
|
setBorgSshRemotePath("")
|
|
setBorgSshKeyPath("/root/.ssh/proxmenux_borg")
|
|
setBorgEncryptionEnabled(true)
|
|
setLocalPath("")
|
|
}, [type, editing])
|
|
|
|
const isEditing = !!editing
|
|
const nameValid = /^[a-zA-Z0-9_-]+$/.test(name)
|
|
const borgPathValid =
|
|
borgMode === "local"
|
|
? !!borgRepo.trim()
|
|
: !!(borgSshUser.trim() && borgSshHost.trim() && borgSshRemotePath.trim())
|
|
// In edit mode the saved passphrase / password is reused when the
|
|
// operator leaves the inputs blank, so validation relaxes.
|
|
const borgPassphraseValid =
|
|
!borgEncryptionEnabled
|
|
|| (isEditing && !borgPassphrase && editing?.has_passphrase)
|
|
|| (borgPassphrase.length > 0 && borgPassphrase === borgPassphrase2)
|
|
const borgValid = borgPathValid && borgPassphraseValid
|
|
const canSubmit =
|
|
type === "pbs"
|
|
? nameValid && server.trim() && datastore.trim() &&
|
|
(!!password || (isEditing && !!editing?.has_password))
|
|
: type === "borg"
|
|
? nameValid && borgValid
|
|
: type === "local"
|
|
? localPath.trim().startsWith("/")
|
|
: false
|
|
|
|
async function generateBorgKey() {
|
|
setGeneratingKey(true)
|
|
setError(null)
|
|
try {
|
|
const resp = await fetchApi<{ public_key: string; authorized_keys_line: string }>(
|
|
"/api/host-backups/ssh-keys/generate",
|
|
{
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
key_path: borgSshKeyPath.trim() || "/root/.ssh/proxmenux_borg",
|
|
remote_path: borgSshRemotePath.trim(),
|
|
}),
|
|
}
|
|
)
|
|
setGeneratedKey(resp)
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : String(e))
|
|
} finally {
|
|
setGeneratingKey(false)
|
|
}
|
|
}
|
|
|
|
async function handleSave() {
|
|
if (!canSubmit) return
|
|
setSubmitting(true)
|
|
setError(null)
|
|
try {
|
|
let savedRepo: string | undefined
|
|
if (type === "pbs") {
|
|
const resp = await fetchApi<{ repository?: string }>("/api/host-backups/destinations/pbs", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
name,
|
|
server: server.trim(),
|
|
datastore: datastore.trim(),
|
|
username: username.trim() || "root@pam",
|
|
password,
|
|
fingerprint: fingerprint.trim() || undefined,
|
|
}),
|
|
})
|
|
savedRepo = resp?.repository
|
|
} else if (type === "borg") {
|
|
const body: Record<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()
|
|
}
|
|
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">
|
|
<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-background/40"} hover:bg-white/5 transition-colors`}
|
|
>
|
|
<div className="font-medium flex items-center gap-1"><HardDrive className="h-3.5 w-3.5" /> {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-background/40"} hover:bg-white/5 transition-colors`}
|
|
>
|
|
<div className="font-medium flex items-center gap-1"><Server className="h-3.5 w-3.5" /> {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>
|
|
<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="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-background/40 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-background/40 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-background/40 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-background/40 p-3 space-y-2">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div className="flex items-center gap-2 text-xs font-medium">
|
|
<HardDrive className="h-3.5 w-3.5 text-orange-400" />
|
|
{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-background/40 break-all">
|
|
{formatTarget.path_or_device}
|
|
{formatTarget.size && <span className="text-muted-foreground"> · {formatTarget.size}</span>}
|
|
</div>
|
|
<Label htmlFor="formatConfirmInput" className="text-xs">
|
|
{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-background/40"
|
|
>
|
|
<div className="min-w-0 flex-1 flex items-center gap-2">
|
|
<span className="font-mono text-xs truncate" title={p.path}>{p.path}</span>
|
|
{!p.exists && (
|
|
<Badge variant="outline" className="text-[10px] text-amber-500 border-amber-500/40">
|
|
{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-background/40"
|
|
>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<span className="font-mono text-sm font-medium">{d.path_or_device}</span>
|
|
<Badge variant="outline" className={`text-[10px] uppercase tracking-wide ${stateBadge.cls}`}>
|
|
{stateBadge.label}
|
|
</Badge>
|
|
</div>
|
|
<div className="mt-1 text-[11px] text-muted-foreground flex items-center gap-x-3 gap-y-1 flex-wrap">
|
|
{d.label && <span><span className="text-muted-foreground/70">{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("backup.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>
|
|
<Field
|
|
icon={<Server className="h-3 w-3 text-green-500/80" />}
|
|
label={t("backup.fields.mode")}
|
|
value={detail.profile_mode || "—"}
|
|
mono
|
|
labelClassName="text-green-500/90"
|
|
/>
|
|
{detail.paths && detail.paths.length > 0 && (
|
|
<PathsDisplay paths={detail.paths} />
|
|
)}
|
|
</section>
|
|
|
|
{/* ─── Destination ──────────────────────── */}
|
|
<section className="space-y-1">
|
|
<h4 className={`text-xs font-semibold uppercase tracking-wide flex items-center gap-1.5 ${destAccent}`}>
|
|
<HardDrive className="h-3.5 w-3.5" /> {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-background/40 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-background/40">
|
|
<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-background/40 p-2 text-foreground/80">
|
|
{content}
|
|
</pre>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</ContentsSection>
|
|
)}
|
|
|
|
{data.plan_error && !data.manifest_missing && (
|
|
<div className="text-[11px] text-amber-400 italic">
|
|
{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-background/40 p-3 space-y-2">
|
|
<h3 className="text-xs font-semibold uppercase tracking-wider flex items-center gap-1.5">
|
|
<Icon className={`h-3.5 w-3.5 ${iconColor || "text-muted-foreground"}`} />
|
|
{title}
|
|
</h3>
|
|
{children}
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function ManifestGrid({ manifest }: { manifest: any }) {
|
|
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-background/40">
|
|
<ul className="text-[11px] font-mono divide-y divide-border/30">
|
|
{filtered.slice(0, 2000).map((f) => (
|
|
<li key={f.path} className="flex items-center justify-between gap-3 px-2 py-1 hover:bg-white/5">
|
|
<span className="truncate" title={f.path}>{f.path}</span>
|
|
<span className="text-muted-foreground shrink-0">{formatBytes(f.size)}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
<div className="text-[10px] text-muted-foreground flex items-center gap-2">
|
|
{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-background/40 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>
|
|
)
|
|
}
|