mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-08-01 13:26:21 +00:00
create 1.2.2.3 beta
This commit is contained in:
Binary file not shown.
@@ -1 +1 @@
|
|||||||
08b669193097449f8330ad430066574bf0e99f477aea97172e6b845ad4ee648c ProxMenux-1.2.2.2-beta.AppImage
|
4abe67f2d2d7516c438d222ed73fb166b0f6b3b77e85e9a6be37194b4b178cdd ProxMenux-1.2.2.3-beta.AppImage
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,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": {
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)}"
|
||||||
|
|||||||
Reference in New Issue
Block a user