update 1.2.2.2 beta

This commit is contained in:
MacRimi
2026-07-03 18:59:45 +02:00
parent eb67fb9bd9
commit 35cb10ff44
8 changed files with 881 additions and 504 deletions

View File

@@ -1 +1 @@
900287e54a05641290f221245a504013e106abc955cdf009a03361e274c55770
1221dec8316a1292bbeaec68dd06fe0b385a01bdc649fa24f52ef99fec0d4078

View File

@@ -992,6 +992,24 @@ function InspectModal({
stagingPath: string
pathsAvailable: string[]
rollbackPlan?: RollbackPlan
// Cross-kernel awareness passed through to RestoreOptionsModal:
// when direction === "bk_older" the modal shows the safe-subset
// banner and grays out any path prefixed by anything in
// blockedPaths. "bk_newer" and "same" pass through as no-ops.
crossKernel?: {
direction: "same" | "bk_older" | "bk_newer"
backupKernel: string
targetKernel: string
blockedPaths: string[]
}
// Kernel-agnostic hydration preview (bk_older only): what the
// restore will re-apply to the target via merge instead of
// whole-file copy — IOMMU cmdline tokens, VFIO modules,
// operator's GRUB keys, whitelisted vfio/nvidia files.
hydration?: {
applies: boolean
actions: string[]
}
} | null>(null)
const [restoreTerminal, setRestoreTerminal] = useState<{
stagingPath: string
@@ -999,27 +1017,6 @@ function InspectModal({
paths: string[]
rollbackExecute?: boolean
} | null>(null)
// Cross-kernel modal state:
// - refuse: backup kernel is neither installed nor available in
// Proxmox repos. Restore cannot proceed cleanly; the modal
// mirrors the CLI msgbox and tells the operator what to do.
// - notice: backup kernel is installed (or installable). Restore
// will proceed but the operator sees a plain heads-up so they
// know why the flow will pin a different kernel + reboot.
const [crossKernelRefuse, setCrossKernelRefuse] = useState<{
backupKernel: string
targetKernel: string
reason: string
} | null>(null)
const [crossKernelNotice, setCrossKernelNotice] = useState<{
backupKernel: string
targetKernel: string
action: "installed" | "installable"
pkg: string
stagingPath: string
pathsAvailable: string[]
rollbackPlan?: RollbackPlan
} | null>(null)
const beginRestore = async () => {
if (!archive) return
@@ -1045,44 +1042,25 @@ function InspectModal({
})
if (!r?.staging_path) throw new Error("backend did not return a staging path")
const ck = r.cross_kernel || {}
// Refuse path: kernel unreachable, do not open the restore
// options dialog. The operator sees the modal explaining why.
if (ck?.detected && ck?.action === "unavailable") {
setCrossKernelRefuse({
backupKernel: ck.backup_kernel || "",
targetKernel: ck.target_kernel || "",
reason: ck.reason || "unknown",
})
// Clean up the staging tree the backend just created — the
// restore is not going to run against it.
try {
await fetchApi("/api/host-backups/restore/cleanup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ staging_path: r.staging_path }),
})
} catch { /* best effort */ }
return
}
// Notice path: heads-up modal, then continue to the standard
// restore options once the operator acknowledges.
if (ck?.detected && (ck?.action === "installed" || ck?.action === "installable")) {
setCrossKernelNotice({
backupKernel: ck.backup_kernel || "",
targetKernel: ck.target_kernel || "",
action: ck.action,
pkg: ck.pkg || "",
stagingPath: r.staging_path,
pathsAvailable: Array.isArray(r.paths_available) ? r.paths_available : [],
rollbackPlan: r.rollback_plan,
})
return
}
// Same-kernel: straight to the options dialog.
const hyd = r.hydration || {}
setRestoreOptions({
stagingPath: r.staging_path,
pathsAvailable: Array.isArray(r.paths_available) ? r.paths_available : [],
rollbackPlan: r.rollback_plan,
crossKernel: ck.direction
? {
direction: ck.direction,
backupKernel: ck.backup_kernel || "",
targetKernel: ck.target_kernel || "",
blockedPaths: Array.isArray(ck.blocked_paths) ? ck.blocked_paths : [],
}
: undefined,
hydration: hyd.applies
? {
applies: true,
actions: Array.isArray(hyd.actions) ? hyd.actions : [],
}
: undefined,
})
} catch (e) {
setRestoreError(e instanceof Error ? e.message : String(e))
@@ -1540,131 +1518,6 @@ function InspectModal({
</Dialog>
)}
{/* ── Cross-kernel refuse modal ───────────────────────────
Backend detected the backup was taken on a kernel major.minor
different from the target's, and that kernel is neither
installed nor pullable from Proxmox repos. Restoring anyway
would leave drivers/DKMS pinned to a kernel that isn't on
the box; we refuse and tell the operator what to do. Copy
mirrors the CLI msgbox in backup_host.sh so a user reading
both surfaces gets the exact same instructions. */}
{crossKernelRefuse && (
<Dialog open={true} onOpenChange={(o) => !o && setCrossKernelRefuse(null)}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-red-500">
<AlertTriangle className="h-5 w-5" />
Restore refused incompatible kernel
</DialogTitle>
</DialogHeader>
<div className="space-y-3 text-sm">
<p>
This backup was taken on kernel{" "}
<code className="font-mono text-red-300">{crossKernelRefuse.backupKernel}</code>.
</p>
<p>
That kernel is <span className="font-medium">{crossKernelRefuse.reason}</span>{" "}
(current host runs{" "}
<code className="font-mono">{crossKernelRefuse.targetKernel}</code>).
</p>
<p>
Restoring without a matching kernel would leave drivers and modules built against a
kernel that is not present on this host the system may fail to boot or run degraded.
</p>
<div className="rounded-md border border-border bg-muted/40 p-3 space-y-2">
<p className="font-medium">How to proceed</p>
<ul className="list-disc pl-5 space-y-1 text-xs">
<li>Install Proxmox VE from an ISO whose kernel matches the backup.</li>
<li>
Or fetch the kernel manually:{" "}
<code className="font-mono">
apt install proxmox-kernel-{crossKernelRefuse.backupKernel}
</code>
</li>
<li>Then relaunch the restore from this dialog.</li>
</ul>
</div>
</div>
<DialogFooter>
<Button onClick={() => setCrossKernelRefuse(null)}>Close</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
{/* ── Cross-kernel notice modal ──────────────────────────
Backend detected cross-kernel but the correct kernel is
available (already installed or pullable). The restore
flow will handle the pin + reboot automatically; this
just tells the operator what to expect before they see
the standard restore options dialog. */}
{crossKernelNotice && (
<Dialog open={true} onOpenChange={(o) => !o && setCrossKernelNotice(null)}>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-blue-400">
<AlertTriangle className="h-5 w-5" />
Cross-kernel restore
</DialogTitle>
</DialogHeader>
<div className="space-y-3 text-sm">
<p>
This backup was taken on kernel{" "}
<code className="font-mono">{crossKernelNotice.backupKernel}</code>. The host
currently runs <code className="font-mono">{crossKernelNotice.targetKernel}</code>.
</p>
{crossKernelNotice.action === "installed" ? (
<p>
<code className="font-mono">{crossKernelNotice.pkg}</code> is already installed on
this host. The restore will pin it as the default boot kernel and reboot into it
once so DKMS drivers are rebuilt against the correct ABI.
</p>
) : (
<p>
<code className="font-mono">{crossKernelNotice.pkg}</code> is available from
Proxmox repos. The restore will install it, pin it as the default boot kernel and
reboot into it once so DKMS drivers are rebuilt against the correct ABI.
</p>
)}
<p className="text-xs text-muted-foreground">
An extra reboot will happen automatically as part of the restore. You do not need to
do anything.
</p>
</div>
<DialogFooter>
<Button
variant="ghost"
onClick={() => {
const sp = crossKernelNotice.stagingPath
setCrossKernelNotice(null)
if (sp) {
fetchApi("/api/host-backups/restore/cleanup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ staging_path: sp }),
}).catch(() => undefined)
}
}}
>
Cancel
</Button>
<Button
onClick={() => {
setRestoreOptions({
stagingPath: crossKernelNotice.stagingPath,
pathsAvailable: crossKernelNotice.pathsAvailable,
rollbackPlan: crossKernelNotice.rollbackPlan,
})
setCrossKernelNotice(null)
}}
>
Understood continue
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
{/* ── Restore options modal: opens only AFTER the prepare step,
so the Custom checklist already knows what's inside. ──── */}
<RestoreOptionsModal
@@ -1672,6 +1525,8 @@ function InspectModal({
stagingPath={restoreOptions?.stagingPath ?? ""}
pathsAvailable={restoreOptions?.pathsAvailable ?? []}
rollbackPlan={restoreOptions?.rollbackPlan}
crossKernel={restoreOptions?.crossKernel}
hydration={restoreOptions?.hydration}
display_id={archive?.display_id}
onClose={() => {
const sp = restoreOptions?.stagingPath
@@ -7485,6 +7340,8 @@ function RestoreOptionsModal({
stagingPath,
pathsAvailable,
rollbackPlan,
crossKernel,
hydration,
display_id,
onLaunch,
}: {
@@ -7493,9 +7350,27 @@ function RestoreOptionsModal({
stagingPath: string
pathsAvailable: string[]
rollbackPlan?: RollbackPlan
crossKernel?: {
direction: "same" | "bk_older" | "bk_newer"
backupKernel: string
targetKernel: string
blockedPaths: string[]
}
hydration?: {
applies: boolean
actions: string[]
}
display_id?: string
onLaunch: (mode: "full" | "custom", paths: string[], rollbackExecute?: boolean) => void
}) {
// When direction === "bk_older" the CLI-side filter (RS_SKIP_PATHS)
// will drop these prefixes from any restore. We mirror that
// behavior in the picker so the operator can see what will be
// skipped rather than being surprised at run time.
const isBkOlder = crossKernel?.direction === "bk_older"
const blockedPrefixes = isBkOlder ? (crossKernel?.blockedPaths ?? []) : []
const isPathBlocked = (p: string) =>
blockedPrefixes.some((b) => p === b || p.startsWith(b.endsWith("/") ? b : `${b}/`))
const [step, setStep] = useState<"choose" | "custom">("choose")
const [selected, setSelected] = useState<Set<string>>(new Set())
const [error, setError] = useState<string | null>(null)
@@ -7524,6 +7399,7 @@ function RestoreOptionsModal({
}, [open])
const togglePath = (p: string) => {
if (isPathBlocked(p)) return
setSelected((prev) => {
const next = new Set(prev)
if (next.has(p)) next.delete(p)
@@ -7533,16 +7409,18 @@ function RestoreOptionsModal({
}
const toggleAll = () => {
if (selected.size === filteredPaths.length) {
const selectable = filteredPaths.filter((p) => !isPathBlocked(p))
if (selected.size === selectable.length) {
setSelected(new Set())
} else {
setSelected(new Set(filteredPaths))
setSelected(new Set(selectable))
}
}
const filteredPaths = filter
? pathsAvailable.filter((p) => p.toLowerCase().includes(filter.toLowerCase()))
: pathsAvailable
const selectableCount = filteredPaths.filter((p) => !isPathBlocked(p)).length
const launch = (mode: "full" | "custom") => {
if (mode === "custom" && selected.size === 0) {
@@ -7568,6 +7446,56 @@ function RestoreOptionsModal({
{step === "choose" && (
<div className="space-y-3">
{isBkOlder && (
<div className="rounded-md border border-amber-500/50 bg-amber-500/5 p-3">
<div className="text-[11px] font-semibold uppercase tracking-wider text-amber-400 flex items-center gap-1.5">
<AlertTriangle className="h-3.5 w-3.5" />
Cross-kernel restore safe subset only
</div>
<div className="text-[11px] text-muted-foreground mt-2 space-y-1.5">
<div>
Backup kernel <code className="font-mono">{crossKernel?.backupKernel}</code> is older than target kernel <code className="font-mono">{crossKernel?.targetKernel}</code>.
</div>
<div>
Both Complete and Custom restore will automatically skip kernel/boot-tied paths to avoid a kernel panic on next boot. Everything else (VMs, LXCs, network, components, custom paths) restores normally.
</div>
{blockedPrefixes.length > 0 && (
<details className="mt-1.5">
<summary className="cursor-pointer text-amber-400/90 hover:text-amber-300">
Paths that will be skipped ({blockedPrefixes.length})
</summary>
<ul className="mt-1.5 pl-3 space-y-0.5 max-h-40 overflow-auto">
{blockedPrefixes.map((p) => (
<li key={p} className="font-mono text-[10.5px] text-muted-foreground/80 break-all">{p}</li>
))}
</ul>
</details>
)}
</div>
</div>
)}
{isBkOlder && hydration?.applies && hydration.actions.length > 0 && (
<div className="rounded-md border border-emerald-500/50 bg-emerald-500/5 p-3">
<div className="text-[11px] font-semibold uppercase tracking-wider text-emerald-400 flex items-center gap-1.5">
<CheckCircle2 className="h-3.5 w-3.5" />
Operator config re-applied via kernel-agnostic merge
</div>
<div className="text-[11px] text-muted-foreground mt-2 space-y-1.5">
<div>
The following operator-authored settings will be merged into the target automatically, without touching kernel-tied defaults. Runs on both Complete and Custom restore.
</div>
<ul className="mt-1.5 pl-3 space-y-0.5 max-h-40 overflow-auto">
{hydration.actions.map((a, i) => (
<li key={`${a}-${i}`} className="text-[10.5px] text-emerald-300/90 break-all">
<span className="text-emerald-500 mr-1"></span>{a}
</li>
))}
</ul>
</div>
</div>
)}
{/* Surface destructive deltas BEFORE the operator picks
Complete. Custom-by-paths can't trigger these
(paths-only restore doesn't touch the guest list or
@@ -7666,29 +7594,44 @@ function RestoreOptionsModal({
onClick={toggleAll}
className="h-8 text-xs whitespace-nowrap"
>
{selected.size === filteredPaths.length && filteredPaths.length > 0 ? "Clear" : "All"}
{selected.size === selectableCount && selectableCount > 0 ? "Clear" : "All"}
</Button>
</div>
{isBkOlder && (
<div className="text-[10.5px] text-amber-400/90 px-2 py-1.5 rounded border border-amber-500/30 bg-amber-500/5">
Kernel/boot-tied paths are grayed out and cannot be selected target runs a newer kernel.
</div>
)}
<div className="rounded-md border border-border bg-background/40 p-1 max-h-72 overflow-auto">
<ul className="divide-y divide-border/40">
{filteredPaths.map((p) => (
<li key={p}>
<label className="flex items-center gap-2 px-2 py-1 hover:bg-white/5 cursor-pointer">
<Checkbox
checked={selected.has(p)}
onCheckedChange={() => togglePath(p)}
/>
<code className="font-mono text-xs break-all flex-1">{p}</code>
</label>
</li>
))}
{filteredPaths.map((p) => {
const blocked = isPathBlocked(p)
return (
<li key={p}>
<label
className={`flex items-center gap-2 px-2 py-1 ${blocked ? "opacity-40 cursor-not-allowed" : "hover:bg-white/5 cursor-pointer"}`}
title={blocked ? "Skipped: kernel-tied path, target runs a newer kernel" : undefined}
>
<Checkbox
checked={!blocked && selected.has(p)}
disabled={blocked}
onCheckedChange={() => togglePath(p)}
/>
<code className={`font-mono text-xs break-all flex-1 ${blocked ? "line-through" : ""}`}>{p}</code>
{blocked && (
<span className="text-[10px] text-amber-400/80 font-normal shrink-0">skipped</span>
)}
</label>
</li>
)
})}
{filteredPaths.length === 0 && (
<li className="text-[11px] text-muted-foreground italic px-2 py-2">No paths match the filter.</li>
)}
</ul>
</div>
<div className="text-[11px] text-muted-foreground">
Selected: {selected.size} / {pathsAvailable.length}{filter && filteredPaths.length !== pathsAvailable.length ? ` (${filteredPaths.length} shown)` : ""}
Selected: {selected.size} / {isBkOlder ? selectableCount : pathsAvailable.length}{filter && filteredPaths.length !== pathsAvailable.length ? ` (${filteredPaths.length} shown)` : ""}
</div>
{error && (
<div className="text-xs text-red-400 px-3 py-2 rounded-md border border-red-500/30 bg-red-500/10">

View File

@@ -16905,27 +16905,113 @@ def api_host_backups_restore_prepare():
except (subprocess.TimeoutExpired, OSError, json.JSONDecodeError, ValueError):
pass
# Cross-kernel plan: shared with the CLI menu via
# kernel_pin_plan.sh so both surfaces refuse the same restores.
# `detected=true` + `action=unavailable` is what the frontend
# treats as a hard refuse: it shows the "install a kernel first"
# modal instead of proceeding to the safety checkboxes.
cross_kernel: dict = {'detected': False}
kernel_plan_script = f'{_PROXMENUX_SCRIPTS_DIR}/backup_restore/restore/kernel_pin_plan.sh'
if os.path.isfile(kernel_plan_script):
# Direction-aware cross-kernel signal for the Web restore UI.
# We source the same logic the CLI uses: read the backup's
# kernel from the staging's run_info.env, compare major.minor
# to the target's current kernel, and classify the direction.
# "bk_older" is the only mode that trims the picker; the
# blocked list mirrors hb_unsafe_paths_cross_version so both
# surfaces skip the exact same set. "bk_newer" and "same" send
# empty lists and the Web treats them as a normal restore.
cross_kernel: dict = {
'direction': 'same',
'backup_kernel': '',
'target_kernel': '',
'blocked_paths': [],
}
try:
bk_kernel = ''
run_info = os.path.join(staging, 'metadata', 'run_info.env')
if os.path.isfile(run_info):
with open(run_info, 'r') as f:
for line in f:
if line.startswith('kernel='):
bk_kernel = line.strip().split('=', 1)[1]
break
try:
r = subprocess.run(['bash', kernel_plan_script, staging],
capture_output=True, text=True, timeout=90)
if r.returncode == 0 and r.stdout.strip():
cross_kernel = json.loads(r.stdout.strip())
except (subprocess.TimeoutExpired, OSError, json.JSONDecodeError, ValueError):
pass
tg_kernel = subprocess.check_output(['uname', '-r'], text=True).strip()
except (OSError, subprocess.CalledProcessError):
tg_kernel = ''
cross_kernel['backup_kernel'] = bk_kernel
cross_kernel['target_kernel'] = tg_kernel
def _mm(k: str) -> tuple:
parts = (k or '').split('.')
try:
return (int(parts[0]), int(parts[1]))
except (ValueError, IndexError):
return (0, 0)
if bk_kernel and tg_kernel:
bk_mm, tg_mm = _mm(bk_kernel), _mm(tg_kernel)
if bk_mm != tg_mm and bk_mm != (0, 0) and tg_mm != (0, 0):
if bk_mm < tg_mm:
cross_kernel['direction'] = 'bk_older'
# Pull the exact list the CLI uses.
lib_sh = f'{_PROXMENUX_SCRIPTS_DIR}/backup_restore/lib_host_backup_common.sh'
if os.path.isfile(lib_sh):
try:
r = subprocess.run(
['bash', '-c',
f'source "{lib_sh}"; hb_unsafe_paths_cross_version'],
capture_output=True, text=True, timeout=10)
if r.returncode == 0:
blocked = []
for line in r.stdout.splitlines():
parts = line.split('\t', 1)
if parts and parts[0].strip():
blocked.append(parts[0].strip())
cross_kernel['blocked_paths'] = blocked
except (subprocess.TimeoutExpired, OSError):
pass
else:
cross_kernel['direction'] = 'bk_newer'
except (OSError, ValueError):
pass
# Hydration preview — only meaningful in bk_older direction.
# Runs the same phases the CLI runs in "plan" mode: no
# writes to the live target, just the list of would-be
# actions. Same code path as the shell dialog, so the Web
# banner and the CLI msgbox always list the same items.
hydration = {'applies': False, 'actions': []}
if cross_kernel['direction'] == 'bk_older':
backup_sh = (
f'{_PROXMENUX_SCRIPTS_DIR}/backup_restore/backup_host.sh'
)
if os.path.isfile(backup_sh):
try:
# Source lib+backup_host in a subshell, set the
# direction so any inner guard passes, run the
# orchestrator in plan mode, print RS_HYDRATION_ACTIONS
# one per line. HB_LIB_ONLY/HB_NO_MAIN etc. are not
# needed: sourcing backup_host.sh only defines
# functions; nothing runs until we call one.
script = (
f'set +e; '
f'source "{_PROXMENUX_SCRIPTS_DIR}/backup_restore/lib_host_backup_common.sh"; '
f'source "{backup_sh}" >/dev/null 2>&1; '
f'HB_COMPAT_KERNEL_DIRECTION=bk_older '
f'_rs_apply_bk_older_hydration "{staging}" plan >/dev/null 2>&1; '
f'printf "%s\\n" "${{RS_HYDRATION_ACTIONS[@]}}"'
)
r = subprocess.run(
['bash', '-c', script],
capture_output=True, text=True, timeout=15)
if r.returncode == 0:
actions = [
ln.strip() for ln in r.stdout.splitlines()
if ln.strip()
]
hydration['actions'] = actions
hydration['applies'] = bool(actions)
except (subprocess.TimeoutExpired, OSError):
pass
return jsonify({
'staging_path': staging,
'paths_available': paths_available,
'rollback_plan': rollback_plan,
'cross_kernel': cross_kernel,
'hydration': hydration,
})
except Exception as e:
shutil.rmtree(staging, ignore_errors=True)