mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-07-26 18:38:30 +00:00
update 1.2.2.2 beta
This commit is contained in:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user