create 1.2.2.3 beta

This commit is contained in:
MacRimi
2026-07-05 16:50:06 +02:00
parent 9f03164258
commit 7fc7125c71
18 changed files with 1581 additions and 300 deletions
+1 -1
View File
@@ -1 +1 @@
08b669193097449f8330ad430066574bf0e99f477aea97172e6b845ad4ee648c ProxMenux-1.2.2.2-beta.AppImage 4abe67f2d2d7516c438d222ed73fb166b0f6b3b77e85e9a6be37194b4b178cdd ProxMenux-1.2.2.3-beta.AppImage
+214 -89
View File
@@ -46,6 +46,7 @@ import {
History, History,
} from "lucide-react" } from "lucide-react"
import { ScriptTerminalModal } from "./script-terminal-modal" import { ScriptTerminalModal } from "./script-terminal-modal"
import { RestoreProgressCard } from "./restore-progress-card"
import { fetchApi, getApiUrl } from "../lib/api-config" import { fetchApi, getApiUrl } from "../lib/api-config"
import { fetchTerminalTicket } from "../lib/terminal-ws" import { fetchTerminalTicket } from "../lib/terminal-ws"
import { formatStorage, formatBytes } from "../lib/utils" import { formatStorage, formatBytes } from "../lib/utils"
@@ -415,6 +416,12 @@ export function HostBackup() {
return ( return (
<div className="space-y-4 md:space-y-6"> <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 ───────────────────────────────── */} {/* ── Scheduled jobs ───────────────────────────────── */}
<Card className="bg-card border-border"> <Card className="bg-card border-border">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
@@ -2347,12 +2354,21 @@ function CreateJobDialog({
if (mode === "attach" && !selectedPveJob) return if (mode === "attach" && !selectedPveJob) return
setSubmitting(true) setSubmitting(true)
setError(null) setError(null)
// "Import existing" mode: push the user-supplied keyfile to the // Encryption submit paths:
// canonical shared path BEFORE anything else. This mirrors the // • enabled + keyfile already on disk → mode = "existing", no upload
// shell wizard where hb_pbs_import_keyfile runs before recovery // (backend reuses the canonical file at _PBS_KEYFILE_PATH).
// setup and before the job .env is written. If the upload fails // • enabled + no keyfile + Generate → mode = "new", backend creates it.
// we bail loudly instead of silently downgrading to "generate new". // • enabled + no keyfile + Import → upload the file first, then
if (backend === "pbs" && pbsEncryptMode === "existing") { // mode = "existing" so the backend uses the freshly-installed key.
//
// Only the third branch actually needs to POST to
// /api/host-backups/pbs-encryption/import — the second is handled
// server-side, and the first has nothing to upload.
const needsImport =
backend === "pbs" &&
pbsEncryptMode === "existing" &&
!pbsRecoveryStatus?.has_keyfile
if (needsImport) {
if (!pbsImportFile) { if (!pbsImportFile) {
setError("Pick a keyfile to import.") setError("Pick a keyfile to import.")
setSubmitting(false) setSubmitting(false)
@@ -2995,51 +3011,100 @@ function CreateJobDialog({
PBS organises backups into named groups, each with its own retention. Leave blank to use the automatic default for this host (recommended). PBS organises backups into named groups, each with its own retention. Leave blank to use the automatic default for this host (recommended).
</p> </p>
</div> </div>
{/* PBS client-side encryption same flow as the {/* PBS client-side encryption mirrors the shell wizard:
shell wizard. The keyfile can be freshly generated step 1 is a plain yes/no, step 2 only appears when
per host or imported from an existing one the no keyfile is installed yet. Once a keyfile exists,
operator uses across every host. */} every future encrypted job silently reuses it. */}
<div className="pt-2 border-t border-border space-y-3"> <div className="pt-2 border-t border-border space-y-3">
<div className="space-y-2"> <label className="flex items-start gap-2 cursor-pointer">
<Label className="inline-flex items-center gap-1.5"> <Checkbox
<Lock className="h-3.5 w-3.5 text-emerald-400" /> checked={pbsEncrypt}
Encrypt backups (client-side keyfile) onCheckedChange={(v) => {
</Label> const checked = !!v
<Select if (!checked) {
value={pbsEncryptMode} setPbsEncryptMode("none")
onValueChange={(v) => setPbsEncryptMode(v as "none" | "new" | "existing")} } else {
> // Yes → if the host already has a keyfile, submit
<SelectTrigger className="h-9 text-xs"> // "existing" (reuse it silently). If not, default
<SelectValue /> // to "new" (Generate) — the operator can flip to
</SelectTrigger> // "existing" (Import) via the radio below.
<SelectContent> setPbsEncryptMode(pbsRecoveryStatus?.has_keyfile ? "existing" : "new")
<SelectItem value="none">No encryption</SelectItem> }
<SelectItem value="new">Generate a new keyfile (per host safest isolation)</SelectItem> }}
<SelectItem value="existing">Import an existing keyfile (shared across hosts)</SelectItem> className="mt-0.5"
</SelectContent> />
</Select> <div className="flex-1">
<p className="text-[11px] text-muted-foreground"> <div className="inline-flex items-center gap-1.5 text-sm">
Adds <code className="font-mono">--keyfile</code> to <code className="font-mono">proxmox-backup-client backup</code>. Encryption happens before upload so chunks land on PBS already ciphered. The keyfile is installed at <code className="font-mono">/usr/local/share/proxmenux/pbs-key.conf</code> (chmod 0600) and reused by every encrypted PBS job on this host. <Lock className="h-3.5 w-3.5 text-emerald-400" />
</p> Encrypt backups (client-side keyfile)
</div> </div>
{pbsEncryptMode === "existing" && ( <p className="text-[11px] text-muted-foreground mt-1">
<div className="space-y-2 rounded-md border border-emerald-500/30 bg-emerald-500/5 p-3"> Adds <code className="font-mono">--keyfile</code> to <code className="font-mono">proxmox-backup-client backup</code>. Encryption happens before upload so chunks land on PBS already ciphered. The keyfile is installed at <code className="font-mono">/usr/local/share/proxmenux/pbs-key.conf</code> (chmod 0600) and reused by every encrypted PBS job on this host.
<Label htmlFor="pbsImportFile" className="text-[11px] font-medium">
Keyfile to import
</Label>
<Input
id="pbsImportFile"
type="file"
accept=".conf,.key,application/json,text/plain"
onChange={(e) => setPbsImportFile(e.target.files?.[0] ?? null)}
disabled={pbsImportBusy}
className="h-8 text-[11px]"
/>
<p className="text-[10px] text-muted-foreground">
The file is validated with <code className="font-mono">proxmox-backup-client key info</code> before being installed. If validation fails the job is not created and the existing keyfile (if any) stays in place.
</p> </p>
</div> </div>
</label>
{pbsEncrypt && pbsRecoveryStatus?.has_keyfile && (
<div className="rounded-md border border-emerald-500/30 bg-emerald-500/5 p-3 text-[11px] text-muted-foreground flex items-start gap-2">
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-400 mt-0.5 shrink-0" />
<div>
An encryption key is already installed on this host it will be reused for this job. To replace it, first import a new one from Settings Host backup or remove the existing keyfile.
</div>
</div>
)} )}
{pbsEncrypt && !pbsRecoveryStatus?.has_keyfile && (
<div className="space-y-2 rounded-md border border-emerald-500/30 bg-emerald-500/5 p-3">
<div className="text-[11px] font-medium">
No encryption key is stored on this host. Choose how to set one up:
</div>
<label className="flex items-start gap-2 cursor-pointer text-[11px]">
<input
type="radio"
name="pbsEncryptSetupCreate"
checked={pbsEncryptMode === "new"}
onChange={() => setPbsEncryptMode("new")}
className="mt-1"
/>
<div className="flex-1">
<div className="font-medium">Generate a new keyfile (per host safest isolation)</div>
<div className="text-muted-foreground">Creates a fresh keyfile at <code className="font-mono">/usr/local/share/proxmenux/pbs-key.conf</code>. Backup up the recovery passphrase (below) to survive host loss.</div>
</div>
</label>
<label className="flex items-start gap-2 cursor-pointer text-[11px]">
<input
type="radio"
name="pbsEncryptSetupCreate"
checked={pbsEncryptMode === "existing"}
onChange={() => setPbsEncryptMode("existing")}
className="mt-1"
/>
<div className="flex-1">
<div className="font-medium">Import an existing keyfile (shared across hosts)</div>
<div className="text-muted-foreground">Use the same keyfile another host already has enables cross-host restore of encrypted backups.</div>
</div>
</label>
{pbsEncryptMode === "existing" && (
<div className="space-y-1.5 pt-1">
<Label htmlFor="pbsImportFile" className="text-[11px] font-medium">
Keyfile to import
</Label>
<Input
id="pbsImportFile"
type="file"
accept=".conf,.key,application/json,text/plain"
onChange={(e) => setPbsImportFile(e.target.files?.[0] ?? null)}
disabled={pbsImportBusy}
className="h-8 text-[11px]"
/>
<p className="text-[10px] text-muted-foreground">
Validated with <code className="font-mono">proxmox-backup-client key info</code> before install. On validation failure the job is not created and no keyfile is written.
</p>
</div>
)}
</div>
)}
{pbsEncrypt && ( {pbsEncrypt && (
<div className="pl-7 space-y-2 rounded-md border border-blue-500/30 bg-blue-500/5 p-3"> <div className="pl-7 space-y-2 rounded-md border border-blue-500/30 bg-blue-500/5 p-3">
<div className="text-[11px] font-medium text-foreground flex items-center gap-1.5"> <div className="text-[11px] font-medium text-foreground flex items-center gap-1.5">
@@ -3562,10 +3627,14 @@ function ManualBackupDialog({
if (!canSubmit) return if (!canSubmit) return
setSubmitting(true) setSubmitting(true)
setError(null) setError(null)
// "Import existing" mode: same shape as CreateJobDialog — push the // Same three encryption submit paths as CreateJobDialog. Only
// keyfile to the canonical path before anything else, bail loudly // "Import + no keyfile installed yet" actually uploads; the on-disk
// if validation fails so we don't silently fall back to "new". // reuse case sends mode=existing without touching the file.
if (backend === "pbs" && pbsEncryptMode === "existing") { const needsImport =
backend === "pbs" &&
pbsEncryptMode === "existing" &&
!pbsRecoveryStatus?.has_keyfile
if (needsImport) {
if (!pbsImportFile) { if (!pbsImportFile) {
setError("Pick a keyfile to import.") setError("Pick a keyfile to import.")
setSubmitting(false) setSubmitting(false)
@@ -3827,46 +3896,91 @@ function ManualBackupDialog({
/> />
</div> </div>
<div className="pt-2 border-t border-border space-y-3"> <div className="pt-2 border-t border-border space-y-3">
<div className="space-y-2"> <label className="flex items-start gap-2 cursor-pointer">
<Label className="inline-flex items-center gap-1.5"> <Checkbox
<Lock className="h-3.5 w-3.5 text-emerald-400" /> checked={pbsEncrypt}
Encrypt this backup (client-side keyfile) onCheckedChange={(v) => {
</Label> const checked = !!v
<Select if (!checked) {
value={pbsEncryptMode} setPbsEncryptMode("none")
onValueChange={(v) => setPbsEncryptMode(v as "none" | "new" | "existing")} } else {
> setPbsEncryptMode(pbsRecoveryStatus?.has_keyfile ? "existing" : "new")
<SelectTrigger className="h-9 text-xs"> }
<SelectValue /> }}
</SelectTrigger> className="mt-0.5"
<SelectContent> />
<SelectItem value="none">No encryption</SelectItem> <div className="flex-1">
<SelectItem value="new">Generate a new keyfile (per host safest isolation)</SelectItem> <div className="inline-flex items-center gap-1.5 text-sm">
<SelectItem value="existing">Import an existing keyfile (shared across hosts)</SelectItem> <Lock className="h-3.5 w-3.5 text-emerald-400" />
</SelectContent> Encrypt this backup (client-side keyfile)
</Select> </div>
<p className="text-[11px] text-muted-foreground"> <p className="text-[11px] text-muted-foreground mt-1">
Uses the shared keyfile at <code className="font-mono">/usr/local/share/proxmenux/pbs-key.conf</code>. Uses the shared keyfile at <code className="font-mono">/usr/local/share/proxmenux/pbs-key.conf</code>.
</p>
</div>
{pbsEncryptMode === "existing" && (
<div className="space-y-2 rounded-md border border-emerald-500/30 bg-emerald-500/5 p-3">
<Label htmlFor="manualPbsImportFile" className="text-[11px] font-medium">
Keyfile to import
</Label>
<Input
id="manualPbsImportFile"
type="file"
accept=".conf,.key,application/json,text/plain"
onChange={(e) => setPbsImportFile(e.target.files?.[0] ?? null)}
disabled={pbsImportBusy}
className="h-8 text-[11px]"
/>
<p className="text-[10px] text-muted-foreground">
Validated with <code className="font-mono">proxmox-backup-client key info</code> before install.
</p> </p>
</div> </div>
</label>
{pbsEncrypt && pbsRecoveryStatus?.has_keyfile && (
<div className="rounded-md border border-emerald-500/30 bg-emerald-500/5 p-3 text-[11px] text-muted-foreground flex items-start gap-2">
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-400 mt-0.5 shrink-0" />
<div>
An encryption key is already installed on this host it will be reused for this backup.
</div>
</div>
)} )}
{pbsEncrypt && !pbsRecoveryStatus?.has_keyfile && (
<div className="space-y-2 rounded-md border border-emerald-500/30 bg-emerald-500/5 p-3">
<div className="text-[11px] font-medium">
No encryption key is stored on this host. Choose how to set one up:
</div>
<label className="flex items-start gap-2 cursor-pointer text-[11px]">
<input
type="radio"
name="manualPbsEncryptSetup"
checked={pbsEncryptMode === "new"}
onChange={() => setPbsEncryptMode("new")}
className="mt-1"
/>
<div className="flex-1">
<div className="font-medium">Generate a new keyfile (per host safest isolation)</div>
<div className="text-muted-foreground">Creates a fresh keyfile. Set a recovery passphrase (below) to survive host loss.</div>
</div>
</label>
<label className="flex items-start gap-2 cursor-pointer text-[11px]">
<input
type="radio"
name="manualPbsEncryptSetup"
checked={pbsEncryptMode === "existing"}
onChange={() => setPbsEncryptMode("existing")}
className="mt-1"
/>
<div className="flex-1">
<div className="font-medium">Import an existing keyfile (shared across hosts)</div>
<div className="text-muted-foreground">Reuse the same keyfile another host already has.</div>
</div>
</label>
{pbsEncryptMode === "existing" && (
<div className="space-y-1.5 pt-1">
<Label htmlFor="manualPbsImportFile" className="text-[11px] font-medium">
Keyfile to import
</Label>
<Input
id="manualPbsImportFile"
type="file"
accept=".conf,.key,application/json,text/plain"
onChange={(e) => setPbsImportFile(e.target.files?.[0] ?? null)}
disabled={pbsImportBusy}
className="h-8 text-[11px]"
/>
<p className="text-[10px] text-muted-foreground">
Validated with <code className="font-mono">proxmox-backup-client key info</code> before install.
</p>
</div>
)}
</div>
)}
{pbsEncrypt && ( {pbsEncrypt && (
<div className="pl-7 space-y-2 rounded-md border border-blue-500/30 bg-blue-500/5 p-3"> <div className="pl-7 space-y-2 rounded-md border border-blue-500/30 bg-blue-500/5 p-3">
<div className="text-[11px] font-medium text-foreground flex items-center gap-1.5"> <div className="text-[11px] font-medium text-foreground flex items-center gap-1.5">
@@ -4436,7 +4550,7 @@ function DestinationsSection({
PBS keyfile is missing recover it from PBS PBS keyfile is missing recover it from PBS
</div> </div>
<div className="text-[11px] text-muted-foreground mt-0.5"> <div className="text-[11px] text-muted-foreground mt-0.5">
An encrypted recovery copy of the keyfile is available ({pbsRecoveryAvailable!.snapshots.length} snapshot{pbsRecoveryAvailable!.snapshots.length === 1 ? "" : "s"}). Click to rebuild the keyfile using your recovery passphrase. An encrypted recovery copy of the keyfile is available ({pbsRecoveryAvailable!.snapshots.length} backup{pbsRecoveryAvailable!.snapshots.length === 1 ? "" : "s"}). Click to rebuild the keyfile using your recovery passphrase.
</div> </div>
</div> </div>
<span className="text-xs text-emerald-300 shrink-0 self-center">Recover </span> <span className="text-xs text-emerald-300 shrink-0 self-center">Recover </span>
@@ -7597,6 +7711,17 @@ function RestoreOptionsModal({
{step === "choose" && ( {step === "choose" && (
<div className="space-y-3"> <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>
After the reboot, this Backups tab will show a live post-restore progress card with ETA, per-component status, log tail and rollback delta. If Telegram/Discord/ntfy notifications are configured, you'll also receive the "Host restore finished" event.
</div>
</div>
{isBkOlder && ( {isBkOlder && (
<div className="rounded-md border border-amber-500/50 bg-amber-500/5 p-3"> <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"> <div className="text-[11px] font-semibold uppercase tracking-wider text-amber-400 flex items-center gap-1.5">
+1 -1
View File
@@ -271,7 +271,7 @@ export function Login({ onLogin }: LoginProps) {
</form> </form>
</div> </div>
<p className="text-center text-sm text-muted-foreground">ProxMenux Monitor v1.2.2.2-beta</p> <p className="text-center text-sm text-muted-foreground">ProxMenux Monitor v1.2.2.3-beta</p>
</div> </div>
</div> </div>
) )
+1 -1
View File
@@ -836,7 +836,7 @@ export function ProxmoxDashboard() {
</Tabs> </Tabs>
<footer className="mt-8 md:mt-12 pt-4 md:pt-6 border-t border-border text-center text-xs md:text-sm text-muted-foreground"> <footer className="mt-8 md:mt-12 pt-4 md:pt-6 border-t border-border text-center text-xs md:text-sm text-muted-foreground">
<p className="font-medium mb-2">ProxMenux Monitor v1.2.2.2-beta</p> <p className="font-medium mb-2">ProxMenux Monitor v1.2.2.3-beta</p>
<p> <p>
<a <a
href="https://ko-fi.com/macrimi" href="https://ko-fi.com/macrimi"
+10 -2
View File
@@ -6,7 +6,7 @@ import { Dialog, DialogContent, DialogTitle } from "./ui/dialog"
import { X, Sparkles, Thermometer, Activity, HardDrive, Shield, Globe, Cpu, Zap, Sliders, Wrench, RefreshCw, Server, BellOff, Bell, Calendar, DatabaseBackup } from "lucide-react" import { X, Sparkles, Thermometer, Activity, HardDrive, Shield, Globe, Cpu, Zap, Sliders, Wrench, RefreshCw, Server, BellOff, Bell, Calendar, DatabaseBackup } from "lucide-react"
import { Checkbox } from "./ui/checkbox" import { Checkbox } from "./ui/checkbox"
const APP_VERSION = "1.2.2.2-beta" // Sync with AppImage/package.json const APP_VERSION = "1.2.2.3-beta" // Sync with AppImage/package.json
interface ReleaseNote { interface ReleaseNote {
date: string date: string
@@ -220,9 +220,17 @@ const CURRENT_VERSION_FEATURES = [
icon: <DatabaseBackup className="h-5 w-5" />, icon: <DatabaseBackup className="h-5 w-5" />,
text: "New Backups section integrated in the Monitor — create, schedule and restore host backups against Local, PBS or Borg destinations; run on a proper systemd timer or attach to an existing PVE vzdump job with live-inherited retention; PBS encryption with paired recovery blobs; and a direction-aware restore flow that safely handles cross-kernel jumps by reapplying IOMMU / VFIO / GRUB tunings, protecting critical packages from cascade-remove, and auto-remapping NICs after a motherboard swap.", text: "New Backups section integrated in the Monitor — create, schedule and restore host backups against Local, PBS or Borg destinations; run on a proper systemd timer or attach to an existing PVE vzdump job with live-inherited retention; PBS encryption with paired recovery blobs; and a direction-aware restore flow that safely handles cross-kernel jumps by reapplying IOMMU / VFIO / GRUB tunings, protecting critical packages from cascade-remove, and auto-remapping NICs after a motherboard swap.",
}, },
{
icon: <RefreshCw className="h-5 w-5" />,
text: "Live post-restore progress — after a reboot the Backups tab shows a real-time progress card with step-by-step milestones, ETA, per-component status (NVIDIA, Intel GPU tools, Coral, AMD tools), boot sanity warnings, a rollback delta widget listing VMs / LXCs / components that exist on the host but weren't in the backup, and a log tail with an Issues-only filter. Past restores are archived and browsable.",
},
{
icon: <Shield className="h-5 w-5" />,
text: "PBS encryption flow reworked — first a yes/no dialog; only when a keyfile is not yet installed does a second menu ask whether to generate a new one or import an existing one (shared across hosts). Subsequent encrypted backups reuse the installed key silently. Cancelling never leaves a phantom keyfile behind.",
},
{ {
icon: <HardDrive className="h-5 w-5" />, icon: <HardDrive className="h-5 w-5" />,
text: "Storage & Hardware — per-disk cards in a responsive grid; USB-NVMe enclosures (ASMedia / JMicron / Realtek) now show the drive's real model, serial, temperature and health instead of the bridge's identity; USB-SATA SSDs (ASM105x) no longer flip to HDD; consistent blue / amber / red capacity palette across the app; multi-TB PBS datastores render in the right unit.", text: "Storage & Hardware — per-disk cards in a responsive grid; USB-NVMe enclosures (ASMedia / JMicron / Realtek) now show the drive's real model, serial, temperature and health instead of the bridge's identity; USB detection now walks the sysfs device path instead of trusting `/sys/block/*/removable`, so USB-NVMe and USB-HDD enclosures that report removable=0 finally reach the snt* pass-through and expose full SMART (temperature, power-on hours, power cycles, health); USB-SATA SSDs (ASM105x) no longer flip to HDD; consistent blue / amber / red capacity palette across the app; multi-TB PBS datastores render in the right unit.",
}, },
{ {
icon: <Activity className="h-5 w-5" />, icon: <Activity className="h-5 w-5" />,
@@ -0,0 +1,593 @@
"use client"
// Live inline card + detail modal for the post-boot restore.
//
// apply_cluster_postboot.sh writes /var/lib/proxmenux/restore-state.json
// as it works through the milestones (apply cluster config, initramfs,
// grub, per-component reinstalls, sanity check, finalize). The Flask
// endpoints /api/host-backups/restore/{status,dismiss,history,log}
// expose that state to this component. While the restore is running we
// poll every 2s; once it's terminal (complete|failed) we back off to
// 30s so the card can still be re-opened as a summary. Once the
// operator hits Dismiss the card collapses and the History button
// keeps the run browsable.
import { useMemo, 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,
DialogFooter,
} from "./ui/dialog"
import { ScrollArea } from "./ui/scroll-area"
import {
Loader2,
CheckCircle2,
XCircle,
AlertTriangle,
History,
RotateCcw,
ChevronRight,
Cpu,
FileText,
ArrowDownAZ,
Filter,
} from "lucide-react"
import { fetchApi } from "../lib/api-config"
// ── Shape contracts with the backend ──────────────────────────
interface RestoreComponent {
name: string
status: "installing" | "ok" | "failed"
log: string
exit_code?: string
}
interface RestoreSummary {
hostname: string
guests: string
stubs: string
stale_nodes: string
components: string
duration: string
}
interface RestoreRollback {
vms_to_remove?: string[]
lxcs_to_remove?: string[]
components_to_uninstall?: string[]
}
interface RestoreState {
status: "running" | "complete" | "failed"
started_at: string
finished_at: string | null
current_step: string
steps_done: number
steps_total: number
log_path: string
components: RestoreComponent[]
rollback_delta: RestoreRollback
sanity_warnings: string[]
summary: RestoreSummary | null
acknowledged: boolean
duration?: string
}
interface HistoryEntry {
file: string
mtime: number
status: string
started_at: string | null
finished_at: string | null
duration: string | null
}
const fetcher = (url: string) => fetchApi(url)
const COMPONENT_LABEL: Record<string, string> = {
nvidia_driver: "NVIDIA driver",
amdgpu_top: "amdgpu_top",
intel_gpu_tools: "Intel GPU tools",
coral_driver: "Google Coral TPU driver",
}
const formatComponent = (name: string) => COMPONENT_LABEL[name] ?? name
const formatIso = (iso: string | null | undefined) => {
if (!iso) return "—"
try {
return new Date(iso).toLocaleString()
} catch {
return iso
}
}
const formatRelative = (iso: string) => {
try {
const then = new Date(iso).getTime()
const now = Date.now()
const diff = Math.max(0, Math.round((now - then) / 1000))
if (diff < 60) return `${diff}s ago`
if (diff < 3600) return `${Math.round(diff / 60)}m ago`
if (diff < 86400) return `${Math.round(diff / 3600)}h ago`
return `${Math.round(diff / 86400)}d ago`
} catch {
return iso
}
}
// Rough ETA derived from steps_done + elapsed. Best-effort: skips at
// step 0 (no data yet) and returns "—" once the run is terminal.
const computeEta = (state: RestoreState): string => {
if (state.status !== "running") return "—"
if (!state.steps_done || state.steps_done <= 0) return "estimating…"
const elapsedSec = Math.max(1, Math.round((Date.now() - new Date(state.started_at).getTime()) / 1000))
const perStep = elapsedSec / state.steps_done
const remaining = Math.max(0, state.steps_total - state.steps_done)
const eta = Math.round(perStep * remaining)
if (eta < 60) return `~${eta}s`
if (eta < 3600) return `~${Math.round(eta / 60)}m`
return `~${Math.round(eta / 3600)}h`
}
// ── Small building blocks ─────────────────────────────────────
const StatusBadge: React.FC<{ status: string }> = ({ status }) => {
if (status === "running")
return (
<Badge className="bg-blue-500/10 border-blue-500/40 text-blue-300 gap-1">
<Loader2 className="h-3 w-3 animate-spin" />
Restore in progress
</Badge>
)
if (status === "complete")
return (
<Badge className="bg-emerald-500/10 border-emerald-500/40 text-emerald-400 gap-1">
<CheckCircle2 className="h-3 w-3" />
Restore complete
</Badge>
)
if (status === "failed")
return (
<Badge className="bg-red-500/10 border-red-500/40 text-red-400 gap-1">
<XCircle className="h-3 w-3" />
Restore failed
</Badge>
)
return <Badge variant="outline">{status}</Badge>
}
const ComponentStatusIcon: React.FC<{ status: string }> = ({ status }) => {
if (status === "installing")
return <Loader2 className="h-3.5 w-3.5 animate-spin text-blue-400" />
if (status === "ok")
return <CheckCircle2 className="h-3.5 w-3.5 text-emerald-400" />
return <XCircle className="h-3.5 w-3.5 text-red-400" />
}
// ── Log viewer ────────────────────────────────────────────────
const LogViewer: React.FC<{ path: string | null; historyOnly?: boolean }> = ({ path, historyOnly }) => {
const [filter, setFilter] = useState<"all" | "issues">("all")
const swrKey = path
? `/api/host-backups/restore/log?filter=${filter}&tail=600${historyOnly ? `&path=${encodeURIComponent(path)}` : ""}`
: null
const { data, isLoading } = useSWR<{ lines: string[]; total_lines: number; path: string | null }>(
swrKey,
fetcher,
{ refreshInterval: historyOnly ? 0 : 4000 },
)
return (
<div className="space-y-2">
<div className="flex items-center justify-between text-xs">
<div className="flex items-center gap-1 text-muted-foreground">
<FileText className="h-3.5 w-3.5" />
{path ?? "no log yet"}
</div>
<div className="flex items-center gap-1">
<Button
size="sm"
variant={filter === "all" ? "default" : "outline"}
className="h-6 px-2 text-xs"
onClick={() => setFilter("all")}
>
<ArrowDownAZ className="h-3 w-3 mr-1" />
Full
</Button>
<Button
size="sm"
variant={filter === "issues" ? "default" : "outline"}
className="h-6 px-2 text-xs"
onClick={() => setFilter("issues")}
>
<Filter className="h-3 w-3 mr-1" />
Issues only
</Button>
</div>
</div>
<ScrollArea className="h-72 rounded-md border border-border bg-black/40">
<pre className="p-3 text-xs text-muted-foreground whitespace-pre-wrap font-mono leading-relaxed">
{isLoading ? "Loading…" : (data?.lines?.join("\n") || "(no output)")}
</pre>
</ScrollArea>
</div>
)
}
// ── Rollback delta widget ─────────────────────────────────────
const RollbackDelta: React.FC<{ delta: RestoreRollback | undefined }> = ({ delta }) => {
const vms = delta?.vms_to_remove ?? []
const lxcs = delta?.lxcs_to_remove ?? []
const comps = delta?.components_to_uninstall ?? []
if (!vms.length && !lxcs.length && !comps.length) {
return (
<div className="text-xs text-muted-foreground">
No entries exist on this host that weren't in the restored backup.
</div>
)
}
const Row: React.FC<{ label: string; items: string[]; cmd: (id: string) => string }> = ({ label, items, cmd }) =>
items.length === 0 ? null : (
<div className="space-y-1">
<div className="text-xs font-medium text-muted-foreground">{label}</div>
<div className="flex flex-wrap gap-1.5">
{items.map((id) => (
<Badge key={id} variant="outline" className="font-mono text-xs">
{id}
</Badge>
))}
</div>
{items.length > 0 && (
<details className="text-xs">
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">
Show manual cleanup commands
</summary>
<pre className="mt-1 p-2 rounded-md bg-black/40 text-xs text-muted-foreground font-mono">
{items.map(cmd).join("\n")}
</pre>
</details>
)}
</div>
)
return (
<div className="space-y-3">
<div className="text-xs text-muted-foreground">
These entries exist on this host but were NOT in the restored backup. Review before removing.
</div>
<Row
label="VMs created after the backup"
items={vms}
cmd={(id) => `qm stop ${id} 2>/dev/null; qm destroy ${id} --purge`}
/>
<Row
label="LXCs created after the backup"
items={lxcs}
cmd={(id) => `pct stop ${id} 2>/dev/null; pct destroy ${id} --purge`}
/>
<Row
label="Components installed after the backup"
items={comps}
cmd={(name) => `# uninstall ${name} manually via ProxMenux → Hardware & GPU`}
/>
</div>
)
}
// ── Detail modal ──────────────────────────────────────────────
const RestoreDetailModal: React.FC<{
open: boolean
onClose: () => void
state: RestoreState
historyMode?: boolean
}> = ({ open, onClose, state, historyMode }) => {
const progressPct = state.steps_total > 0 ? Math.round((state.steps_done / state.steps_total) * 100) : 0
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<RotateCcw className="h-5 w-5 text-blue-500" />
Post-restore progress
<StatusBadge status={state.status} />
</DialogTitle>
<DialogDescription>
Started {formatIso(state.started_at)}
{state.finished_at ? ` · finished ${formatIso(state.finished_at)}` : ""}
{state.summary?.duration ? ` · ${state.summary.duration}` : ""}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1">
<div className="flex justify-between text-xs text-muted-foreground">
<span>{state.current_step || "—"}</span>
<span>
{state.steps_done}/{state.steps_total} steps
{state.status === "running" && ` · ETA ${computeEta(state)}`}
</span>
</div>
<div className="h-2 rounded-full bg-muted overflow-hidden">
<div
className={`h-full transition-all duration-500 ${
state.status === "failed" ? "bg-red-500" : state.status === "complete" ? "bg-emerald-500" : "bg-blue-500"
}`}
style={{ width: `${progressPct}%` }}
/>
</div>
</div>
{state.components.length > 0 && (
<div className="space-y-2">
<div className="text-sm font-medium flex items-center gap-2">
<Cpu className="h-4 w-4" />
Components
</div>
<div className="space-y-1.5">
{state.components.map((c) => (
<div
key={c.name}
className="flex items-center justify-between rounded-md border border-border bg-muted/30 px-3 py-2 text-xs"
>
<div className="flex items-center gap-2">
<ComponentStatusIcon status={c.status} />
<span className="font-medium">{formatComponent(c.name)}</span>
<span className="text-muted-foreground">{c.status}</span>
{c.exit_code && <span className="text-red-400">exit {c.exit_code}</span>}
</div>
{c.log && <span className="text-muted-foreground font-mono">{c.log}</span>}
</div>
))}
</div>
</div>
)}
{state.sanity_warnings.length > 0 && (
<div className="space-y-2">
<div className="text-sm font-medium flex items-center gap-2 text-amber-400">
<AlertTriangle className="h-4 w-4" />
Boot sanity warnings
</div>
<ul className="list-disc list-inside text-xs text-muted-foreground space-y-1">
{state.sanity_warnings.map((w) => (
<li key={w}>{w}</li>
))}
</ul>
</div>
)}
<div className="space-y-2">
<div className="text-sm font-medium">Rollback delta</div>
<RollbackDelta delta={state.rollback_delta} />
</div>
<div className="space-y-2">
<div className="text-sm font-medium">Log</div>
<LogViewer path={state.log_path} historyOnly={historyMode} />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
// ── History browser modal ─────────────────────────────────────
const RestoreHistoryModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, onClose }) => {
const { data } = useSWR<{ entries: HistoryEntry[] }>(open ? "/api/host-backups/restore/history" : null, fetcher)
const [detailFile, setDetailFile] = useState<string | null>(null)
const { data: detailResp } = useSWR<{ state: RestoreState }>(
detailFile ? `/api/host-backups/restore/history?file=${encodeURIComponent(detailFile)}` : null,
fetcher,
)
return (
<>
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<History className="h-5 w-5" />
Past restores
</DialogTitle>
<DialogDescription>
Restores archived by the post-boot dispatcher. The latest 20 are kept.
</DialogDescription>
</DialogHeader>
<ScrollArea className="h-96">
<div className="space-y-1.5">
{(data?.entries ?? []).length === 0 ? (
<div className="text-sm text-muted-foreground py-6 text-center">No past restores recorded.</div>
) : (
(data?.entries ?? []).map((e) => (
<button
key={e.file}
onClick={() => setDetailFile(e.file)}
className="w-full flex items-center justify-between rounded-md border border-border bg-muted/30 hover:bg-muted px-3 py-2 text-xs text-left"
>
<div className="flex items-center gap-2">
<StatusBadge status={e.status} />
<span className="text-muted-foreground">
{e.started_at ? formatIso(e.started_at) : formatIso(new Date(e.mtime * 1000).toISOString())}
</span>
{e.duration && <span className="text-muted-foreground">· {e.duration}</span>}
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground" />
</button>
))
)}
</div>
</ScrollArea>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{detailFile && detailResp?.state && (
<RestoreDetailModal
open={!!detailFile}
onClose={() => setDetailFile(null)}
state={detailResp.state}
historyMode
/>
)}
</>
)
}
// ── Main inline card ──────────────────────────────────────────
export const RestoreProgressCard: React.FC = () => {
const { data, mutate } = useSWR<{ state: RestoreState | null }>(
"/api/host-backups/restore/status",
fetcher,
{
refreshInterval: (latest) => (latest?.state?.status === "running" ? 2000 : 30000),
revalidateOnFocus: true,
},
)
const [detailOpen, setDetailOpen] = useState(false)
const [historyOpen, setHistoryOpen] = useState(false)
const [dismissing, setDismissing] = useState(false)
const state = data?.state ?? null
const progressPct = useMemo(() => {
if (!state || state.steps_total <= 0) return 0
return Math.round((state.steps_done / state.steps_total) * 100)
}, [state])
const dismiss = async () => {
if (!state) return
setDismissing(true)
try {
await fetchApi("/api/host-backups/restore/dismiss", { method: "POST" })
await mutate()
} finally {
setDismissing(false)
}
}
// Hidden entirely when: no restore run has ever happened, OR the
// last run is terminal AND acknowledged. History button is still
// reachable from the main card header (rendered elsewhere).
if (!state) return null
if (state.status !== "running" && state.acknowledged) {
return (
<div className="flex justify-end">
<Button variant="ghost" size="sm" onClick={() => setHistoryOpen(true)}>
<History className="h-3.5 w-3.5 mr-1" />
Past restores
</Button>
<RestoreHistoryModal open={historyOpen} onClose={() => setHistoryOpen(false)} />
</div>
)
}
const hasWarnings = state.sanity_warnings.length > 0
const barColor =
state.status === "failed" ? "bg-red-500" : state.status === "complete" ? "bg-emerald-500" : "bg-blue-500"
return (
<>
<Card className="bg-card border-border">
<CardHeader className="pb-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<CardTitle className="text-base font-semibold flex items-center gap-2">
<RotateCcw
className={`h-5 w-5 ${state.status === "running" ? "text-blue-500 animate-spin" : "text-blue-500"}`}
/>
Post-restore progress
<StatusBadge status={state.status} />
{hasWarnings && (
<Badge variant="outline" className="text-amber-400 border-amber-500/40 bg-amber-500/10 gap-1">
<AlertTriangle className="h-3 w-3" />
{state.sanity_warnings.length} boot warning{state.sanity_warnings.length === 1 ? "" : "s"}
</Badge>
)}
</CardTitle>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={() => setDetailOpen(true)}>
Details
</Button>
<Button size="sm" variant="ghost" onClick={() => setHistoryOpen(true)}>
<History className="h-3.5 w-3.5 mr-1" />
History
</Button>
{state.status !== "running" && (
<Button size="sm" onClick={dismiss} disabled={dismissing}>
{dismissing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : "Dismiss"}
</Button>
)}
</div>
</div>
</CardHeader>
<CardContent className="space-y-3">
<div className="space-y-1">
<div className="flex justify-between text-xs text-muted-foreground">
<span className="truncate">
{state.current_step || "—"} · started {formatRelative(state.started_at)}
</span>
<span>
{state.steps_done}/{state.steps_total} steps
{state.status === "running" && ` · ETA ${computeEta(state)}`}
{state.summary?.duration && state.status !== "running" && ` · ${state.summary.duration}`}
</span>
</div>
<div className="h-2 rounded-full bg-muted overflow-hidden">
<div className={`h-full transition-all duration-500 ${barColor}`} style={{ width: `${progressPct}%` }} />
</div>
</div>
{state.summary && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-xs">
<div className="rounded-md border border-border bg-muted/30 px-2 py-1.5">
<div className="text-muted-foreground">Guests</div>
<div className="font-medium">{state.summary.guests}</div>
</div>
<div className="rounded-md border border-border bg-muted/30 px-2 py-1.5">
<div className="text-muted-foreground">Bind-mount stubs</div>
<div className="font-medium">{state.summary.stubs}</div>
</div>
<div className="rounded-md border border-border bg-muted/30 px-2 py-1.5">
<div className="text-muted-foreground">Stale nodes cleaned</div>
<div className="font-medium">{state.summary.stale_nodes}</div>
</div>
<div className="rounded-md border border-border bg-muted/30 px-2 py-1.5">
<div className="text-muted-foreground">Components</div>
<div className="font-medium">{state.summary.components}</div>
</div>
</div>
)}
</CardContent>
</Card>
<RestoreDetailModal open={detailOpen} onClose={() => setDetailOpen(false)} state={state} />
<RestoreHistoryModal open={historyOpen} onClose={() => setHistoryOpen(false)} />
</>
)
}
export default RestoreProgressCard
@@ -299,6 +299,11 @@ const initMessage = {
setTimeout(() => { setTimeout(() => {
if (fitAddonRef.current && termRef.current) { if (fitAddonRef.current && termRef.current) {
fitAddonRef.current.fit() fitAddonRef.current.fit()
// Send the keyboard to the xterm instance so any --yesno /
// --menu the script opens receives Enter / arrow keys
// directly. Without this the modal's Close button (or any
// other focusable descendant) intercepts the keystrokes.
termRef.current.focus()
} }
}, 100) }, 100)
@@ -682,6 +687,13 @@ const initMessage = {
}} }}
onInteractOutside={(e) => e.preventDefault()} onInteractOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()} onEscapeKeyDown={(e) => e.preventDefault()}
// Radix defaults to focusing the first focusable descendant
// when a Dialog opens — that used to land on the Close button
// and every Enter/Space keystroke intended for the shell
// dialogs INSIDE the terminal would close the modal instead.
// Preempting the auto-focus lets the xterm focus() call in
// initializeTerminal below own the keyboard from the start.
onOpenAutoFocus={(e) => e.preventDefault()}
hideClose hideClose
> >
<DialogTitle className="sr-only">{title}</DialogTitle> <DialogTitle className="sr-only">{title}</DialogTitle>
+1 -1
View File
@@ -3717,7 +3717,7 @@ ${observationsHtml}
<!-- Footer --> <!-- Footer -->
<div class="rpt-footer"> <div class="rpt-footer">
<div>Report generated by ProxMenux Monitor</div> <div>Report generated by ProxMenux Monitor</div>
<div>ProxMenux Monitor v1.2.2.2-beta</div> <div>ProxMenux Monitor v1.2.2.3-beta</div>
</div> </div>
</body> </body>
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "ProxMenux-Monitor", "name": "ProxMenux-Monitor",
"version": "1.2.2.2-beta", "version": "1.2.2.3-beta",
"description": "Proxmox System Monitoring Dashboard", "description": "Proxmox System Monitoring Dashboard",
"private": true, "private": true,
"scripts": { "scripts": {
+25 -1
View File
@@ -237,6 +237,22 @@ def _list_target_disks() -> list[str]:
return fresh return fresh
def _is_disk_usb(disk_name: str) -> bool:
"""True if the disk sits behind a USB bus, checked via the resolved
sysfs device path. USB-NVMe bridges (ASMedia, JMicron, Realtek) and
plain USB-HDDs both report `/sys/block/<disk>/removable = 0`, so the
older removable-flag heuristic missed them and the temperature
poller never tried the snt* driver variants that are the only way
to reach the NVMe controller behind those bridges."""
try:
base = disk_name[5:] if disk_name.startswith('/dev/') else disk_name
real = os.path.realpath(f'/sys/block/{base}')
return any(seg.startswith('usb') and (len(seg) == 3 or seg[3:].isdigit())
for seg in real.split('/'))
except Exception:
return False
def _smartctl_cmd_for(disk_name: str, probe: str) -> list[str]: def _smartctl_cmd_for(disk_name: str, probe: str) -> list[str]:
"""Build the smartctl invocation for a given probe key. """Build the smartctl invocation for a given probe key.
@@ -354,7 +370,15 @@ def _read_temperature(disk_name: str) -> Optional[float]:
# Cached probe stopped working — fall through and re-detect. # Cached probe stopped working — fall through and re-detect.
# Slow path: try every probe and remember the first one that works. # Slow path: try every probe and remember the first one that works.
for probe in ("auto", "nvme", "ata", "sat"): # For USB-attached disks we prepend the three snt* driver variants —
# USB-NVMe bridges (ASMedia / JMicron / Realtek) don't answer the
# plain probes with real SMART; only snt* passes through to the NVMe
# controller so temperature actually comes back. Non-USB disks skip
# them, so this adds zero overhead on internal drives.
probes: tuple[str, ...] = ("auto", "nvme", "ata", "sat")
if _is_disk_usb(disk_name):
probes = ("sntasmedia", "sntjmicron", "sntrealtek") + probes
for probe in probes:
if probe == cached_probe: if probe == cached_probe:
continue # already tried above continue # already tried above
temp = _handle(_try_probe(disk_name, probe)) temp = _handle(_try_probe(disk_name, probe))
+198 -32
View File
@@ -2658,6 +2658,29 @@ def is_disk_removable(disk_name):
return False return False
def is_disk_usb(disk_name):
"""Return True if the disk is attached via USB, using the sysfs device
path. Reliable for USB-attached HDDs and USB-NVMe bridges (which
both report `/sys/block/<disk>/removable = 0` even though they ARE
USB) the previous heuristic based on the removable flag missed
them entirely, so snt* pass-through was never attempted and the
bridge's own identity + missing temperature/hours were cached.
Uses `os.path.realpath` (no subprocess) so it's cheap enough to be
called on every SMART probe.
"""
try:
real = os.path.realpath(f'/sys/block/{disk_name}')
# sysfs path segment like `usb1`, `usb2`, ... always precedes a
# USB-attached block device. Match on the segment prefix rather
# than a substring so a directory happening to contain "usb" in
# its literal name can't false-positive.
return any(seg.startswith('usb') and (len(seg) == 3 or seg[3:].isdigit())
for seg in real.split('/'))
except Exception:
return False
def _is_system_mount(mountpoint): def _is_system_mount(mountpoint):
"""Check if mountpoint is a critical system path (matching bash scripts logic).""" """Check if mountpoint is a critical system path (matching bash scripts logic)."""
system_mounts = ('/', '/boot', '/boot/efi', '/efi', '/usr', '/var', '/etc', system_mounts = ('/', '/boot', '/boot/efi', '/efi', '/usr', '/var', '/etc',
@@ -3676,12 +3699,15 @@ def _get_smart_data_uncached(disk_name):
# passes through to the actual NVMe controller and returns real # passes through to the actual NVMe controller and returns real
# model, serial, temperature and health. # model, serial, temperature and health.
# #
# For removable disks we prepend the three snt* variants so the # For USB-attached disks we prepend the three snt* variants so
# cascade tries them FIRST — otherwise the plain variant "succeeds" # the cascade tries them FIRST — otherwise the plain variant
# (>50 chars of bridge chatter), the probe cache locks it in, and # "succeeds" (>50 chars of bridge chatter), the probe cache locks
# temperature is never seen. For non-removable disks the cascade # it in, and temperature is never seen. USB detection is by sysfs
# is unchanged (zero regression risk on internal SATA/NVMe). # path (`is_disk_usb`) rather than the `removable` flag: USB-NVMe
if is_disk_removable(disk_name): # bridges and USB-HDDs both report `removable=0` even though they
# ARE USB, so the older `is_disk_removable` check missed them.
# For internal SATA/NVMe the cascade is unchanged (zero regression).
if is_disk_usb(disk_name) or is_disk_removable(disk_name):
all_commands = [ all_commands = [
['smartctl', '-a', '-j', '-d', 'sntasmedia', f'/dev/{disk_name}'], ['smartctl', '-a', '-j', '-d', 'sntasmedia', f'/dev/{disk_name}'],
['smartctl', '-a', '-j', '-d', 'sntjmicron', f'/dev/{disk_name}'], ['smartctl', '-a', '-j', '-d', 'sntjmicron', f'/dev/{disk_name}'],
@@ -10027,45 +10053,40 @@ def api_network_summary():
bridge_interfaces = [] bridge_interfaces = []
for interface_name, stats in net_if_stats.items(): for interface_name, stats in net_if_stats.items():
# Skip loopback and special interfaces # Delegate classification to the shared helper so this endpoint
if interface_name in ['lo', 'docker0'] or interface_name.startswith(('veth', 'tap', 'fw')): # matches the full /api/network path. The inline prefix-based
# check that lived here missed operator-renamed NICs (e.g.
# `nic0` via systemd .link rules) and reported 0 physical
# interfaces even when the host clearly had one — surfaces as
# "Network Interfaces: N/A" on the System Overview card.
iface_type = get_interface_type(interface_name)
if iface_type not in ('physical', 'bridge'):
continue continue
is_up = stats.isup is_up = stats.isup
addresses = []
# Classify interface type if interface_name in net_if_addrs:
if interface_name.startswith(('enp', 'eth', 'eno', 'ens', 'wlan', 'wlp')): for addr in net_if_addrs[interface_name]:
if addr.family == socket.AF_INET:
addresses.append({'ip': addr.address, 'netmask': addr.netmask})
if iface_type == 'physical':
physical_total += 1 physical_total += 1
if is_up: if is_up:
physical_active += 1 physical_active += 1
# Get IP addresses
addresses = []
if interface_name in net_if_addrs:
for addr in net_if_addrs[interface_name]:
if addr.family == socket.AF_INET:
addresses.append({'ip': addr.address, 'netmask': addr.netmask})
physical_interfaces.append({ physical_interfaces.append({
'name': interface_name, 'name': interface_name,
'status': 'up' if is_up else 'down', 'status': 'up',
'addresses': addresses 'addresses': addresses,
}) })
else: # bridge
elif interface_name.startswith(('vmbr', 'br')):
bridge_total += 1 bridge_total += 1
if is_up: if is_up:
bridge_active += 1 bridge_active += 1
# Get IP addresses
addresses = []
if interface_name in net_if_addrs:
for addr in net_if_addrs[interface_name]:
if addr.family == socket.AF_INET:
addresses.append({'ip': addr.address, 'netmask': addr.netmask})
bridge_interfaces.append({ bridge_interfaces.append({
'name': interface_name, 'name': interface_name,
'status': 'up' if is_up else 'down', 'status': 'up',
'addresses': addresses 'addresses': addresses,
}) })
return jsonify({ return jsonify({
@@ -13803,6 +13824,151 @@ def api_pbs_recovery_status():
}) })
# ─── Restore progress state (written by apply_cluster_postboot.sh) ──
# The postboot script maintains /var/lib/proxmenux/restore-state.json
# with a `running|complete|failed` status plus milestone counters,
# per-component results, sanity warnings and a rollback delta. The
# Backups tab polls status() to render a live card while the restore
# is running and a summary once it's done; dismiss() flips the
# `acknowledged` flag so the card collapses; history() lists archived
# runs; log() returns a filtered tail of the postboot log.
_RESTORE_STATE_FILE = '/var/lib/proxmenux/restore-state.json'
_RESTORE_HISTORY_DIR = '/var/lib/proxmenux/restore-history'
_RESTORE_LOG_DIR = '/var/log/proxmenux'
def _read_restore_state(path):
try:
with open(path, 'r') as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError, OSError):
return None
@app.route('/api/host-backups/restore/status', methods=['GET'])
@require_auth
def api_host_backups_restore_status():
"""Current restore-state.json content — empty response means no
restore has ever run on this host (or the state dir is missing)."""
state = _read_restore_state(_RESTORE_STATE_FILE)
if state is None:
return jsonify({'state': None})
return jsonify({'state': state})
@app.route('/api/host-backups/restore/dismiss', methods=['POST'])
@require_auth
def api_host_backups_restore_dismiss():
"""Flip acknowledged=true on the current state file so the Backups
tab card collapses. The state file itself is preserved until the
next restore overwrites it the operator can still open the
history modal to review this run's details later."""
state = _read_restore_state(_RESTORE_STATE_FILE)
if state is None:
return jsonify({'error': 'no restore state to dismiss'}), 404
state['acknowledged'] = True
try:
tmp = _RESTORE_STATE_FILE + '.tmp'
with open(tmp, 'w') as f:
json.dump(state, f)
os.replace(tmp, _RESTORE_STATE_FILE)
except OSError as e:
return jsonify({'error': f'cannot persist state: {e}'}), 500
return jsonify({'status': 'ok'})
@app.route('/api/host-backups/restore/history', methods=['GET'])
@require_auth
def api_host_backups_restore_history():
"""List archived restore runs (newest first). Each entry carries
just the summary metadata; the detail modal fetches the full
payload on demand via ?file=<name>."""
file_arg = request.args.get('file')
if file_arg:
# Reject anything that could climb out of the history dir.
# Filenames are always <YYYYmmdd_HHMMSS>-<complete|failed>.json.
if '/' in file_arg or '..' in file_arg or not file_arg.endswith('.json'):
return jsonify({'error': 'invalid file name'}), 400
full = os.path.join(_RESTORE_HISTORY_DIR, file_arg)
payload = _read_restore_state(full)
if payload is None:
return jsonify({'error': 'not found'}), 404
return jsonify({'state': payload})
entries = []
try:
for name in os.listdir(_RESTORE_HISTORY_DIR):
if not name.endswith('.json'):
continue
full = os.path.join(_RESTORE_HISTORY_DIR, name)
try:
mtime = os.stat(full).st_mtime
except OSError:
continue
payload = _read_restore_state(full) or {}
entries.append({
'file': name,
'mtime': mtime,
'status': payload.get('status', 'unknown'),
'started_at': payload.get('started_at'),
'finished_at': payload.get('finished_at'),
'duration': (payload.get('summary') or {}).get('duration'),
})
except FileNotFoundError:
pass
entries.sort(key=lambda e: e['mtime'], reverse=True)
return jsonify({'entries': entries})
@app.route('/api/host-backups/restore/log', methods=['GET'])
@require_auth
def api_host_backups_restore_log():
"""Return a tail of the postboot log — optionally filtered to just
error/warning lines. Path is taken from the state file's log_path
(or from ?path= for a history entry) and validated to live under
/var/log/proxmenux so an operator can't read arbitrary files."""
path = request.args.get('path')
filter_mode = request.args.get('filter', 'all') # all | issues
try:
tail_lines = min(int(request.args.get('tail', '400')), 4000)
except ValueError:
tail_lines = 400
if not path:
state = _read_restore_state(_RESTORE_STATE_FILE) or {}
path = state.get('log_path')
if not path:
return jsonify({'lines': [], 'path': None})
# Resolve to guard against traversal; the log MUST live inside
# /var/log/proxmenux for the endpoint to expose it.
real = os.path.realpath(path)
if not real.startswith(_RESTORE_LOG_DIR + os.sep) and real != _RESTORE_LOG_DIR:
return jsonify({'error': 'log path outside allowed directory'}), 400
if not os.path.isfile(real):
return jsonify({'lines': [], 'path': path}), 200
try:
with open(real, 'r', errors='replace') as f:
# Simple tail: read the whole file (postboot logs are
# small — a few hundred KB at worst) and slice.
data = f.readlines()
except OSError as e:
return jsonify({'error': f'cannot read log: {e}'}), 500
if filter_mode == 'issues':
needle = re.compile(r'(error|warning|failed|✗|✘|traceback)', re.IGNORECASE)
data = [ln for ln in data if needle.search(ln)]
return jsonify({
'path': path,
'total_lines': len(data),
'lines': [ln.rstrip('\n') for ln in data[-tail_lines:]],
'filter': filter_mode,
})
@app.route('/api/host-backups/pbs-encryption/import', methods=['POST']) @app.route('/api/host-backups/pbs-encryption/import', methods=['POST'])
@require_auth @require_auth
def api_pbs_encryption_import(): def api_pbs_encryption_import():
+31 -10
View File
@@ -83,6 +83,22 @@ def _is_disk_removable(disk_name: str) -> bool:
except Exception: except Exception:
return False return False
def _is_disk_usb(disk_name: str) -> bool:
"""True if the disk sits behind a USB bus. Reads the resolved sysfs
device path reliable for USB-NVMe bridges and USB-attached HDDs
that report `removable=0` even though they ARE USB (so the older
`_is_disk_removable` heuristic skipped snt* driver probes and left
NVMe-behind-a-bridge disks with the bridge's own chatter cached
forever)."""
try:
base = _disk_base_for_sysfs(disk_name)
real = os.path.realpath(f'/sys/block/{base}')
return any(seg.startswith('usb') and (len(seg) == 3 or seg[3:].isdigit())
for seg in real.split('/'))
except Exception:
return False
class HealthMonitor: class HealthMonitor:
""" """
Monitors system health across multiple components with minimal impact. Monitors system health across multiple components with minimal impact.
@@ -2431,13 +2447,16 @@ class HealthMonitor:
try: try:
dev_path = f'/dev/{disk_name}' if not disk_name.startswith('/') else disk_name dev_path = f'/dev/{disk_name}' if not disk_name.startswith('/') else disk_name
# Removable disks may be USB-NVMe bridges: try the snt* driver # USB-attached disks may sit behind an NVMe bridge: try the
# variants first so identity reflects the drive (Samsung 990 PRO) # snt* driver variants first so identity reflects the drive
# rather than the enclosure (ASMT 2462 NVME). If all snt* fail, # (Samsung 990 PRO) rather than the enclosure (ASMT 2462 NVME).
# fall through to the plain call — that's still correct for # If all snt* fail, fall through to the plain call — that's
# USB-SATA sticks and non-USB devices. # still correct for USB-SATA sticks and non-USB devices.
# USB detection is by sysfs path (`_is_disk_usb`) rather than
# the `removable` flag, since USB-NVMe and USB-HDD both report
# `removable=0` even though they ARE USB.
attempts = [] attempts = []
if _is_disk_removable(disk_name): if _is_disk_usb(disk_name) or _is_disk_removable(disk_name):
for drv in _USB_NVME_DRIVERS: for drv in _USB_NVME_DRIVERS:
attempts.append(['smartctl', '-i', '-j', '-d', drv, dev_path]) attempts.append(['smartctl', '-i', '-j', '-d', drv, dev_path])
attempts.append(['smartctl', '-i', '-j', dev_path]) attempts.append(['smartctl', '-i', '-j', dev_path])
@@ -2495,12 +2514,14 @@ class HealthMonitor:
# sleep — issue #232. The "UNKNOWN" branch below correctly # sleep — issue #232. The "UNKNOWN" branch below correctly
# keeps the previous cached result alive on exit code 2. # keeps the previous cached result alive on exit code 2.
# #
# Removable disks may be USB-NVMe bridges: try snt* drivers # USB-attached disks may sit behind an NVMe bridge: try snt*
# first so health reflects the actual NVMe controller. A # drivers first so health reflects the actual NVMe controller.
# bridge that fakes "PASSED" while the drive behind it is # A bridge that fakes "PASSED" while the drive behind it is
# failing is exactly the false-negative we want to avoid. # failing is exactly the false-negative we want to avoid.
# USB detection uses the sysfs path so USB-NVMe bridges (which
# report removable=0) are caught too.
attempts = [] attempts = []
if _is_disk_removable(disk_name): if _is_disk_usb(disk_name) or _is_disk_removable(disk_name):
for drv in _USB_NVME_DRIVERS: for drv in _USB_NVME_DRIVERS:
attempts.append(['smartctl', '-n', 'standby', '--health', '-j', '-d', drv, dev_path]) attempts.append(['smartctl', '-n', 'standby', '--health', '-j', '-d', drv, dev_path])
attempts.append(['smartctl', '-n', 'standby', '--health', '-j', dev_path]) attempts.append(['smartctl', '-n', 'standby', '--health', '-j', dev_path])
@@ -19,11 +19,114 @@ set +u
MARKER="${PMX_CLUSTER_APPLY_MARKER:-/var/lib/proxmenux/cluster-apply-pending}" MARKER="${PMX_CLUSTER_APPLY_MARKER:-/var/lib/proxmenux/cluster-apply-pending}"
LOG_DIR="${PMX_LOG_DIR:-/var/log/proxmenux}" LOG_DIR="${PMX_LOG_DIR:-/var/log/proxmenux}"
# State file the Monitor Web polls to show a live progress card on
# the Backups tab. A dismiss action (POST /api/host-backups/restore/dismiss)
# just flips `acknowledged` to true — the file itself lives until the
# next restore overwrites it, and a copy is archived under history/
# when the run finishes so the operator can browse past restores.
STATE_DIR="/var/lib/proxmenux"
STATE_FILE="$STATE_DIR/restore-state.json"
HISTORY_DIR="$STATE_DIR/restore-history"
mkdir -p "$STATE_DIR" "$HISTORY_DIR" >/dev/null 2>&1 || true
mkdir -p "$LOG_DIR" >/dev/null 2>&1 || true mkdir -p "$LOG_DIR" >/dev/null 2>&1 || true
LOG_FILE="${LOG_DIR}/proxmenux-cluster-postboot-$(date +%Y%m%d_%H%M%S).log" LOG_FILE="${LOG_DIR}/proxmenux-cluster-postboot-$(date +%Y%m%d_%H%M%S).log"
exec >>"$LOG_FILE" 2>&1 exec >>"$LOG_FILE" 2>&1
# ── State-file helpers ─────────────────────────────────────────
# Every milestone advances `steps_done` and optionally updates a
# handful of other fields. Writes go through a temp file + rename
# so the Monitor never reads a half-written JSON. All calls are
# `|| true` at the callsite — if jq or write fails, the restore
# still proceeds; only the UI progress reporting suffers.
_state_started_at="$(date -Iseconds)"
_state_steps_total=0
_state_steps_done=0
_state_write() {
# Merges a JSON snippet ($1) into the existing state file.
# Missing state file → seeded from an empty JSON object first.
command -v jq >/dev/null 2>&1 || return 0
[[ -f "$STATE_FILE" ]] || echo '{}' > "$STATE_FILE"
local tmp
tmp=$(mktemp "${STATE_FILE}.XXXXXX") || return 0
if jq -c ". * $1" "$STATE_FILE" > "$tmp" 2>/dev/null; then
mv -f "$tmp" "$STATE_FILE"
else
rm -f "$tmp"
fi
}
_state_step() {
# Called as a *step transition*: the previous step just finished,
# start the next one. Increments steps_done and sets the label to
# $1. Init seeds current_step with the first step's label; every
# _state_step call after that advances to the NEXT step.
#
# Example flow with 3 total steps:
# init → steps_done=0, current_step="Applying cluster config"
# step "Foo" → steps_done=1, current_step="Foo"
# step "Bar" → steps_done=2, current_step="Bar"
# _state_finish→ steps_done=3, status="complete"
local label="$1"
_state_steps_done=$((_state_steps_done + 1))
# jq variable names sdone/stotal — plain `done` collides with the
# bash reserved word when the arg is on its own continuation line.
_state_write "$(jq -n \
--arg step "$label" \
--argjson sdone "$_state_steps_done" \
--argjson stotal "$_state_steps_total" \
'{current_step:$step, steps_done:$sdone, steps_total:$stotal}')"
}
_state_component() {
# Add or update an entry in state.components. Arrays are replaced
# by jq's `*` merge, so plain _state_write can't be used for the
# per-component list — this helper explicitly appends new entries
# and updates existing ones by name.
# $1 = component name (nvidia_driver, coral_driver, …)
# $2 = status: installing | ok | failed
# $3 = per-component log path (empty allowed)
# $4 = installer exit code (only meaningful for failed; empty otherwise)
command -v jq >/dev/null 2>&1 || return 0
[[ -f "$STATE_FILE" ]] || echo '{}' > "$STATE_FILE"
local tmp
tmp=$(mktemp "${STATE_FILE}.XXXXXX") || return 0
if jq -c \
--arg name "$1" \
--arg status "$2" \
--arg log "$3" \
--arg rc "${4:-}" \
'($.components // []) as $comps
| ($comps | map(.name) | index($name)) as $idx
| ({name:$name, status:$status, log:$log}
+ (if $rc == "" then {} else {exit_code:$rc} end)) as $entry
| .components =
(if $idx == null then $comps + [$entry]
else ($comps | map(if .name == $name then $entry else . end)) end)' \
"$STATE_FILE" > "$tmp" 2>/dev/null; then
mv -f "$tmp" "$STATE_FILE"
else
rm -f "$tmp"
fi
}
_state_finish() {
# $1 = "complete" | "failed"
local final_status="$1"
_state_write "$(jq -n \
--arg s "$final_status" \
--arg t "$(date -Iseconds)" \
--arg dur "${POSTBOOT_DURATION_FMT:-}" \
'{status:$s, finished_at:$t, duration:$dur}')"
# Archive a copy to history/ so past restores stay browsable
# from the Monitor even after the operator dismisses the card.
if [[ -f "$STATE_FILE" ]]; then
cp -f "$STATE_FILE" "$HISTORY_DIR/$(date +%Y%m%d_%H%M%S)-${final_status}.json" 2>/dev/null || true
# Keep the last 20 entries in the history dir. Everything
# older is expected to have been reviewed already. Using
# find+sort-by-mtime keeps this safe against odd filenames.
find "$HISTORY_DIR" -maxdepth 1 -type f -name '*.json' -printf '%T@ %p\n' 2>/dev/null \
| sort -rn | tail -n +21 | cut -d' ' -f2- | xargs -r rm -f 2>/dev/null || true
fi
}
echo "=== ProxMenux cluster post-boot apply at $(date -Iseconds) ===" echo "=== ProxMenux cluster post-boot apply at $(date -Iseconds) ==="
if [[ ! -f "$MARKER" ]]; then if [[ ! -f "$MARKER" ]]; then
@@ -44,9 +147,51 @@ echo "Pending dir: $PENDING_DIR"
echo "Needs initramfs: $NEEDS_INITRAMFS" echo "Needs initramfs: $NEEDS_INITRAMFS"
echo "Needs grub: $NEEDS_GRUB" echo "Needs grub: $NEEDS_GRUB"
# Compute how many milestones the Monitor will see for this run.
# Base 3 = apply /etc/pve + boot sanity check + finalize. Add one
# for each optional phase that will actually run.
_state_steps_total=3
[[ "$NEEDS_INITRAMFS" == "1" ]] && _state_steps_total=$((_state_steps_total + 1))
[[ "$NEEDS_GRUB" == "1" ]] && _state_steps_total=$((_state_steps_total + 1))
# Count components that will be re-installed via --auto-reinstall.
# Read the same components_status.json the reinstall loop uses so
# our step count matches exactly what the loop will process.
_COMP_STATUS_PATH="/usr/local/share/proxmenux/components_status.json"
_COMPONENT_KEYS=(nvidia_driver amdgpu_top intel_gpu_tools coral_driver)
_comps_to_reinstall=0
if command -v jq >/dev/null 2>&1 && [[ -f "$_COMP_STATUS_PATH" ]]; then
for _k in "${_COMPONENT_KEYS[@]}"; do
[[ "$(jq -r ".$_k.status // \"\"" "$_COMP_STATUS_PATH" 2>/dev/null)" == "installed" ]] \
&& _comps_to_reinstall=$((_comps_to_reinstall + 1))
done
fi
(( _comps_to_reinstall > 0 )) && _state_steps_total=$((_state_steps_total + _comps_to_reinstall))
# Seed the initial state file so the Monitor's poll sees the run
# almost immediately after the postboot unit starts. `acknowledged`
# false means the Backups tab card will show; the operator flips
# it to true (via POST /dismiss) after they've read the summary.
_state_write "$(jq -n \
--arg started "$_state_started_at" \
--arg log "$LOG_FILE" \
--argjson steps "$_state_steps_total" \
'{status:"running",
started_at:$started,
finished_at:null,
current_step:"Applying cluster config",
steps_done:0,
steps_total:$steps,
log_path:$log,
components:[],
rollback_delta:{},
sanity_warnings:[],
summary:null,
acknowledged:false}')"
if [[ -z "$RECOVERY_ROOT" || ! -d "$RECOVERY_ROOT" ]]; then if [[ -z "$RECOVERY_ROOT" || ! -d "$RECOVERY_ROOT" ]]; then
echo "Recovery root invalid — aborting cleanly." echo "Recovery root invalid — aborting cleanly."
rm -f "$MARKER" rm -f "$MARKER"
_state_finish "failed" || true
exit 0 exit 0
fi fi
@@ -282,6 +427,7 @@ if [[ "$NEEDS_INITRAMFS" == "1" ]] || [[ "$NEEDS_GRUB" == "1" ]]; then
fi fi
if [[ "$NEEDS_INITRAMFS" == "1" ]] && command -v update-initramfs >/dev/null 2>&1; then if [[ "$NEEDS_INITRAMFS" == "1" ]] && command -v update-initramfs >/dev/null 2>&1; then
_state_step "Rebuilding initramfs" || true
echo "Running: update-initramfs -u -k all (5-10 min — restore touched initramfs inputs)" echo "Running: update-initramfs -u -k all (5-10 min — restore touched initramfs inputs)"
if update-initramfs -u -k all 2>&1 | tail -10; then if update-initramfs -u -k all 2>&1 | tail -10; then
echo " ✓ update-initramfs done" echo " ✓ update-initramfs done"
@@ -296,6 +442,7 @@ else
fi fi
if [[ "$NEEDS_GRUB" == "1" ]] && command -v update-grub >/dev/null 2>&1; then if [[ "$NEEDS_GRUB" == "1" ]] && command -v update-grub >/dev/null 2>&1; then
_state_step "Updating bootloader" || true
echo "Running: update-grub" echo "Running: update-grub"
if update-grub 2>&1 | tail -3; then if update-grub 2>&1 | tail -3; then
echo " ✓ update-grub done" echo " ✓ update-grub done"
@@ -358,6 +505,8 @@ if command -v jq >/dev/null 2>&1 && [[ -f "$COMPONENTS_STATUS" ]]; then
echo "" echo ""
echo "$comp (running $installer --auto-reinstall)" echo "$comp (running $installer --auto-reinstall)"
_state_step "Reinstalling $comp" || true
_state_component "$comp" "installing" "" ""
# Redirect to a per-component log instead of piping. NVIDIA's # Redirect to a per-component log instead of piping. NVIDIA's
# runfile installer forks helpers that inherit stdout, so a # runfile installer forks helpers that inherit stdout, so a
# `bash $installer | sed | tail` pipeline never sees EOF after # `bash $installer | sed | tail` pipeline never sees EOF after
@@ -368,8 +517,10 @@ if command -v jq >/dev/null 2>&1 && [[ -f "$COMPONENTS_STATUS" ]]; then
sed -e 's/^/ /' "$comp_log" | tail -15 sed -e 's/^/ /' "$comp_log" | tail -15
if (( rc == 0 )); then if (( rc == 0 )); then
echo "$comp ok (full log: $comp_log)" echo "$comp ok (full log: $comp_log)"
_state_component "$comp" "ok" "$comp_log" ""
else else
echo "$comp installer exited $rc — see $comp_log" echo "$comp installer exited $rc — see $comp_log"
_state_component "$comp" "failed" "$comp_log" "$rc"
fi fi
done done
fi fi
@@ -393,6 +544,14 @@ if [[ -n "$ROLLBACK_PLAN_FILE" ]] && command -v jq >/dev/null 2>&1; then
rb_vm_extras=$(jq -r '.vms_to_remove | join(", ") // ""' "$ROLLBACK_PLAN_FILE" 2>/dev/null) rb_vm_extras=$(jq -r '.vms_to_remove | join(", ") // ""' "$ROLLBACK_PLAN_FILE" 2>/dev/null)
rb_lxc_extras=$(jq -r '.lxcs_to_remove | join(", ") // ""' "$ROLLBACK_PLAN_FILE" 2>/dev/null) rb_lxc_extras=$(jq -r '.lxcs_to_remove | join(", ") // ""' "$ROLLBACK_PLAN_FILE" 2>/dev/null)
rb_comp_extras=$(jq -r '.components_to_uninstall | join(", ") // ""' "$ROLLBACK_PLAN_FILE" 2>/dev/null) rb_comp_extras=$(jq -r '.components_to_uninstall | join(", ") // ""' "$ROLLBACK_PLAN_FILE" 2>/dev/null)
# Copy the structured plan into the state file so the Monitor's
# detail modal can render each list as its own table without
# re-parsing the CSV strings above.
_state_write "$(jq -c '{rollback_delta:{
vms_to_remove: (.vms_to_remove // []),
lxcs_to_remove: (.lxcs_to_remove // []),
components_to_uninstall: (.components_to_uninstall // [])
}}' "$ROLLBACK_PLAN_FILE" 2>/dev/null)" || true
if [[ -n "$rb_vm_extras" || -n "$rb_lxc_extras" || -n "$rb_comp_extras" ]]; then if [[ -n "$rb_vm_extras" || -n "$rb_lxc_extras" || -n "$rb_comp_extras" ]]; then
echo "" echo ""
echo "── Rollback delta report ──" echo "── Rollback delta report ──"
@@ -419,6 +578,7 @@ fi
# configured, or a stale reference survived. Runs on every restore # configured, or a stale reference survived. Runs on every restore
# (cheap); warnings feed the completion notification so it tells # (cheap); warnings feed the completion notification so it tells
# the truth instead of a blanket "all fine". # the truth instead of a blanket "all fine".
_state_step "Boot sanity check" || true
SANITY_WARNINGS="" SANITY_WARNINGS=""
_sanity_warn() { _sanity_warn() {
if [[ -z "$SANITY_WARNINGS" ]]; then if [[ -z "$SANITY_WARNINGS" ]]; then
@@ -455,6 +615,14 @@ if [[ -n "$SANITY_WARNINGS" ]]; then
echo "Cross-version: ${HB_COMPAT_CROSS_VERSION:-0}" echo "Cross-version: ${HB_COMPAT_CROSS_VERSION:-0}"
fi fi
# Persist sanity warnings as a JSON array so the Monitor's detail
# modal can render each warning as its own line. Empty warnings →
# empty array, which the UI treats as "sanity OK".
if command -v jq >/dev/null 2>&1; then
_state_write "$(jq -cn --arg s "$SANITY_WARNINGS" \
'{sanity_warnings: (if $s == "" then [] else ($s | split("; ")) end)}')" || true
fi
# ── Notify ProxMenux Monitor that we're done ─────────────────── # ── Notify ProxMenux Monitor that we're done ───────────────────
# Routes through the user's configured channels (Telegram, Discord, # Routes through the user's configured channels (Telegram, Discord,
# ntfy, etc.). Localhost-only endpoint, no auth needed. We try # ntfy, etc.). Localhost-only endpoint, no auth needed. We try
@@ -509,6 +677,21 @@ if command -v curl >/dev/null 2>&1; then
fi fi
fi fi
# Persist a compact summary the Monitor's card can render inline.
# Mirrors what the notification block sends; keeps a single source
# of truth for both the alert channels and the Web UI.
if command -v jq >/dev/null 2>&1; then
_state_write "$(jq -cn \
--arg hostname "$(hostname)" \
--arg guests "${copied_guests:-0}" \
--arg stubs "${stub_created:-0}" \
--arg stale_nodes "${removed_nodes:-0}" \
--arg components "${COMPONENTS_REINSTALLED_CSV:-none}" \
--arg duration "$POSTBOOT_DURATION_FMT" \
'{summary:{hostname:$hostname, guests:$guests, stubs:$stubs, stale_nodes:$stale_nodes, components:$components, duration:$duration}}')" || true
fi
_state_finish "complete" || true
echo "" echo ""
echo "=== Apply finished at $(date -Iseconds) — total ${POSTBOOT_DURATION_FMT} ===" echo "=== Apply finished at $(date -Iseconds) — total ${POSTBOOT_DURATION_FMT} ==="
echo "Log: $LOG_FILE" echo "Log: $LOG_FILE"
+22 -18
View File
@@ -163,7 +163,7 @@ _bk_pbs() {
# is still passphrase-protected by openssl. # is still passphrase-protected by openssl.
if [[ -n "$HB_PBS_KEYFILE_OPT" && -f "$HB_STATE_DIR/pbs-key.recovery.enc" ]]; then if [[ -n "$HB_PBS_KEYFILE_OPT" && -f "$HB_STATE_DIR/pbs-key.recovery.enc" ]]; then
hb_pbs_upload_recovery_blob "$epoch" \ hb_pbs_upload_recovery_blob "$epoch" \
|| msg_warn "$(translate "Recovery blob upload failed — main backup is OK, but keyfile recovery from PBS will not be available for this snapshot.")" || msg_warn "$(translate "Recovery blob upload failed — main backup is OK, but keyfile recovery from PBS will not be available for this backup.")"
fi fi
elapsed=$((SECONDS - t_start)) elapsed=$((SECONDS - t_start))
@@ -174,7 +174,7 @@ _bk_pbs() {
echo -e "${TAB}${BGN}$(translate "Method:")${CL} ${BL}Proxmox Backup Server (PBS)${CL}" echo -e "${TAB}${BGN}$(translate "Method:")${CL} ${BL}Proxmox Backup Server (PBS)${CL}"
echo -e "${TAB}${BGN}$(translate "Repository:")${CL} ${BL}${HB_PBS_REPOSITORY}${CL}" echo -e "${TAB}${BGN}$(translate "Repository:")${CL} ${BL}${HB_PBS_REPOSITORY}${CL}"
echo -e "${TAB}${BGN}$(translate "Backup ID:")${CL} ${BL}${backup_id}${CL}" echo -e "${TAB}${BGN}$(translate "Backup ID:")${CL} ${BL}${backup_id}${CL}"
echo -e "${TAB}${BGN}$(translate "Snapshot:")${CL} ${BL}host/${backup_id}/${snap_time}${CL}" echo -e "${TAB}${BGN}$(translate "Backup path:")${CL} ${BL}host/${backup_id}/${snap_time}${CL}"
echo -e "${TAB}${BGN}$(translate "Data size:")${CL} ${BL}${staged_size}${CL}" echo -e "${TAB}${BGN}$(translate "Data size:")${CL} ${BL}${staged_size}${CL}"
echo -e "${TAB}${BGN}$(translate "Duration:")${CL} ${BL}$(hb_human_elapsed "$elapsed")${CL}" echo -e "${TAB}${BGN}$(translate "Duration:")${CL} ${BL}$(hb_human_elapsed "$elapsed")${CL}"
echo -e "${TAB}${BGN}$(translate "Encryption:")${CL} ${BL}${_pbs_enc_label}${CL}" echo -e "${TAB}${BGN}$(translate "Encryption:")${CL} ${BL}${_pbs_enc_label}${CL}"
@@ -843,10 +843,11 @@ _rs_extract_pbs() {
--output-format json 2>/dev/null \ --output-format json 2>/dev/null \
| jq -r '.[] | jq -r '.[]
| select(."backup-type" == "host" | select(."backup-type" == "host"
and not ( and (
(."backup-id" | startswith("proxmenux-keyrecovery-")) ((."backup-id" | startswith("proxmenux-keyrecovery-"))
or ((."backup-id" | startswith("hostcfg-")) and (."backup-id" | endswith("-keyrecovery"))) or ((."backup-id" | startswith("hostcfg-")) and (."backup-id" | endswith("-keyrecovery"))))
)) | not
))
| "\(."backup-type")|\(."backup-id")|\(."backup-time")"' 2>/dev/null \ | "\(."backup-type")|\(."backup-id")|\(."backup-time")"' 2>/dev/null \
| while IFS='|' read -r _type _id _epoch; do | while IFS='|' read -r _type _id _epoch; do
local _iso local _iso
@@ -863,8 +864,8 @@ _rs_extract_pbs() {
# it. msg_error alone gets erased the moment we `return 1` # it. msg_error alone gets erased the moment we `return 1`
# because the restore_menu loop redraws the source picker # because the restore_menu loop redraws the source picker
# immediately afterward. # immediately afterward.
dialog --backtitle "ProxMenux" --title "$(translate "No snapshots")" \ dialog --backtitle "ProxMenux" --title "$(translate "No backups")" \
--msgbox "$(translate "No host snapshots were found in this PBS repository:")"$'\n\n'"$HB_PBS_REPOSITORY" \ --msgbox "$(translate "No host backups were found in this PBS repository:")"$'\n\n'"$HB_PBS_REPOSITORY" \
10 78 10 78
return 1 return 1
fi fi
@@ -873,8 +874,8 @@ _rs_extract_pbs() {
for snapshot in "${snapshots[@]}"; do menu+=("$i" "$snapshot"); ((i++)); done for snapshot in "${snapshots[@]}"; do menu+=("$i" "$snapshot"); ((i++)); done
local sel local sel
sel=$(dialog --backtitle "ProxMenux" \ sel=$(dialog --backtitle "ProxMenux" \
--title "$(translate "Select snapshot to restore")" \ --title "$(translate "Select backup to restore")" \
--menu "\n$(translate "Available host snapshots:")" \ --menu "\n$(translate "Available host backups:")" \
"$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" "${menu[@]}" 3>&1 1>&2 2>&3) || return 1 "$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" "${menu[@]}" 3>&1 1>&2 2>&3) || return 1
snapshot="${snapshots[$((sel-1))]}" snapshot="${snapshots[$((sel-1))]}"
@@ -893,7 +894,7 @@ _rs_extract_pbs() {
) )
if [[ ${#archives[@]} -eq 0 ]]; then if [[ ${#archives[@]} -eq 0 ]]; then
dialog --backtitle "ProxMenux" --title "$(translate "No archives")" \ dialog --backtitle "ProxMenux" --title "$(translate "No archives")" \
--msgbox "$(translate "No .pxar archives were found in this snapshot:")"$'\n\n'"$snapshot" \ --msgbox "$(translate "No .pxar archives were found in this backup:")"$'\n\n'"$snapshot" \
10 78 10 78
return 1 return 1
fi fi
@@ -915,7 +916,7 @@ _rs_extract_pbs() {
msg_title "$(translate "Restore from PBS → staging")" msg_title "$(translate "Restore from PBS → staging")"
echo -e "" echo -e ""
echo -e "${TAB}${BGN}$(translate "Repository:")${CL} ${BL}${HB_PBS_REPOSITORY}${CL}" echo -e "${TAB}${BGN}$(translate "Repository:")${CL} ${BL}${HB_PBS_REPOSITORY}${CL}"
echo -e "${TAB}${BGN}$(translate "Snapshot:")${CL} ${BL}${snapshot}${CL}" echo -e "${TAB}${BGN}$(translate "Backup:")${CL} ${BL}${snapshot}${CL}"
echo -e "${TAB}${BGN}$(translate "Archive:")${CL} ${BL}${archive}${CL}" echo -e "${TAB}${BGN}$(translate "Archive:")${CL} ${BL}${archive}${CL}"
echo -e "${TAB}${BGN}$(translate "Staging directory:")${CL} ${BL}${staging_root}${CL}" echo -e "${TAB}${BGN}$(translate "Staging directory:")${CL} ${BL}${staging_root}${CL}"
echo -e "" echo -e ""
@@ -960,16 +961,16 @@ _rs_extract_pbs() {
# error rather than the raw log. # error rather than the raw log.
local extra_hint="" local extra_hint=""
if grep -qiE 'encryption key|unable to (load|read) key|no key (file|found)|decrypt|failed to decrypt' "$log_file" 2>/dev/null; then if grep -qiE 'encryption key|unable to (load|read) key|no key (file|found)|decrypt|failed to decrypt' "$log_file" 2>/dev/null; then
extra_hint=$'\n\n'"$(translate "This snapshot is encrypted but no keyfile is available on this host.")" extra_hint=$'\n\n'"$(translate "This backup is encrypted but no keyfile is available on this host.")"
if [[ -f "$HB_STATE_DIR/pbs-key.conf" ]]; then if [[ -f "$HB_STATE_DIR/pbs-key.conf" ]]; then
extra_hint+=$'\n\n'"$(translate "A keyfile is present but doesn't match the one used to create the snapshot. Make sure you have the correct keyfile from the source host.")" extra_hint+=$'\n\n'"$(translate "A keyfile is present but doesn't match the one used to create the backup. Make sure you have the correct keyfile from the source host.")"
else else
extra_hint+=$'\n\n'"$(translate "No keyfile recovery copy was found in PBS for this snapshot — it was created before the recovery feature existed. The encrypted content cannot be recovered.")" extra_hint+=$'\n\n'"$(translate "No keyfile recovery copy was found in PBS for this backup — it was created before the recovery feature existed. The encrypted content cannot be recovered.")"
fi fi
fi fi
dialog --backtitle "ProxMenux" --title "$(translate "PBS extraction failed")" \ dialog --backtitle "ProxMenux" --title "$(translate "PBS extraction failed")" \
--msgbox "$(translate "Could not extract from PBS.")"$'\n\n'"$(translate "Snapshot:") $snapshot"$'\n'"$(translate "Archive:") $archive$extra_hint" \ --msgbox "$(translate "Could not extract from PBS.")"$'\n\n'"$(translate "Backup:") $snapshot"$'\n'"$(translate "Archive:") $archive$extra_hint" \
16 78 16 78
hb_show_log "$log_file" "$(translate "PBS restore error log")" hb_show_log "$log_file" "$(translate "PBS restore error log")"
return 1 return 1
@@ -1751,8 +1752,9 @@ _rs_offer_reboot_after_pending() {
bg_block+="${label} (${eta})"$'\n' bg_block+="${label} (${eta})"$'\n'
done done
bg_block+=$'\n'"$(translate "Monitor progress:")"$'\n' bg_block+=$'\n'"$(translate "Monitor progress:")"$'\n'
bg_block+=" tail -f /var/log/proxmenux/proxmenux-cluster-postboot-*.log"$'\n' bg_block+="$(translate "ProxMenux Monitor → Backups tab (live progress card with ETA, logs, rollback delta)")"$'\n'
bg_block+=" systemctl status proxmenux-apply-cluster-postboot.service"$'\n\n' bg_block+=" • tail -f /var/log/proxmenux/proxmenux-cluster-postboot-*.log"$'\n'
bg_block+=" • systemctl status proxmenux-apply-cluster-postboot.service"$'\n\n'
bg_block+="$(translate "If notifications are enabled (Telegram/Discord/ntfy/...), you will receive a \"Host restore finished\" message when all background tasks complete.")"$'\n\n' bg_block+="$(translate "If notifications are enabled (Telegram/Discord/ntfy/...), you will receive a \"Host restore finished\" message when all background tasks complete.")"$'\n\n'
fi fi
@@ -2681,6 +2683,7 @@ _rs_run_complete_guided() {
body+=$'\n'"${RS_HYDRATION_SUMMARY}" body+=$'\n'"${RS_HYDRATION_SUMMARY}"
fi fi
body+=$'\n'"\Zb\Z4$(translate "A reboot is required to finish the restore.")\Zn"$'\n\n' body+=$'\n'"\Zb\Z4$(translate "A reboot is required to finish the restore.")\Zn"$'\n\n'
body+="$(translate "After the reboot you can follow the post-restore work live from ProxMenux Monitor → Backups tab (ETA, per-component status, log tail, rollback delta).")"$'\n\n'
body+="$(translate "If notifications are enabled (Telegram/Discord/ntfy/...), you will receive a \"Host restore finished\" message when all background tasks complete.")"$'\n\n' body+="$(translate "If notifications are enabled (Telegram/Discord/ntfy/...), you will receive a \"Host restore finished\" message when all background tasks complete.")"$'\n\n'
body+="\Zb$(translate "Continue?")\ZB" body+="\Zb$(translate "Continue?")\ZB"
@@ -3089,6 +3092,7 @@ _rs_run_custom_restore() {
fi fi
if (( pending_count > 0 )); then if (( pending_count > 0 )); then
body+=$'\n'"\Zb\Z4$(translate "A reboot will be required to complete the restore.")\Zn"$'\n' body+=$'\n'"\Zb\Z4$(translate "A reboot will be required to complete the restore.")\Zn"$'\n'
body+="$(translate "Follow post-restore progress live from ProxMenux Monitor → Backups tab after the reboot.")"$'\n'
fi fi
body+=$'\n'"\Zb$(translate "Continue?")\ZB" body+=$'\n'"\Zb$(translate "Continue?")\ZB"
+103 -13
View File
@@ -75,6 +75,22 @@ _list_jobs() {
done | sort done | sort
} }
# Same as _list_jobs but skips one-shot manual runs (Sprint B).
# Manual entries are `.env` files with `MANUAL_RUN=1` — they're
# closed executions, not configured tasks. Used by "Show jobs"
# and "Run a job now" so those views only surface real backup
# tasks; Delete / Toggle keep using _list_jobs so the operator
# can still clean up leftover manual entries from those menus.
_list_scheduled_jobs() {
local f id
for f in "$JOBS_DIR"/*.env; do
[[ -f "$f" ]] || continue
grep -q '^MANUAL_RUN=1$' "$f" 2>/dev/null && continue
id=$(basename "$f" .env)
printf '%s\n' "$id"
done | sort
}
# Returns 0 if the job is attached to a PVE vzdump storage (no systemd # Returns 0 if the job is attached to a PVE vzdump storage (no systemd
# timer — the trigger comes from the vzdump hook, matched by PVE_STORAGE # timer — the trigger comes from the vzdump hook, matched by PVE_STORAGE
# against $STOREID set by PVE for every backup phase). # against $STOREID set by PVE for every backup phase).
@@ -443,9 +459,18 @@ _create_job() {
_pick_job() { _pick_job() {
local title="$1" local title="$1"
local __out_var="$2" local __out_var="$2"
# Optional scope: "all" (default) surfaces every .env in JOBS_DIR
# including one-shot manual runs; "scheduled" filters those out so
# Run-now only shows real configured tasks. Delete / Toggle keep
# the default so the operator can still clean up manual leftovers.
local scope="${3:-all}"
local -a ids=() local -a ids=()
mapfile -t ids < <(_list_jobs) if [[ "$scope" == "scheduled" ]]; then
mapfile -t ids < <(_list_scheduled_jobs)
else
mapfile -t ids < <(_list_jobs)
fi
if [[ ${#ids[@]} -eq 0 ]]; then if [[ ${#ids[@]} -eq 0 ]]; then
dialog --backtitle "ProxMenux" --title "$(translate "No jobs")" \ dialog --backtitle "ProxMenux" --title "$(translate "No jobs")" \
--msgbox "$(translate "No scheduled backup jobs found.")" 8 62 --msgbox "$(translate "No scheduled backup jobs found.")" 8 62
@@ -495,8 +520,11 @@ _render_action_screen() {
} }
_job_run_now() { _job_run_now() {
# "scheduled" scope — one-shot manual runs are closed executions,
# not tasks to re-fire. Filtering them out of this picker prevents
# accidental re-runs of an operator's past manual backups.
local id="" local id=""
_pick_job "$(translate "Run job now")" id || return 1 _pick_job "$(translate "Run job now")" id scheduled || return 1
# Defensive guard against a future regression of the nameref-shadowing # Defensive guard against a future regression of the nameref-shadowing
# bug that left $id empty here on 2026-06-07. Without this, the runner # bug that left $id empty here on 2026-06-07. Without this, the runner
# gets called with no argument and emits "Usage: ... <job_id>". # gets called with no argument and emits "Usage: ... <job_id>".
@@ -527,8 +555,12 @@ _job_run_now() {
} }
_job_toggle() { _job_toggle() {
# "scheduled" scope — one-shot manual runs carry ENABLED=0 by
# definition and can't be re-fired from a timer, so offering them
# in this picker was noise. Delete keeps the "all" scope so the
# operator can still clean up manual entries from that menu.
local id="" local id=""
_pick_job "$(translate "Enable/Disable job")" id || return 1 _pick_job "$(translate "Enable/Disable job")" id scheduled || return 1
if [[ -z "$id" ]]; then if [[ -z "$id" ]]; then
_render_action_screen "$(translate "Enable/Disable job")" _render_action_screen "$(translate "Enable/Disable job")"
msg_error "$(translate "Job selection returned empty id — aborting.")" msg_error "$(translate "Job selection returned empty id — aborting.")"
@@ -610,21 +642,79 @@ _job_delete() {
_show_jobs() { _show_jobs() {
local tmp local tmp
tmp=$(mktemp) || return tmp=$(mktemp) || return
# Per-job block: header line with status badge + 3 detail lines
# summarising the backend destination and the last run — same
# information the Monitor UI shows on the Backups tab, condensed
# for the shell dialog.
local -a job_ids=()
local id
while IFS= read -r id; do
[[ -z "$id" ]] && continue
job_ids+=("$id")
done < <(_list_scheduled_jobs)
{ {
echo "=== $(translate "Scheduled backup jobs") ===" echo "=== $(translate "Scheduled backup jobs") ==="
echo "" echo ""
local id if [[ ${#job_ids[@]} -eq 0 ]]; then
while IFS= read -r id; do translate "No scheduled backup jobs configured."
[[ -z "$id" ]] && continue else
echo "$id [$(_show_job_status "$id")]" local status backend dest label profile last_run last_result
if [[ -f "${LOG_DIR}/${id}-last.status" ]]; then for id in "${job_ids[@]}"; do
sed 's/^/ /' "${LOG_DIR}/${id}-last.status" status=$(_show_job_status "$id")
fi backend=$(_job_env_get "$id" BACKEND || echo "")
echo "" profile=$(_job_env_get "$id" PROFILE_MODE || echo default)
done < <(_list_jobs) case "$backend" in
pbs)
local pbs_repo pbs_bid
pbs_repo=$(_job_env_get "$id" PBS_REPOSITORY || echo "?")
pbs_bid=$(_job_env_get "$id" PBS_BACKUP_ID || echo "?")
dest="$pbs_repo (id=$pbs_bid)"
label="PBS"
;;
local)
dest=$(_job_env_get "$id" LOCAL_DEST_DIR || echo "?")
label="Local archive"
;;
borg)
dest=$(_job_env_get "$id" BORG_REPO || echo "?")
label="Borg"
;;
*)
dest="?"
label="${backend:-?}"
;;
esac
last_run=""; last_result=""
if [[ -f "${LOG_DIR}/${id}-last.status" ]]; then
local last_at
last_at=$(grep -m1 '^RUN_AT=' "${LOG_DIR}/${id}-last.status" | cut -d= -f2-)
last_result=$(grep -m1 '^RESULT=' "${LOG_DIR}/${id}-last.status" | cut -d= -f2-)
# Trim the timezone suffix for readability (14:13:54 vs 14:13:54+02:00)
last_run="${last_at%%+*}"
last_run="${last_run%%-[0-9][0-9]:[0-9][0-9]}"
last_run="${last_run/T/ }"
fi
printf "• %s [%s]\n" "$id" "$status"
printf " %-9s %s · %s\n" "$(translate "Backend:")" "$label" "$dest"
printf " %-9s %s\n" "$(translate "Profile:")" "$profile"
if [[ -n "$last_run" ]]; then
printf " %-9s %s → %s\n" "$(translate "Last run:")" "$last_run" "${last_result:-?}"
else
printf " %-9s %s\n" "$(translate "Last run:")" "$(translate "never")"
fi
echo ""
done
fi
} > "$tmp" } > "$tmp"
# Same window size as the parent scheduler menu — keeps the
# dimensions consistent across the flow instead of one dialog
# shrinking or growing per view.
dialog --backtitle "ProxMenux" --title "$(translate "Scheduled backup jobs")" \ dialog --backtitle "ProxMenux" --title "$(translate "Scheduled backup jobs")" \
--textbox "$tmp" 28 100 || true --textbox "$tmp" "$HB_UI_MENU_H" "$HB_UI_MENU_W" || true
rm -f "$tmp" rm -f "$tmp"
} }
+178 -126
View File
@@ -953,20 +953,18 @@ hb_pbs_import_keyfile() {
return 0 return 0
} }
hb_pbs_setup_recovery() { # Prompt for a recovery passphrase pair; on OK returns the passphrase on
local key_file="$HB_STATE_DIR/pbs-key.conf" # stdout. Cancel or empty input returns non-zero WITHOUT creating any
local recovery_enc="$HB_STATE_DIR/pbs-key.recovery.enc" # file. The caller decides when — and whether — to persist a keyfile
# and its recovery blob after this. Splitting the prompt out of the
# Choosing "encrypt" already implies wanting to protect the keyfile. # side-effectful phase means "user cancels passphrase" leaves the disk
# No second yes/no — go straight to the passphrase input. Cancel at # in exactly the state it was before the encryption flow started.
# any passphrase prompt propagates up as return 1 so the caller can _hb_pbs_prompt_recovery_pass() {
# drop the operator back to the previous menu.
if ! command -v openssl >/dev/null 2>&1; then if ! command -v openssl >/dev/null 2>&1; then
dialog --backtitle "ProxMenux" --title "$(hb_translate "Recovery setup failed")" \ dialog --backtitle "ProxMenux" --title "$(hb_translate "Recovery setup failed")" \
--msgbox "$(hb_translate "openssl is not installed — cannot create recovery copy. Install openssl and retry.")" 9 70 --msgbox "$(hb_translate "openssl is not installed — cannot create recovery copy. Install openssl and retry.")" 9 70
return 1 return 1
fi fi
local pass1 pass2 local pass1 pass2
while true; do while true; do
pass1=$(dialog --backtitle "ProxMenux" --title "$(hb_translate "Recovery passphrase")" \ pass1=$(dialog --backtitle "ProxMenux" --title "$(hb_translate "Recovery passphrase")" \
@@ -980,8 +978,20 @@ hb_pbs_setup_recovery() {
dialog --backtitle "ProxMenux" \ dialog --backtitle "ProxMenux" \
--msgbox "$(hb_translate "Passphrases do not match. Try again.")" 8 50 --msgbox "$(hb_translate "Passphrases do not match. Try again.")" 8 50
done done
printf '%s' "$pass1"
}
if ! printf '%s' "$pass1" | hb_pbs_encrypt_recovery "$key_file" "$recovery_enc"; then # Encrypt the on-disk keyfile with the supplied passphrase, drop an
# offsite copy under /root, and show the operator the success dialog.
# Precondition: the keyfile exists at $HB_STATE_DIR/pbs-key.conf.
# Returns non-zero only on openssl failure (rare). The caller is
# responsible for undoing whatever it just did if this fails.
_hb_pbs_finalize_recovery() {
local pass="$1"
local key_file="$HB_STATE_DIR/pbs-key.conf"
local recovery_enc="$HB_STATE_DIR/pbs-key.recovery.enc"
if ! printf '%s' "$pass" | hb_pbs_encrypt_recovery "$key_file" "$recovery_enc"; then
dialog --backtitle "ProxMenux" --title "$(hb_translate "Recovery setup failed")" \ dialog --backtitle "ProxMenux" --title "$(hb_translate "Recovery setup failed")" \
--msgbox "$(hb_translate "openssl encryption failed.")" 9 70 --msgbox "$(hb_translate "openssl encryption failed.")" 9 70
return 1 return 1
@@ -1009,6 +1019,15 @@ hb_pbs_setup_recovery() {
return 0 return 0
} }
# Public wrapper preserved for external callers that already have a
# keyfile on disk and just want the recovery blob generated. Combines
# the prompt + finalize phases.
hb_pbs_setup_recovery() {
local pass
pass=$(_hb_pbs_prompt_recovery_pass) || return 1
_hb_pbs_finalize_recovery "$pass"
}
# Upload the local recovery .enc to PBS as a separate snapshot # Upload the local recovery .enc to PBS as a separate snapshot
# group. Called from _bk_pbs after the main backup succeeds. # group. Called from _bk_pbs after the main backup succeeds.
# Skips silently if no recovery copy is present. Returns 0 on # Skips silently if no recovery copy is present. Returns 0 on
@@ -1111,7 +1130,7 @@ hb_pbs_try_keyfile_recovery() {
local recovery_snapshot="host/${picked_id}/${iso}" local recovery_snapshot="host/${picked_id}/${iso}"
dialog --backtitle "ProxMenux" --title "$(hb_translate "Keyfile recovery available")" \ dialog --backtitle "ProxMenux" --title "$(hb_translate "Keyfile recovery available")" \
--yesno "$(hb_translate "Local keyfile is missing but a recovery copy was found in PBS.")"$'\n\n'"$(hb_translate "Snapshot:") $recovery_snapshot"$'\n\n'"$(hb_translate "Recover the keyfile using your recovery passphrase?")" \ --yesno "$(hb_translate "Local keyfile is missing but a recovery copy was found in PBS.")"$'\n\n'"$(hb_translate "Backup:") $recovery_snapshot"$'\n\n'"$(hb_translate "Recover the keyfile using your recovery passphrase?")" \
13 78 || return 1 13 78 || return 1
# Download the blob once; we may retry passphrase entry without # Download the blob once; we may retry passphrase entry without
@@ -1160,166 +1179,176 @@ hb_pbs_try_keyfile_recovery() {
} }
# Internal helper: create a fresh PBS keyfile at the canonical path, # Internal helper: create a fresh PBS keyfile at the canonical path
# offer the recovery passphrase, and export HB_PBS_KEYFILE_OPT. # and its paired recovery blob. The passphrase is prompted BEFORE the
# Returns 0 on success, 1 on any failure or cancel (leaves state # keyfile hits disk — so if the operator cancels at the passphrase
# clean — canonical path is wiped on cancel so the next attempt # prompt, nothing is created and nothing needs cleaning up. The only
# starts fresh). # time we ever have to remove a keyfile here is if openssl fails to
# encrypt the recovery blob (rare); that's a real error, not a cancel.
_hb_pbs_create_new_keyfile() { _hb_pbs_create_new_keyfile() {
local key_file="$HB_STATE_DIR/pbs-key.conf" local key_file="$HB_STATE_DIR/pbs-key.conf"
local recovery_enc="$HB_STATE_DIR/pbs-key.recovery.enc" local recovery_enc="$HB_STATE_DIR/pbs-key.recovery.enc"
# 1. Collect the recovery passphrase. Cancel here → zero side effect.
local pass
pass=$(_hb_pbs_prompt_recovery_pass) || return 1
# 2. Create the keyfile. Point of no return: any failure past here
# leaves us with state to clean up.
msg_info "$(hb_translate "Creating PBS encryption key...")" msg_info "$(hb_translate "Creating PBS encryption key...")"
mkdir -p "$HB_STATE_DIR" mkdir -p "$HB_STATE_DIR"
local create_stderr local create_stderr
create_stderr=$(proxmox-backup-client key create --kdf none "$key_file" </dev/null 2>&1 >/dev/null) create_stderr=$(proxmox-backup-client key create --kdf none "$key_file" </dev/null 2>&1 >/dev/null)
local create_rc=$? local create_rc=$?
if [[ $create_rc -eq 0 && -s "$key_file" ]]; then if [[ $create_rc -ne 0 || ! -s "$key_file" ]]; then
chmod 600 "$key_file" # Sweep any zero-byte residue and surface the real error.
msg_ok "$(hb_translate "Encryption key created:") $key_file" [[ -f "$key_file" && ! -s "$key_file" ]] && rm -f "$key_file" 2>/dev/null
HB_PBS_KEYFILE_OPT="--keyfile $key_file" local err_msg
if ! hb_pbs_setup_recovery; then err_msg="$(hb_translate "Failed to create encryption key. Backup cancelled — fix the underlying issue and retry.")"$'\n\n'
rm -f "$key_file" "$recovery_enc" 2>/dev/null err_msg+="$(hb_translate "Tool exit code:") $create_rc"$'\n'
HB_PBS_KEYFILE_OPT="" err_msg+="$(hb_translate "Tool output:")"$'\n'
return 1 err_msg+="${create_stderr:-(empty)}"
fi dialog --backtitle "ProxMenux" --title "$(hb_translate "Encryption key creation failed")" \
return 0 --msgbox "$err_msg" 14 78
HB_PBS_KEYFILE_OPT=""
return 1
fi
chmod 600 "$key_file"
msg_ok "$(hb_translate "Encryption key created:") $key_file"
# 3. Encrypt the recovery blob with the already-collected passphrase.
# Only an openssl-level failure lands us in the wipe branch.
if ! _hb_pbs_finalize_recovery "$pass"; then
rm -f "$key_file" "$recovery_enc" 2>/dev/null
HB_PBS_KEYFILE_OPT=""
return 1
fi fi
# Create failed — sweep zero-byte residue and surface the real error. HB_PBS_KEYFILE_OPT="--keyfile $key_file"
[[ -f "$key_file" && ! -s "$key_file" ]] && rm -f "$key_file" 2>/dev/null return 0
local err_msg
err_msg="$(hb_translate "Failed to create encryption key. Backup cancelled — fix the underlying issue and retry.")"$'\n\n'
err_msg+="$(hb_translate "Tool exit code:") $create_rc"$'\n'
err_msg+="$(hb_translate "Tool output:")"$'\n'
err_msg+="${create_stderr:-(empty)}"
dialog --backtitle "ProxMenux" --title "$(hb_translate "Encryption key creation failed")" \
--msgbox "$err_msg" 14 78
HB_PBS_KEYFILE_OPT=""
return 1
} }
# Internal helper: prompt the operator for a source path, validate # Internal helper: prompt for the source path, validate WITHOUT
# with hb_pbs_import_keyfile, install at the canonical path, offer # copying, ask the recovery passphrase, and only then install the
# the recovery passphrase, and export HB_PBS_KEYFILE_OPT. Returns # keyfile at the canonical path. Cancel at any dialog before the
# 0 on success, 1 on cancel or failure (state is left clean). # install step leaves the disk untouched — no "phantom" keyfile.
_hb_pbs_import_dialog() { _hb_pbs_import_dialog() {
local key_file="$HB_STATE_DIR/pbs-key.conf" local key_file="$HB_STATE_DIR/pbs-key.conf"
local recovery_enc="$HB_STATE_DIR/pbs-key.recovery.enc"
local src local src
# 1. Ask the path.
src=$(dialog --backtitle "ProxMenux" --title "$(hb_translate "Import PBS keyfile")" \ src=$(dialog --backtitle "ProxMenux" --title "$(hb_translate "Import PBS keyfile")" \
--inputbox "$(hb_translate "Absolute path to your existing PBS keyfile:")"$'\n\n'"$(hb_translate "The file is validated with 'proxmox-backup-client key info' and copied to")"$'\n'"$key_file $(hb_translate "with chmod 600.")" \ --inputbox "$(hb_translate "Absolute path to your existing PBS keyfile:")"$'\n\n'"$(hb_translate "The file is validated with 'proxmox-backup-client key info' and copied to")"$'\n'"$key_file $(hb_translate "with chmod 600.")" \
14 78 "" 3>&1 1>&2 2>&3) || return 1 14 78 "" 3>&1 1>&2 2>&3) || return 1
src="$(echo "$src" | xargs)" src="$(echo "$src" | xargs)"
[[ -z "$src" ]] && return 1 [[ -z "$src" ]] && return 1
hb_pbs_import_keyfile "$src" # 2. Validate the source without touching the canonical path yet.
local rc=$? # Same shape as the check inside hb_pbs_import_keyfile, so a
case $rc in # later install call can't disagree with what we saw here.
0) if [[ ! -s "$src" || ! -r "$src" ]]; then
msg_ok "$(hb_translate "Imported existing encryption key:") $key_file" dialog --backtitle "ProxMenux" --title "$(hb_translate "Import failed")" \
HB_PBS_KEYFILE_OPT="--keyfile $key_file" --msgbox "$(hb_translate "The file does not exist, is empty or is not readable.")"$'\n\n'"$(hb_translate "Path:") $src" \
# Recovery blob is still offered. When the same key lives 10 78
# on multiple hosts, a single blob works for all — but return 1
# re-uploading from each host is idempotent and lets the fi
# operator recover the key from PBS even if only ONE host if ! proxmox-backup-client key info --output-format json "$src" >/dev/null 2>&1; then
# is left standing. dialog --backtitle "ProxMenux" --title "$(hb_translate "Import failed")" \
if ! hb_pbs_setup_recovery; then --msgbox "$(hb_translate "proxmox-backup-client did not recognise this file as a valid PBS keyfile.")"$'\n\n'"$(hb_translate "Path:") $src" \
HB_PBS_KEYFILE_OPT="" 10 78
return 1 return 1
fi fi
return 0
;; # 3. Collect the recovery passphrase. Cancel here → zero side effect
1) # (the source file is still where it was; we haven't copied yet).
dialog --backtitle "ProxMenux" --title "$(hb_translate "Import failed")" \ local pass
--msgbox "$(hb_translate "The file does not exist, is empty or is not readable.")"$'\n\n'"$(hb_translate "Path:") $src" \ pass=$(_hb_pbs_prompt_recovery_pass) || return 1
10 78
return 1 # 4. Now install the keyfile. Point of no return.
;; if ! hb_pbs_import_keyfile "$src"; then
2) dialog --backtitle "ProxMenux" --title "$(hb_translate "Import failed")" \
dialog --backtitle "ProxMenux" --title "$(hb_translate "Import failed")" \ --msgbox "$(hb_translate "Could not copy the keyfile into place. Check permissions on:") $HB_STATE_DIR" \
--msgbox "$(hb_translate "proxmox-backup-client did not recognise this file as a valid PBS keyfile.")"$'\n\n'"$(hb_translate "Path:") $src" \ 10 78
10 78 return 1
return 1 fi
;; msg_ok "$(hb_translate "Imported existing encryption key:") $key_file"
*)
dialog --backtitle "ProxMenux" --title "$(hb_translate "Import failed")" \ # 5. Encrypt the recovery blob with the passphrase collected in step 3.
--msgbox "$(hb_translate "Could not copy the keyfile into place. Check permissions on:") $HB_STATE_DIR" \ # openssl failure is the only path that has to undo the install.
10 78 if ! _hb_pbs_finalize_recovery "$pass"; then
return 1 rm -f "$key_file" "$recovery_enc" 2>/dev/null
;; HB_PBS_KEYFILE_OPT=""
esac return 1
fi
HB_PBS_KEYFILE_OPT="--keyfile $key_file"
return 0
} }
hb_ask_pbs_encryption() { hb_ask_pbs_encryption() {
# Two-step flow:
# 1. Yes/No: "Do you want to encrypt this backup?"
# No → plain backup, no keyfile touched.
# Yes → step 2.
# 2a. Keyfile already exists on this host → use it silently.
# 2b. No keyfile yet → menu with 2 options:
# new → generate + confirm passphrase (legacy flow).
# import → prompt path, validate, copy into place.
# cancel → ABORT the whole backup (operator asked for
# encryption but didn't pick a method; we don't
# silently downgrade to plain).
# A single-run helper leftover from a cancelled/failed create can
# leave a zero-byte keyfile behind; we wipe it before deciding so
# the "keyfile exists" branch never trips on an empty file.
local key_file="$HB_STATE_DIR/pbs-key.conf" local key_file="$HB_STATE_DIR/pbs-key.conf"
local recovery_enc="$HB_STATE_DIR/pbs-key.recovery.enc"
export HB_PBS_KEYFILE_OPT="" export HB_PBS_KEYFILE_OPT=""
export HB_PBS_ENC_PASS="" export HB_PBS_ENC_PASS=""
# Wipe any scrollback that might leak above our dialogs — most
# often the terminal title or a stray line from a prior manual
# `proxmox-backup-client` invocation in the same SSH session.
clear clear
printf '\033]0;ProxMenux\007' printf '\033]0;ProxMenux\007'
# Wipe any zero-byte keyfile left behind by a previous cancelled
# or failed key-create run — otherwise the "existing" branch would
# hand `--keyfile <empty>` to proxmox-backup-client and die mid
# backup with an opaque "unable to load encryption key" error.
if [[ -f "$key_file" && ! -s "$key_file" ]]; then if [[ -f "$key_file" && ! -s "$key_file" ]]; then
rm -f "$key_file" 2>/dev/null rm -f "$key_file" 2>/dev/null
fi fi
# Build the menu. When a keyfile is already present the operator # ── Step 1: yes/no ─────────────────────────────────────────
# can Use it as-is; otherwise the choice is Generate / Import / if ! dialog --backtitle "ProxMenux" --title "$(hb_translate "Encryption")" \
# Skip. Same shape both times so muscle memory works. --yesno "$(hb_translate "Do you want to encrypt this backup?")" 8 60; then
local -a menu_items=() # No → continue without encryption. `HB_PBS_KEYFILE_OPT` stays
if [[ -s "$key_file" ]]; then # empty; the runner will call proxmox-backup-client without
menu_items+=("existing" "$(hb_translate "Use existing keyfile at") $key_file") # --keyfile.
return 0
fi fi
menu_items+=(
"new" "$(hb_translate "Generate a new keyfile (one per host — safest isolation)")" # ── Step 2a: keyfile already present → reuse silently ─────
"import" "$(hb_translate "Import an existing keyfile from a path (shared across hosts)")" if [[ -s "$key_file" ]]; then
"none" "$(hb_translate "Skip — no encryption for this backup")" HB_PBS_KEYFILE_OPT="--keyfile $key_file"
) msg_ok "$(hb_translate "Using existing encryption key:") $key_file"
local menu_h=14 return 0
(( ${#menu_items[@]} > 8 )) && menu_h=16 fi
# ── Step 2b: no keyfile → let the operator generate or import ─
local choice local choice
choice=$(dialog --backtitle "ProxMenux" --title "$(hb_translate "Encryption")" \ choice=$(dialog --backtitle "ProxMenux" --title "$(hb_translate "Encryption key")" \
--menu "$(hb_translate "Choose how to protect this backup:")" \ --menu "$(hb_translate "No encryption key is stored on this host. Choose how to set one up:")" \
"$menu_h" 78 4 "${menu_items[@]}" \ 12 78 2 \
3>&1 1>&2 2>&3) || return 0 "new" "$(hb_translate "Generate a new keyfile (one per host — safest isolation)")" \
"import" "$(hb_translate "Import an existing keyfile from a path (shared across hosts)")" \
3>&1 1>&2 2>&3) || return 1 # Cancel → abort backup.
case "$choice" in case "$choice" in
existing)
# An existing keyfile is trusted — it only lands here after
# a previous encrypted backup completed successfully (the
# create / import paths wipe the file on cancel).
HB_PBS_KEYFILE_OPT="--keyfile $key_file"
msg_ok "$(hb_translate "Using existing encryption key:") $key_file"
return 0
;;
new) new)
# Operator asked to Replace or first-time Generate — wipe
# any prior keyfile + recovery blob so the create path
# starts from a clean slate.
if [[ -s "$key_file" ]]; then
rm -f "$key_file" "$recovery_enc" 2>/dev/null
fi
_hb_pbs_create_new_keyfile _hb_pbs_create_new_keyfile
return $? return $?
;; ;;
import) import)
if [[ -s "$key_file" ]]; then
rm -f "$key_file" "$recovery_enc" 2>/dev/null
fi
_hb_pbs_import_dialog _hb_pbs_import_dialog
return $? return $?
;; ;;
*) *)
# "none" or unknown → no encryption for this backup. # Empty selection from dialog — treat as cancel.
return 0 return 1
;; ;;
esac esac
} }
@@ -2446,6 +2475,15 @@ hb_prompt_dest_dir() {
} }
hb_prompt_restore_source_dir() { hb_prompt_restore_source_dir() {
# Same fd-9 trick used by hb_prompt_mounted_path: this function
# is called as `dir=$(hb_prompt_restore_source_dir)` which
# captures stdout, so any --msgbox we open inside would render
# into the subshell (invisible to the operator) and the caller
# would think the flow "hung". Stash real stdout in fd 9,
# redirect stdout to the tty for every dialog, and emit the
# actual return value through fd 9 at the end.
exec 9>&1 >/dev/tty
local choice out local choice out
choice=$(dialog --backtitle "ProxMenux" \ choice=$(dialog --backtitle "ProxMenux" \
@@ -2470,11 +2508,16 @@ hb_prompt_restore_source_dir() {
esac esac
out=$(hb_trim_dialog_value "$out") out=$(hb_trim_dialog_value "$out")
[[ -n "$out" && -d "$out" ]] || { if [[ -z "$out" || ! -d "$out" ]]; then
msg_error "$(hb_translate "Directory does not exist.")" dialog --backtitle "ProxMenux" \
--title "$(hb_translate "Directory not found")" \
--msgbox "$(hb_translate "The selected path does not exist on this host:")"$'\n\n'"${out:-<empty>}" \
10 78 || true
return 1 return 1
} fi
echo "$out" # Emit the real return value through the saved fd 9 — the caller's
# `$(...)` capture reads from fd 9 (see the exec at the top).
echo "$out" >&9
} }
# Return the set of scheduler job_ids that currently have a .env on # Return the set of scheduler job_ids that currently have a .env on
@@ -2524,6 +2567,13 @@ hb_is_host_backup_archive() {
} }
hb_prompt_local_archive() { hb_prompt_local_archive() {
# See hb_prompt_restore_source_dir above for why we do the fd-9
# trick — same story: caller uses `archive=$(hb_prompt_local_archive)`
# so any --msgbox inside would render into the subshell and
# never reach the operator. Force dialog output to the tty and
# emit the selected archive path via fd 9 at the end.
exec 9>&1 >/dev/tty
local base_dir="$1" local base_dir="$1"
local title="${2:-$(hb_translate "Select backup archive")}" local title="${2:-$(hb_translate "Select backup archive")}"
local -a rows=() files=() menu=() local -a rows=() files=() menu=()
@@ -2592,7 +2642,9 @@ hb_prompt_local_archive() {
--menu "$menu_prompt" \ --menu "$menu_prompt" \
"$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" "${menu[@]}" 3>&1 1>&2 2>&3) || return 1 "$HB_UI_MENU_H" "$HB_UI_MENU_W" "$HB_UI_MENU_LIST" "${menu[@]}" 3>&1 1>&2 2>&3) || return 1
echo "${files[$((choice-1))]}" # Emit the selected archive path through fd 9 so the caller's
# `$(...)` capture actually receives it (see the exec at the top).
echo "${files[$((choice-1))]}" >&9
} }
# ========================================================== # ==========================================================
@@ -27,6 +27,9 @@ else
exit 1 exit 1
fi fi
load_language
initialize_cache
LIB_FILE="$SCRIPT_DIR/lib_host_backup_common.sh" LIB_FILE="$SCRIPT_DIR/lib_host_backup_common.sh"
[[ ! -f "$LIB_FILE" ]] && LIB_FILE="$LOCAL_SCRIPTS_DEFAULT/backup_restore/lib_host_backup_common.sh" [[ ! -f "$LIB_FILE" ]] && LIB_FILE="$LOCAL_SCRIPTS_DEFAULT/backup_restore/lib_host_backup_common.sh"
if [[ -f "$LIB_FILE" ]]; then if [[ -f "$LIB_FILE" ]]; then
@@ -516,15 +519,15 @@ main() {
archive_path=$(grep "^LOCAL_ARCHIVE=" <<<"$_output" | cut -d'=' -f2-) archive_path=$(grep "^LOCAL_ARCHIVE=" <<<"$_output" | cut -d'=' -f2-)
;; ;;
borg) borg)
(( TTY )) && { echo; msg_info "$(translate "Sending snapshot to Borg repository...")"; stop_spinner; } (( TTY )) && { echo; msg_info "$(translate "Sending backup to Borg repository...")"; stop_spinner; }
echo "Sending snapshot to Borg repository ${BORG_REPO:-} ..." >>"$log_file" echo "Sending backup to Borg repository ${BORG_REPO:-} ..." >>"$log_file"
_sb_run_borg "$stage_root" "${job_id}-${ts}" >>"$log_file" 2>&1 _sb_run_borg "$stage_root" "${job_id}-${ts}" >>"$log_file" 2>&1
rc=$? rc=$?
archive_path="${BORG_REPO:-}::${job_id}-${ts}" archive_path="${BORG_REPO:-}::${job_id}-${ts}"
;; ;;
pbs) pbs)
(( TTY )) && { echo; msg_info "$(translate "Sending snapshot to PBS...")"; stop_spinner; } (( TTY )) && { echo; msg_info "$(translate "Sending backup to PBS...")"; stop_spinner; }
echo "Sending snapshot to PBS ${PBS_REPOSITORY:-} (id=${PBS_BACKUP_ID:-hostcfg-$(hostname)}) ..." >>"$log_file" echo "Sending backup to PBS ${PBS_REPOSITORY:-} (id=${PBS_BACKUP_ID:-hostcfg-$(hostname)}) ..." >>"$log_file"
_sb_run_pbs "$stage_root" "${PBS_BACKUP_ID:-hostcfg-$(hostname)}" "$(date +%s)" >>"$log_file" 2>&1 _sb_run_pbs "$stage_root" "${PBS_BACKUP_ID:-hostcfg-$(hostname)}" "$(date +%s)" >>"$log_file" 2>&1
rc=$? rc=$?
archive_path="${PBS_REPOSITORY:-}::host/${PBS_BACKUP_ID:-hostcfg-$(hostname)}" archive_path="${PBS_REPOSITORY:-}::host/${PBS_BACKUP_ID:-hostcfg-$(hostname)}"