update beta 1.2.2.2

This commit is contained in:
MacRimi
2026-06-09 00:13:24 +02:00
parent 6844406cf7
commit 61ff665cec
30 changed files with 5510 additions and 396 deletions

View File

@@ -1 +1 @@
ee588e46f8898925d60d56a79f5364083be4eedccc2274fd0caeb220f795ade6 ProxMenux-1.2.2.1-beta.AppImage
aa53e689c13d7184ebd7cb46cc0f24af9628804fcaa223a833364a5a09e382ed ProxMenux-1.2.2.1-beta.AppImage

View File

@@ -0,0 +1,600 @@
"use client"
import { useState } 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 } from "./ui/dialog"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"
import {
DatabaseBackup,
Clock,
HardDrive,
Server,
CheckCircle2,
AlertTriangle,
XCircle,
Loader2,
PlayCircle,
Archive,
FileSearch,
Calendar,
} from "lucide-react"
import { fetchApi } from "../lib/api-config"
import { formatStorage } from "../lib/utils"
// ── Shape contracts with the backend (flask_server.py: api_host_backups_*) ──
interface BackupJob {
id: string
destination: string
method: string // "local_tar" | "pbs" | "borg" | "unknown"
on_calendar: string
retention: string
timer_enabled: boolean
last_status: string | null
next_run: string | null
}
interface BackupArchive {
id: string // basename of the .tar file (also the URL slug)
path: string // absolute path on host
size_bytes: number
mtime: number // unix seconds
// From the backend identifier — see _identify_host_backup() in flask_server.py.
// kind: "manual" / "scheduled" when we know; "legacy" when only the in-tar
// marker confirmed it's a ProxMenux backup (no sidecar, no name match).
job_id: string | null
kind: "manual" | "scheduled" | "legacy"
profile: string | null
source_hostname: string | null
// Which detection path identified this archive. Surfaced as a small tooltip
// hint so the operator knows whether the metadata is authoritative
// (sidecar) or inferred (filename / tar-peek).
detected_via: "sidecar" | "job_id_match" | "hostcfg_prefix" | "tar_peek"
}
interface ManifestSourceHost {
hostname: string
pve_version: string | null
roles: string[]
kernel: string
boot_mode: string
cpu_model: string
memory_kb: number
}
interface PreflightCheck {
id: string
severity: "pass" | "warn" | "fail"
message: string
details: Record<string, unknown> | null
}
interface PreflightReport {
source_host_at_backup: ManifestSourceHost
selected_mode: {
mode: string
paths_include: string[]
paths_exclude: string[]
components_include: string[]
storage_apply: boolean
network_apply: boolean
}
preflight: {
checks: PreflightCheck[]
summary: { pass: number; warn: number; fail: number }
}
storage: {
zfs: Array<{ name: string; action: string; present: string[]; missing: string[] }>
lvm: Array<{ name: string; action: string }>
pve_storage: Array<{ id: string; type: string; action: string; note: string | null }>
in_selected_mode: boolean
}
network: {
keep: Array<{ ifname: string; mac: string }>
remap: Array<{ source_ifname: string; destination_ifname: string; mac: string }>
orphan: Array<{ source_ifname: string; source_mac: string }>
new: Array<{ ifname: string; mac: string }>
in_selected_mode: boolean
}
driver_reinstall: {
plan: Array<{
component_id: string
type: string
version: string
installer: string | null
action: string
reason: string
}>
}
abort_reason: string | null
}
const fetcher = async (url: string) => fetchApi(url)
const formatMtime = (mtime: number) =>
new Date(mtime * 1000).toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})
const formatNext = (iso: string | null) => {
if (!iso) return "—"
try {
return new Date(iso).toLocaleString()
} catch {
return iso
}
}
export function HostBackup() {
const { data: jobsResp, error: jobsErr } = useSWR<{ jobs: BackupJob[] }>(
"/api/host-backups/jobs",
fetcher,
{ refreshInterval: 30000 },
)
const { data: archivesResp, error: archivesErr } = useSWR<{ archives: BackupArchive[] }>(
"/api/host-backups/archives",
fetcher,
{ refreshInterval: 30000 },
)
const [inspectingArchive, setInspectingArchive] = useState<BackupArchive | null>(null)
return (
<div className="space-y-4 md:space-y-6">
{/* ── Scheduled jobs ───────────────────────────────── */}
<Card className="bg-card border-border">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
<div className="flex items-center gap-2">
<Calendar className="h-5 w-5 text-blue-500" />
<CardTitle className="text-base font-semibold">Scheduled Backup Jobs</CardTitle>
</div>
<Badge variant="outline">{jobsResp?.jobs?.length ?? 0}</Badge>
</CardHeader>
<CardContent>
{jobsErr ? (
<div className="text-sm text-red-500 py-4">Failed to load jobs</div>
) : !jobsResp ? (
<div className="flex items-center gap-2 py-4 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Loading...
</div>
) : jobsResp.jobs.length === 0 ? (
<div className="text-sm text-muted-foreground py-4 space-y-2">
<p>No scheduled backup jobs configured yet.</p>
<p>
For a <span className="font-medium text-foreground">one-shot manual backup</span>{" "}
or to create a scheduled job, run:
</p>
<code className="block mt-1 px-3 py-2 rounded-md bg-muted text-xs font-mono">
bash /usr/local/share/proxmenux/scripts/backup_restore/backup_host.sh
</code>
<p className="text-xs">
Menu options 1-6 are manual backups (default or custom paths, to PBS, Borg, or local tar). Option 7 opens the scheduler if you want a recurring job.
</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="text-[10px] uppercase tracking-wider text-muted-foreground border-b border-border">
<tr>
<th className="text-left px-2 py-2">ID</th>
<th className="text-left px-2 py-2">Destination</th>
<th className="text-left px-2 py-2">Method</th>
<th className="text-left px-2 py-2">Schedule</th>
<th className="text-left px-2 py-2">Last status</th>
<th className="text-left px-2 py-2">Next run</th>
</tr>
</thead>
<tbody className="divide-y divide-border/40">
{jobsResp.jobs.map((j) => (
<tr key={j.id} className="text-xs">
<td className="px-2 py-2 font-mono">{j.id}</td>
<td className="px-2 py-2 font-mono truncate max-w-[260px]" title={j.destination}>
{j.destination || "—"}
</td>
<td className="px-2 py-2">{j.method}</td>
<td className="px-2 py-2 font-mono">{j.on_calendar}</td>
<td className="px-2 py-2">
{j.last_status ? (
<span className="text-xs">{j.last_status}</span>
) : (
<span className="text-muted-foreground">never</span>
)}
</td>
<td className="px-2 py-2">
<span className="text-xs">{formatNext(j.next_run)}</span>
{!j.timer_enabled && (
<Badge variant="outline" className="ml-2 text-amber-500 border-amber-500/30">
timer disabled
</Badge>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</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">Available Archives</CardTitle>
</div>
<Badge variant="outline">{archivesResp?.archives?.length ?? 0}</Badge>
</CardHeader>
<CardContent>
{archivesErr ? (
<div className="text-sm text-red-500 py-4">Failed to load archives</div>
) : !archivesResp ? (
<div className="flex items-center gap-2 py-4 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Loading...
</div>
) : archivesResp.archives.length === 0 ? (
<div className="text-sm text-muted-foreground py-4">
No backup archives found on this host. We scan <code className="font-mono">/var/lib/vz/dump</code> and any custom destination from a scheduled job, looking for files named <code className="font-mono">hostcfg-&lt;hostname&gt;-*.tar.zst</code> (manual backups) or <code className="font-mono">&lt;job_id&gt;-*.tar.*</code> (scheduled). PBS and Borg backups aren't surfaced in the UI yet.
</div>
) : (
<div className="space-y-2">
{archivesResp.archives.map((a) => (
<div
key={a.id}
className="flex items-center justify-between gap-3 p-3 rounded-md border border-border bg-background/40 hover:bg-white/5 transition-colors"
>
<div className="min-w-0 flex-1">
<div className="font-mono text-xs truncate" title={a.path}>
{a.id}
</div>
<div className="text-[11px] text-muted-foreground mt-0.5 flex items-center gap-3 flex-wrap">
<span className="inline-flex items-center gap-1">
<Clock className="h-3 w-3" />
{formatMtime(a.mtime)}
</span>
<span className="inline-flex items-center gap-1">
<HardDrive className="h-3 w-3" />
{formatStorage(a.size_bytes)}
</span>
{a.kind === "scheduled" && a.job_id ? (
<span title={`identified via ${a.detected_via}`}>job: <code className="font-mono">{a.job_id}</code></span>
) : a.kind === "legacy" ? (
<span
title={`identified via ${a.detected_via} — no sidecar metadata`}
className="uppercase tracking-wide text-[10px] px-1.5 py-0.5 rounded bg-amber-500/10 border border-amber-500/40 text-amber-400"
>
legacy
</span>
) : (
<span
title={`identified via ${a.detected_via}`}
className="uppercase tracking-wide text-[10px] px-1.5 py-0.5 rounded bg-white/5 border border-border"
>
manual
</span>
)}
{a.source_hostname && a.source_hostname !== "" && (
<span>host: <code className="font-mono">{a.source_hostname}</code></span>
)}
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setInspectingArchive(a)}
className="flex-shrink-0"
>
<FileSearch className="h-3.5 w-3.5 mr-1.5" />
Inspect
</Button>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* ── Inspect / preflight modal ──────────────────────── */}
<InspectModal
archive={inspectingArchive}
onClose={() => setInspectingArchive(null)}
/>
</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,
}: {
archive: BackupArchive | null
onClose: () => void
}) {
const open = archive !== null
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 { 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[] } }
}>(
archive ? `/api/host-backups/archives/${encodeURIComponent(archive.id)}/manifest` : null,
fetcher,
)
const runPreflight = async () => {
if (!archive) return
setRunning(true)
setError(null)
setReport(null)
try {
const res = await fetchApi<PreflightReport>(
`/api/host-backups/archives/${encodeURIComponent(archive.id)}/preflight`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mode }),
},
)
setReport(res)
} catch (e: any) {
setError(e?.message || "Preflight failed")
} finally {
setRunning(false)
}
}
// Reset state when archive changes
const archiveId = archive?.id
// Note: this useEffect-like cleanup happens via key={archiveId} on the
// Dialog content so React unmounts and remounts; state resets naturally.
return (
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose() }}>
<DialogContent key={archiveId} className="max-w-3xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<DatabaseBackup className="h-5 w-5 text-blue-500" />
<span className="font-mono text-sm truncate">{archive?.id}</span>
</DialogTitle>
<DialogDescription className="text-xs">
Inspect the manifest snapshot taken at backup time, then dry-run the restore plan for a chosen mode. Read-only; nothing on this host is changed.
</DialogDescription>
</DialogHeader>
{/* Manifest summary */}
{manifestErr ? (
<div className="text-sm text-red-500 py-2">
Couldn't read the manifest from this archive it may have been created before the manifest format was added.
</div>
) : !manifest ? (
<div className="flex items-center gap-2 py-4 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Reading manifest...
</div>
) : (
<ManifestSummary manifest={manifest} />
)}
{/* Preflight controls */}
<div className="border-t border-border pt-4 space-y-3">
<div className="flex items-end gap-3">
<div className="flex-1">
<label className="text-xs text-muted-foreground block mb-1.5">
Restore mode
</label>
<Select value={mode} onValueChange={setMode}>
<SelectTrigger className="h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="full">Full apply everything</SelectItem>
<SelectItem value="base">Base everything except network</SelectItem>
<SelectItem value="storage_only">Storage only</SelectItem>
<SelectItem value="network_only">Network only</SelectItem>
<SelectItem value="custom">Custom paths picked manually</SelectItem>
</SelectContent>
</Select>
</div>
<Button onClick={runPreflight} disabled={running || !manifest}>
{running ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Running...
</>
) : (
<>
<PlayCircle className="h-4 w-4 mr-2" />
Run preflight
</>
)}
</Button>
</div>
{error && (
<div className="text-sm text-red-500 p-2 rounded-md border border-red-500/30 bg-red-500/10">
{error}
</div>
)}
{report && <PreflightReportView report={report} />}
</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 sh = manifest.source_host
const zfsCount = manifest.storage_inventory?.zfs_pools?.length ?? 0
const lvmCount = manifest.storage_inventory?.lvm?.vgs?.length ?? 0
return (
<div className="space-y-3 py-2">
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 text-xs">
<Field icon={<Server className="h-3.5 w-3.5" />} label="Source host" value={sh.hostname} />
<Field label="PVE version" value={sh.pve_version || "—"} />
<Field label="Roles" value={sh.roles.join(", ")} />
<Field label="Kernel" value={sh.kernel} mono />
<Field label="Boot mode" value={sh.boot_mode} />
<Field label="Memory" value={`${Math.round(sh.memory_kb / 1024)} MB`} />
<Field label="ZFS pools" value={String(zfsCount)} />
<Field label="LVM VGs" value={String(lvmCount)} />
<Field label="VMs / LXCs" value={`${manifest.vms_lxcs_at_backup.vms.length} VM / ${manifest.vms_lxcs_at_backup.lxcs.length} LXC`} />
</div>
{manifest.proxmenux_installed_components.length > 0 && (
<div className="text-xs">
<div className="text-muted-foreground mb-1">ProxMenux components at backup time:</div>
<div className="flex flex-wrap gap-1.5">
{manifest.proxmenux_installed_components.map((c) => (
<Badge key={c.id} variant="outline" className="font-mono text-[10px]">
{c.id}{c.version_at_backup ? ` @ ${c.version_at_backup}` : ""}
</Badge>
))}
</div>
</div>
)}
</div>
)
}
function Field({ icon, label, value, mono }: { icon?: React.ReactNode; label: string; value: string; mono?: boolean }) {
return (
<div>
<div className="text-[10px] uppercase tracking-wider text-muted-foreground flex items-center gap-1">
{icon}
{label}
</div>
<div className={`${mono ? "font-mono" : ""} truncate`} title={value}>
{value}
</div>
</div>
)
}
// ── Preflight report view ────────────────────────────────────
function PreflightReportView({ report }: { report: PreflightReport }) {
const { summary, checks } = report.preflight
const passColor = "text-emerald-500"
const warnColor = "text-amber-500"
const failColor = "text-red-500"
return (
<div className="space-y-3 border border-border rounded-md p-3 bg-muted/30">
{/* Summary line */}
<div className="flex items-center gap-4 text-sm">
<span className={`inline-flex items-center gap-1 ${passColor}`}>
<CheckCircle2 className="h-4 w-4" />
{summary.pass} pass
</span>
<span className={`inline-flex items-center gap-1 ${warnColor}`}>
<AlertTriangle className="h-4 w-4" />
{summary.warn} warn
</span>
<span className={`inline-flex items-center gap-1 ${failColor}`}>
<XCircle className="h-4 w-4" />
{summary.fail} fail
</span>
{summary.fail > 0 && (
<span className="ml-auto text-xs text-red-500">
--apply would be refused
</span>
)}
</div>
{/* Per-check list */}
<div className="space-y-1.5">
{checks.map((c) => {
const color =
c.severity === "pass" ? passColor :
c.severity === "warn" ? warnColor :
failColor
const Icon =
c.severity === "pass" ? CheckCircle2 :
c.severity === "warn" ? AlertTriangle :
XCircle
return (
<div key={c.id} className="flex items-start gap-2 text-xs">
<Icon className={`h-3.5 w-3.5 ${color} flex-shrink-0 mt-0.5`} />
<div className="flex-1 min-w-0">
<span className={`font-mono ${color}`}>{c.id}</span>
<span className="text-muted-foreground ml-2">{c.message}</span>
</div>
</div>
)
})}
</div>
{/* Storage / network counts */}
<div className="grid grid-cols-2 gap-3 text-xs pt-2 border-t border-border/40">
<div>
<div className="text-muted-foreground mb-1">Storage [in mode: {String(report.storage.in_selected_mode)}]</div>
<div>
{report.storage.zfs.length} ZFS pool(s) ·
{" "}{report.storage.lvm.length} LVM VG(s) ·
{" "}{report.storage.pve_storage.length} PVE storage(s)
</div>
</div>
<div>
<div className="text-muted-foreground mb-1">Network [in mode: {String(report.network.in_selected_mode)}]</div>
<div>
{report.network.keep.length} keep ·
{" "}{report.network.remap.length} remap ·
{" "}{report.network.orphan.length} orphan ·
{" "}{report.network.new.length} new
</div>
</div>
</div>
{/* Driver plan */}
{report.driver_reinstall.plan.length > 0 && (
<div className="text-xs pt-2 border-t border-border/40">
<div className="text-muted-foreground mb-1.5">Driver reinstall plan ({report.driver_reinstall.plan.length})</div>
<div className="space-y-1">
{report.driver_reinstall.plan.map((p) => (
<div key={p.component_id} className="flex items-center justify-between gap-2">
<span className="font-mono">{p.component_id}</span>
<Badge variant="outline" className="text-[10px]">{p.action}</Badge>
</div>
))}
</div>
</div>
)}
{/* Abort reason (if --apply would have been refused) */}
{report.abort_reason && (
<div className="text-xs text-red-500 p-2 border border-red-500/30 rounded-md bg-red-500/10">
{report.abort_reason}
</div>
)}
</div>
)
}

View File

@@ -14,6 +14,7 @@ import { Settings } from "./settings"
import { Security } from "./security"
import { Profile } from "./profile"
import { About } from "./about"
import { HostBackup } from "./host-backup"
import { OnboardingCarousel } from "./onboarding-carousel"
import { HealthStatusModal } from "./health-status-modal"
import { ReleaseNotesModal, useVersionCheck } from "./release-notes-modal"
@@ -30,17 +31,26 @@ import {
LayoutDashboard,
HardDrive,
NetworkIcon,
Box,
Boxes,
Cpu,
FileText,
ScrollText,
SettingsIcon,
Settings2,
Terminal,
ShieldCheck,
Info,
DatabaseBackup,
ChevronDown,
} from "lucide-react"
import Image from "next/image"
import { ThemeToggle } from "./theme-toggle"
import { Sheet, SheetContent, SheetTrigger } from "./ui/sheet"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "./ui/dropdown-menu"
interface SystemStatus {
status: "healthy" | "warning" | "critical"
@@ -352,28 +362,19 @@ export function ProxmoxDashboard() {
const getActiveTabLabel = () => {
switch (activeTab) {
case "overview":
return "Overview"
case "storage":
return "Storage"
case "network":
return "Network"
case "vms":
return "VMs & LXCs"
case "hardware":
return "Hardware"
case "terminal":
return "Terminal"
case "logs":
return "System Logs"
case "security":
return "Security"
case "settings":
return "Settings"
case "profile":
return "Profile"
default:
return "Navigation Menu"
case "overview": return "Overview"
case "vms": return "VMs & LXCs"
case "storage": return "Storage"
case "network": return "Network"
case "hardware": return "Hardware"
case "backup": return "Backup"
case "terminal": return "Terminal"
case "logs": return "System Logs"
case "security": return "Security"
case "settings": return "Settings"
case "about": return "About"
case "profile": return "Profile"
default: return "Navigation Menu"
}
}
@@ -565,71 +566,128 @@ export function ProxmoxDashboard() {
>
<div className="container mx-auto px-4 lg:px-6 pt-4 lg:pt-6">
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-0">
{/* Issue #191: 10 tabs after adding About. The grid wraps via
Tabs primitives so the extra column doesn't push the
triggers off-screen on common laptop widths. */}
<TabsList className="hidden lg:grid w-full grid-cols-10 bg-card border border-border">
<TabsTrigger
value="overview"
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:rounded-md"
>
Overview
</TabsTrigger>
<TabsTrigger
value="storage"
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:rounded-md"
>
Storage
</TabsTrigger>
<TabsTrigger
value="network"
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:rounded-md"
>
Network
</TabsTrigger>
<TabsTrigger
value="vms"
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:rounded-md"
>
VMs & LXCs
</TabsTrigger>
<TabsTrigger
value="hardware"
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:rounded-md"
>
Hardware
</TabsTrigger>
<TabsTrigger
value="logs"
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:rounded-md"
>
System Logs
</TabsTrigger>
<TabsTrigger
value="terminal"
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:rounded-md"
>
Terminal
</TabsTrigger>
<TabsTrigger
value="security"
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:rounded-md"
>
Security
</TabsTrigger>
<TabsTrigger
value="settings"
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:rounded-md"
>
Settings
</TabsTrigger>
<TabsTrigger
value="about"
className="data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:rounded-md"
>
About
</TabsTrigger>
</TabsList>
{/* Sprint 13D nav redesign — 6 top-level slots in usage order:
Overview · VMs & LXCs · Node ⌄ · Backup · Terminal · Admin ⌄
Node groups Storage / Network / Hardware (3 sub-items).
Admin groups System Logs / Security / Settings / About
(will split when RBAC arrives in 1.5.0).
Backup is direct now (only Host Backup); becomes a dropdown
when VM/LXC centralised backup ships. */}
{(() => {
const triggerActiveClass =
"data-[state=active]:bg-blue-500 data-[state=active]:text-white data-[state=active]:rounded-md"
// Each dropdown lists its children in the order they
// render. When one of them is the active tab, the dropdown
// trigger swaps its label + icon to that child — same
// pattern macOS Settings uses inside a category: the
// crumb shows where you are, the chevron tells you the
// siblings are one click away.
const NODE_ITEMS = [
{ value: "storage", label: "Storage", Icon: HardDrive, default: false },
{ value: "network", label: "Network", Icon: NetworkIcon, default: false },
{ value: "hardware", label: "Hardware", Icon: Cpu, default: false },
]
const ADMIN_ITEMS = [
{ value: "logs", label: "System Logs", Icon: ScrollText, default: false },
{ value: "security", label: "Security", Icon: ShieldCheck, default: false },
{ value: "settings", label: "Settings", Icon: SettingsIcon, default: false },
{ value: "about", label: "About", Icon: Info, default: false },
]
const activeNodeItem = NODE_ITEMS.find(i => i.value === activeTab)
const activeAdminItem = ADMIN_ITEMS.find(i => i.value === activeTab)
const isNodeActive = activeNodeItem !== undefined
const isAdminActive = activeAdminItem !== undefined
// The trigger label + icon shown on the bar. When a child
// is active we surface IT; otherwise the group default.
const NodeTriggerIcon = activeNodeItem ? activeNodeItem.Icon : Server
const NodeTriggerLabel = activeNodeItem ? activeNodeItem.label : "Node"
const AdminTriggerIcon = activeAdminItem ? activeAdminItem.Icon : Settings2
const AdminTriggerLabel = activeAdminItem ? activeAdminItem.label : "Admin"
// Dropdown trigger styling: parity with TabsTrigger so the
// parent visibly carries the "I'm the selected section"
// signal when any of its children is the active tab —
// same blue background + white text + rounded as a direct
// tab. Without this the user lands on Storage and the
// entire top bar looks idle.
const dropdownBtnClass = (active: boolean) =>
`inline-flex items-center justify-center whitespace-nowrap px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 ${
active
? "bg-blue-500 text-white rounded-md"
: "text-muted-foreground hover:text-foreground rounded-sm"
}`
return (
<TabsList className="hidden lg:grid w-full grid-cols-6 bg-card border border-border">
{/* Direct: Overview */}
<TabsTrigger value="overview" className={triggerActiveClass}>
<LayoutDashboard className="mr-2 h-4 w-4" />
Overview
</TabsTrigger>
{/* Direct: VMs & LXCs — first-class because Proxmox IS
a hypervisor; workloads belong at top level. */}
<TabsTrigger value="vms" className={triggerActiveClass}>
<Boxes className="mr-2 h-4 w-4" />
VMs &amp; LXCs
</TabsTrigger>
{/* Dropdown: Node (Storage / Network / Hardware) */}
<DropdownMenu>
<DropdownMenuTrigger className={dropdownBtnClass(isNodeActive)}>
<NodeTriggerIcon className="mr-2 h-4 w-4" />
{NodeTriggerLabel}
<ChevronDown className="ml-1.5 h-3 w-3 opacity-70" />
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="min-w-[180px]">
{NODE_ITEMS.map(({ value, label, Icon }) => (
<DropdownMenuItem
key={value}
onClick={() => setActiveTab(value)}
className={activeTab === value ? "bg-blue-500/10 text-blue-500" : ""}
>
<Icon className="mr-2 h-4 w-4" />
{label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* Direct: Backup (today: Host Backup only). When VM/LXC
backup ships this becomes a dropdown. */}
<TabsTrigger value="backup" className={triggerActiveClass}>
<DatabaseBackup className="mr-2 h-4 w-4" />
Backup
</TabsTrigger>
{/* Direct: Terminal */}
<TabsTrigger value="terminal" className={triggerActiveClass}>
<Terminal className="mr-2 h-4 w-4" />
Terminal
</TabsTrigger>
{/* Dropdown: Admin (System Logs / Security / Settings / About) */}
<DropdownMenu>
<DropdownMenuTrigger className={dropdownBtnClass(isAdminActive)}>
<AdminTriggerIcon className="mr-2 h-4 w-4" />
{AdminTriggerLabel}
<ChevronDown className="ml-1.5 h-3 w-3 opacity-70" />
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="min-w-[180px]">
{ADMIN_ITEMS.map(({ value, label, Icon }) => (
<DropdownMenuItem
key={value}
onClick={() => setActiveTab(value)}
className={activeTab === value ? "bg-blue-500/10 text-blue-500" : ""}
>
<Icon className="mr-2 h-4 w-4" />
{label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</TabsList>
)
})()}
<Sheet open={mobileMenuOpen} onOpenChange={setMobileMenuOpen}>
<div className="lg:hidden">
@@ -646,158 +704,74 @@ export function ProxmoxDashboard() {
</SheetTrigger>
</div>
<SheetContent side="top" className="bg-card border-border">
<div className="flex flex-col gap-2 mt-4">
<Button
variant="ghost"
onClick={() => {
setActiveTab("overview")
setMobileMenuOpen(false)
}}
className={`w-full justify-start gap-3 ${
activeTab === "overview"
{(() => {
// Sheet items mirror the desktop layout: 6 sections,
// with two of them (Node, Admin) collapsing into a
// header + nested items. Direct tabs (Overview, VMs,
// Backup, Terminal) sit at the top level.
const select = (v: string) => {
setActiveTab(v)
setMobileMenuOpen(false)
}
const itemClass = (active: boolean) =>
`w-full justify-start gap-3 ${
active
? "bg-blue-500/10 text-blue-500 border-l-4 border-blue-500 rounded-l-none"
: ""
}`}
>
<LayoutDashboard className="h-5 w-5" />
<span>Overview</span>
</Button>
<Button
variant="ghost"
onClick={() => {
setActiveTab("storage")
setMobileMenuOpen(false)
}}
className={`w-full justify-start gap-3 ${
activeTab === "storage"
? "bg-blue-500/10 text-blue-500 border-l-4 border-blue-500 rounded-l-none"
: ""
}`}
>
<HardDrive className="h-5 w-5" />
<span>Storage</span>
</Button>
<Button
variant="ghost"
onClick={() => {
setActiveTab("network")
setMobileMenuOpen(false)
}}
className={`w-full justify-start gap-3 ${
activeTab === "network"
? "bg-blue-500/10 text-blue-500 border-l-4 border-blue-500 rounded-l-none"
: ""
}`}
>
<NetworkIcon className="h-5 w-5" />
<span>Network</span>
</Button>
<Button
variant="ghost"
onClick={() => {
setActiveTab("vms")
setMobileMenuOpen(false)
}}
className={`w-full justify-start gap-3 ${
activeTab === "vms"
? "bg-blue-500/10 text-blue-500 border-l-4 border-blue-500 rounded-l-none"
: ""
}`}
>
<Box className="h-5 w-5" />
<span>VMs & LXCs</span>
</Button>
<Button
variant="ghost"
onClick={() => {
setActiveTab("hardware")
setMobileMenuOpen(false)
}}
className={`w-full justify-start gap-3 ${
activeTab === "hardware"
? "bg-blue-500/10 text-blue-500 border-l-4 border-blue-500 rounded-l-none"
: ""
}`}
>
<Cpu className="h-5 w-5" />
<span>Hardware</span>
</Button>
<Button
variant="ghost"
onClick={() => {
setActiveTab("logs")
setMobileMenuOpen(false)
}}
className={`w-full justify-start gap-3 ${
activeTab === "logs"
? "bg-blue-500/10 text-blue-500 border-l-4 border-blue-500 rounded-l-none"
: ""
}`}
>
<FileText className="h-5 w-5" />
<span>System Logs</span>
</Button>
<Button
variant="ghost"
onClick={() => {
setActiveTab("terminal")
setMobileMenuOpen(false)
}}
className={`w-full justify-start gap-3 ${
activeTab === "terminal"
? "bg-blue-500/10 text-blue-500 border-l-4 border-blue-500 rounded-l-none"
: ""
}`}
>
<Terminal className="h-5 w-5" />
<span>Terminal</span>
</Button>
<Button
variant="ghost"
onClick={() => {
setActiveTab("security")
setMobileMenuOpen(false)
}}
className={`w-full justify-start gap-3 ${
activeTab === "security"
? "bg-blue-500/10 text-blue-500 border-l-4 border-blue-500 rounded-l-none"
: ""
}`}
>
<ShieldCheck className="h-5 w-5" />
<span>Security</span>
</Button>
<Button
variant="ghost"
onClick={() => {
setActiveTab("settings")
setMobileMenuOpen(false)
}}
className={`w-full justify-start gap-3 ${
activeTab === "settings"
? "bg-blue-500/10 text-blue-500 border-l-4 border-blue-500 rounded-l-none"
: ""
}`}
>
<SettingsIcon className="h-5 w-5" />
<span>Settings</span>
</Button>
<Button
variant="ghost"
onClick={() => {
setActiveTab("about")
setMobileMenuOpen(false)
}}
className={`w-full justify-start gap-3 ${
activeTab === "about"
? "bg-blue-500/10 text-blue-500 border-l-4 border-blue-500 rounded-l-none"
: ""
}`}
>
<Info className="h-5 w-5" />
<span>About</span>
</Button>
</div>
}`
// Mobile sheet is a flat list (no section headers).
// The desktop layout uses dropdowns to express the
// Node/Admin grouping; here we just enumerate items
// in the same visual order.
return (
<div className="flex flex-col gap-1 mt-4">
<Button variant="ghost" onClick={() => select("overview")} className={itemClass(activeTab === "overview")}>
<LayoutDashboard className="h-5 w-5" />
<span>Overview</span>
</Button>
<Button variant="ghost" onClick={() => select("vms")} className={itemClass(activeTab === "vms")}>
<Boxes className="h-5 w-5" />
<span>VMs &amp; LXCs</span>
</Button>
<Button variant="ghost" onClick={() => select("storage")} className={itemClass(activeTab === "storage")}>
<HardDrive className="h-5 w-5" />
<span>Storage</span>
</Button>
<Button variant="ghost" onClick={() => select("network")} className={itemClass(activeTab === "network")}>
<NetworkIcon className="h-5 w-5" />
<span>Network</span>
</Button>
<Button variant="ghost" onClick={() => select("hardware")} className={itemClass(activeTab === "hardware")}>
<Cpu className="h-5 w-5" />
<span>Hardware</span>
</Button>
<Button variant="ghost" onClick={() => select("backup")} className={itemClass(activeTab === "backup")}>
<DatabaseBackup className="h-5 w-5" />
<span>Backup</span>
</Button>
<Button variant="ghost" onClick={() => select("terminal")} className={itemClass(activeTab === "terminal")}>
<Terminal className="h-5 w-5" />
<span>Terminal</span>
</Button>
<Button variant="ghost" onClick={() => select("logs")} className={itemClass(activeTab === "logs")}>
<ScrollText className="h-5 w-5" />
<span>System Logs</span>
</Button>
<Button variant="ghost" onClick={() => select("security")} className={itemClass(activeTab === "security")}>
<ShieldCheck className="h-5 w-5" />
<span>Security</span>
</Button>
<Button variant="ghost" onClick={() => select("settings")} className={itemClass(activeTab === "settings")}>
<SettingsIcon className="h-5 w-5" />
<span>Settings</span>
</Button>
<Button variant="ghost" onClick={() => select("about")} className={itemClass(activeTab === "about")}>
<Info className="h-5 w-5" />
<span>About</span>
</Button>
</div>
)
})()}
</SheetContent>
</Sheet>
</Tabs>
@@ -830,6 +804,10 @@ export function ProxmoxDashboard() {
<SystemLogs key={`logs-${componentKey}`} />
</TabsContent>
<TabsContent value="backup" className="space-y-4 md:space-y-6 mt-0">
<HostBackup key={`backup-${componentKey}`} />
</TabsContent>
<TabsContent value="terminal" className="mt-0">
<TerminalPanel key={`terminal-${componentKey}`} />
</TabsContent>

View File

@@ -12128,6 +12128,440 @@ def stream_script_logs(session_id):
return jsonify({'success': False, 'error': str(e)}), 500
# ── Host Backup (Sprint 13D, 1.3.0 preview) ──────────────────
# These endpoints surface the host-backup pipeline implemented in
# scripts/backup_restore/ (collectors + restore tooling). They:
# - list configured scheduled jobs (from /var/lib/proxmenux/backup-jobs/)
# - list backup archives present on disk for local_tar destinations
# - extract the manifest from an archive (uses parse_manifest.sh)
# - run the dry-run preflight report (uses run_restore.sh)
# Mutating actions (run-now, create-job, --apply restore) stay on CLI for
# now — UI surface for those lands later in the 1.3.x cycle.
_PROXMENUX_SCRIPTS_DIR = '/usr/local/share/proxmenux/scripts'
_BACKUP_JOBS_DIR = '/var/lib/proxmenux/backup-jobs'
_BACKUP_LOG_DIR = '/var/log/proxmenux/backup-jobs'
# Always scan PVE's default dump directory in addition to per-job
# DEST_DIRs — manual backups from backup_host.sh (options 1-6) land
# there without ever creating a job env file.
_BACKUP_DEFAULT_DUMP_DIRS = ('/var/lib/vz/dump',)
# Filenames produced by ProxMenux host backups:
# manual (backup_host.sh line 253): hostcfg-<HOSTNAME>-YYYYMMDD_HHMMSS.tar.zst
# scheduled (run_scheduled_backup.sh): <JOB_ID>-YYYYMMDD_HHMMSS.<ext>
# This regex matches both; we then cross-check against the known job_ids
# (everything else, like PVE's vzdump-lxc-*, gets dropped).
_BACKUP_FILENAME_RE = re.compile(r'^([A-Za-z0-9._-]+)-(\d{8}_\d{6})\.tar(\.zst|\.gz)?$')
def _parse_job_env(file_path: str) -> dict:
"""Parse a /var/lib/proxmenux/backup-jobs/*.env file (shell KEY=value
format with optional quoting) into a Python dict. Returns {} on any
I/O or parse error so callers can just .get() with defaults."""
out: dict = {}
try:
with open(file_path) as f:
for raw in f:
line = raw.strip()
if not line or line.startswith('#') or '=' not in line:
continue
key, val = line.split('=', 1)
key = key.strip()
val = val.strip()
# Strip shell quoting if balanced
if len(val) >= 2 and val[0] == val[-1] and val[0] in ('"', "'"):
val = val[1:-1]
out[key] = val
except OSError:
pass
return out
def _collect_backup_scan_dirs():
"""Build the de-duplicated list of directories we scan for host
backup archives: the PVE default(s) plus every local_tar job's
DEST_DIR. Returns directories that actually exist on disk."""
import glob
dirs = []
seen = set()
def _add(d):
if d and d not in seen and os.path.isdir(d):
seen.add(d)
dirs.append(d)
for d in _BACKUP_DEFAULT_DUMP_DIRS:
_add(d)
try:
env_files = sorted(glob.glob(f'{_BACKUP_JOBS_DIR}/*.env'))
except OSError:
env_files = []
for env_file in env_files:
job = _parse_job_env(env_file)
if job.get('METHOD') != 'local_tar':
continue
_add(job.get('DEST_DIR') or job.get('DEST'))
return dirs
def _known_job_ids():
"""Set of job_ids that have a .env file on disk — used to associate
a scheduled archive (<job_id>-<ts>.tar*) with its job."""
import glob
try:
env_files = glob.glob(f'{_BACKUP_JOBS_DIR}/*.env')
except OSError:
return set()
return {os.path.basename(p)[:-len('.env')] for p in env_files}
# In-process cache for the tar-peek fallback so we don't re-decompress
# every archive on every Monitor refresh. Keyed by absolute archive
# path; the cached tuple is (size, mtime, is_proxmenux_backup_bool).
# Invalidated automatically whenever size or mtime changes.
_BACKUP_PEEK_CACHE: dict = {}
def _read_archive_sidecar(archive_path):
"""Read and parse the <archive>.proxmenux.json sidecar if present.
Returns the parsed dict on success, or None if the sidecar is
missing or unreadable. A corrupted sidecar drops back to the next
detection path (peek) rather than masking the archive entirely."""
sidecar = archive_path + '.proxmenux.json'
if not os.path.isfile(sidecar):
return None
try:
with open(sidecar) as f:
data = json.load(f)
except (OSError, json.JSONDecodeError):
return None
return data if isinstance(data, dict) else None
def _peek_host_backup_marker(archive_path, st):
"""Check whether the archive contains 'metadata/run_info.env' — the
in-tar marker that every ProxMenux host backup ships with. Used as
a fallback when no sidecar is present (legacy archives, or archives
copied in from elsewhere). Result is cached by (size, mtime) so a
second call within the same process is free.
Implementation: stream `tar -atf` (auto-detect compression by
extension; GNU tar 1.30+) line by line and short-circuit as soon as
we hit the marker. The marker lives in the first ~10-20 entries of
every ProxMenux archive, so we cap the scan at 500 entries — well
above the real archive's TOC depth but bounded enough that a
pathological archive can't keep the worker tied up.
"""
cached = _BACKUP_PEEK_CACHE.get(archive_path)
if cached and cached[0] == st.st_size and cached[1] == int(st.st_mtime):
return cached[2]
is_pmx = False
proc = None
try:
proc = subprocess.Popen(
['tar', '-atf', archive_path],
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
)
assert proc.stdout is not None
for i, line in enumerate(proc.stdout):
if i > 500:
break
entry = line.strip()
if entry.startswith('./'):
entry = entry[2:]
entry = entry.rstrip('/')
if entry == 'metadata/run_info.env':
is_pmx = True
break
except OSError:
is_pmx = False
finally:
if proc is not None:
try:
proc.kill()
except OSError:
pass
try:
proc.wait(timeout=2)
except (subprocess.TimeoutExpired, OSError):
pass
_BACKUP_PEEK_CACHE[archive_path] = (st.st_size, int(st.st_mtime), is_pmx)
return is_pmx
def _identify_host_backup(archive_path, st, hostname, job_ids):
"""Return a dict of {kind, job_id, profile, source_hostname,
detected_via} if this archive is a ProxMenux host backup, or None
if it isn't (or we can't tell).
Order of confidence (best → worst):
1. <archive>.proxmenux.json sidecar — definitive, written by the
backup script when the archive completes.
2. Filename matches a known scheduled job_id (.env still on disk).
3. Filename starts with 'hostcfg-' — the convention for manual
and the recommended convention for scheduled jobs.
4. Tar-peek for metadata/run_info.env — the universal marker that
every ProxMenux backup carries inside. Caches by mtime/size so
repeat calls are free.
"""
sc = _read_archive_sidecar(archive_path)
if sc is not None:
return {
'kind': sc.get('kind') or 'manual',
'job_id': sc.get('job_id'),
'profile': sc.get('profile'),
'source_hostname': sc.get('hostname'),
'detected_via': 'sidecar',
}
name = os.path.basename(archive_path)
m = _BACKUP_FILENAME_RE.match(name)
stem = m.group(1) if m else None
if stem and stem in job_ids:
return {
'kind': 'scheduled', 'job_id': stem,
'profile': None, 'source_hostname': None,
'detected_via': 'job_id_match',
}
if stem == f'hostcfg-{hostname}':
return {
'kind': 'manual', 'job_id': None,
'profile': None, 'source_hostname': hostname,
'detected_via': 'hostcfg_prefix',
}
if stem and stem.startswith('hostcfg-'):
return {
'kind': 'manual', 'job_id': None,
'profile': None, 'source_hostname': None,
'detected_via': 'hostcfg_prefix',
}
if _peek_host_backup_marker(archive_path, st):
return {
'kind': 'legacy', 'job_id': None,
'profile': None, 'source_hostname': None,
'detected_via': 'tar_peek',
}
return None
def _find_backup_archive_path(archive_id):
"""Resolve an archive_id (basename) to an absolute path by checking
every directory we scan for backups (PVE default + per-job DEST_DIRs).
Returns None if the file isn't found anywhere we know about, or if
the resolved file isn't identifiable as a ProxMenux backup. This is
a deliberate allow-list: callers can't request arbitrary host paths
via the API even if they hit the inspect/preflight URLs directly."""
if '/' in archive_id or archive_id in ('.', '..') or archive_id.startswith('.'):
return None # don't let basename traversal sneak through
if not archive_id.endswith(_BACKUP_TAR_SUFFIXES):
return None # we only handle the tar family
hostname = socket.gethostname()
job_ids = _known_job_ids()
for d in _collect_backup_scan_dirs():
candidate = os.path.join(d, archive_id)
if not os.path.isfile(candidate):
continue
try:
st = os.stat(candidate)
except OSError:
continue
if _identify_host_backup(candidate, st, hostname, job_ids) is None:
continue # exists but isn't a ProxMenux backup — reject
return candidate
return None
@app.route('/api/host-backups/jobs', methods=['GET'])
@require_auth
def api_host_backups_jobs():
"""List scheduled host-backup jobs created via the backup_scheduler
CLI. Each job has a .env file + systemd timer. We report on both,
plus the last-run status when available."""
import glob
jobs: list = []
try:
env_files = sorted(glob.glob(f'{_BACKUP_JOBS_DIR}/*.env'))
except OSError:
env_files = []
for env_file in env_files:
job_id = os.path.basename(env_file)[:-len('.env')]
job = _parse_job_env(env_file)
timer_unit = f'proxmenux-backup-{job_id}.timer'
timer_enabled = subprocess.run(
['systemctl', 'is-enabled', '--quiet', timer_unit],
capture_output=True
).returncode == 0
last_status = None
last_status_file = f'{_BACKUP_LOG_DIR}/{job_id}-last.status'
if os.path.exists(last_status_file):
try:
with open(last_status_file) as f:
last_status = f.read().strip()
except OSError:
pass
# Next scheduled run from systemctl list-timers
next_run = None
try:
r = subprocess.run(
['systemctl', 'list-timers', '--no-pager', '--output=json', timer_unit],
capture_output=True, text=True, timeout=5
)
if r.returncode == 0 and r.stdout.strip():
rows = json.loads(r.stdout)
if rows and isinstance(rows, list):
next_run = rows[0].get('next')
except (subprocess.TimeoutExpired, json.JSONDecodeError, ValueError, OSError):
pass
jobs.append({
'id': job_id,
'destination': (job.get('DEST_DIR') or job.get('DEST')
or job.get('PBS_REPO') or job.get('BORG_REPO') or ''),
'method': job.get('METHOD') or 'unknown',
'on_calendar': job.get('ON_CALENDAR') or 'manual',
'retention': job.get('RETENTION') or '',
'timer_enabled': timer_enabled,
'last_status': last_status,
'next_run': next_run,
})
return jsonify({'jobs': jobs})
_BACKUP_TAR_SUFFIXES = ('.tar', '.tar.zst', '.tar.gz')
@app.route('/api/host-backups/archives', methods=['GET'])
@require_auth
def api_host_backups_archives():
"""List ProxMenux host-backup archives found on disk.
Scans /var/lib/vz/dump (PVE default — covers manual backups from
backup_host.sh options 1-6) plus every DEST_DIR registered by a
local_tar scheduled job. For each archive, _identify_host_backup()
decides whether it's really a ProxMenux backup using, in order of
confidence: (a) the .proxmenux.json sidecar dropped by the backup
scripts at completion (definitive — survives any future rename of
the .tar); (b) the filename conventions (`hostcfg-<host>-<ts>` for
manual, `<job_id>-<ts>` for scheduled with the job env still on
disk); (c) a tar-peek for the in-archive `metadata/run_info.env`
marker that every ProxMenux backup ships with (catches legacy
archives and ones copied in from another host).
PBS and Borg backups aren't surfaced in the UI yet."""
archives: list = []
seen: set = set()
hostname = socket.gethostname()
job_ids = _known_job_ids()
for d in _collect_backup_scan_dirs():
try:
entries = os.listdir(d)
except OSError:
continue
for name in entries:
if not name.endswith(_BACKUP_TAR_SUFFIXES):
continue
tar_path = os.path.join(d, name)
if tar_path in seen:
continue
seen.add(tar_path)
try:
st = os.stat(tar_path)
except OSError:
continue
info = _identify_host_backup(tar_path, st, hostname, job_ids)
if info is None:
continue
archives.append({
'id': name,
'path': tar_path,
'size_bytes': st.st_size,
'mtime': int(st.st_mtime),
**info,
})
archives.sort(key=lambda a: a['mtime'], reverse=True)
return jsonify({'archives': archives})
@app.route('/api/host-backups/archives/<path:archive_id>/manifest', methods=['GET'])
@require_auth
def api_host_backups_archive_manifest(archive_id):
"""Extract the manifest.json embedded inside a backup archive,
using scripts/backup_restore/restore/parse_manifest.sh. Returns the
unwrapped manifest (i.e. without the proxmenux_backup_manifest key)."""
archive_path = _find_backup_archive_path(archive_id)
if not archive_path:
return jsonify({'error': 'archive not found'}), 404
parse_script = f'{_PROXMENUX_SCRIPTS_DIR}/backup_restore/restore/parse_manifest.sh'
if not os.path.exists(parse_script):
return jsonify({'error': 'restore tooling not installed on this host',
'install_hint': 'Run the ProxMenux installer to deploy scripts/backup_restore/'}), 503
try:
r = subprocess.run(['bash', parse_script, archive_path],
capture_output=True, text=True, timeout=30)
except (subprocess.TimeoutExpired, OSError) as e:
return jsonify({'error': f'parser invocation failed: {e}'}), 500
if r.returncode != 0:
return jsonify({'error': r.stderr.strip() or 'parse_manifest exited non-zero'}), 422
try:
return jsonify(json.loads(r.stdout))
except json.JSONDecodeError:
return jsonify({'error': 'parser output was not valid JSON'}), 500
@app.route('/api/host-backups/archives/<path:archive_id>/preflight', methods=['POST'])
@require_auth
def api_host_backups_archive_preflight(archive_id):
"""Run the dry-run preflight + storage + network + driver-plan report
for this archive against the current host. Body: {"mode": "<mode>"}.
Modes match restore_modes.sh: full, storage_only, network_only, base,
custom. Returns the combined run_restore.sh JSON report."""
archive_path = _find_backup_archive_path(archive_id)
if not archive_path:
return jsonify({'error': 'archive not found'}), 404
body = request.get_json(silent=True) or {}
mode = body.get('mode', 'full')
if mode not in ('full', 'storage_only', 'network_only', 'base', 'custom'):
return jsonify({'error': f'unknown mode "{mode}"'}), 400
run_script = f'{_PROXMENUX_SCRIPTS_DIR}/backup_restore/restore/run_restore.sh'
if not os.path.exists(run_script):
return jsonify({'error': 'restore tooling not installed on this host',
'install_hint': 'Run the ProxMenux installer to deploy scripts/backup_restore/'}), 503
try:
r = subprocess.run(
['bash', run_script, archive_path, '--mode', mode, '--json'],
capture_output=True, text=True, timeout=120
)
except (subprocess.TimeoutExpired, OSError) as e:
return jsonify({'error': f'preflight invocation failed: {e}'}), 500
# run_restore.sh exits non-zero when preflight has fails; we still
# want to surface the report so the UI can show what failed.
if not r.stdout.strip():
return jsonify({'error': r.stderr.strip() or 'no report emitted'}), 500
try:
return jsonify(json.loads(r.stdout))
except json.JSONDecodeError:
return jsonify({'error': 'run_restore output was not valid JSON',
'raw_stderr': r.stderr[:2000]}), 500
if __name__ == '__main__':
import sys
import logging