mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-07-26 18:38:30 +00:00
update 1.2.4
This commit is contained in:
@@ -65,6 +65,16 @@ interface RestoreRollback {
|
||||
components_to_uninstall?: string[]
|
||||
}
|
||||
|
||||
interface DataPoolsImport {
|
||||
ok: string[]
|
||||
forced: string[]
|
||||
partial: string[]
|
||||
missing: string[]
|
||||
failed: string[]
|
||||
finished_at?: string
|
||||
log_path?: string
|
||||
}
|
||||
|
||||
interface RestoreState {
|
||||
status: "running" | "complete" | "failed"
|
||||
started_at: string
|
||||
@@ -79,6 +89,7 @@ interface RestoreState {
|
||||
summary: RestoreSummary | null
|
||||
acknowledged: boolean
|
||||
duration?: string
|
||||
data_pools_import?: DataPoolsImport
|
||||
}
|
||||
|
||||
interface HistoryEntry {
|
||||
@@ -371,6 +382,8 @@ const RestoreDetailModal: React.FC<{
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.data_pools_import && <DataPoolsBlock section={state.data_pools_import} />}
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Rollback delta</div>
|
||||
<RollbackDelta delta={state.rollback_delta} />
|
||||
@@ -392,6 +405,94 @@ const RestoreDetailModal: React.FC<{
|
||||
)
|
||||
}
|
||||
|
||||
// ── Data-pools auto-import block ──────────────────────────────
|
||||
// Rendered inside the RestoreDetailModal (and the compact header
|
||||
// badge on the card) so the operator sees which ZFS data pools
|
||||
// were imported automatically during the restore, which ones ZFS
|
||||
// took with -f because the on-disk hostid didn't match the fresh
|
||||
// install, and which ones were skipped and need manual attention.
|
||||
// The section is populated by backup_host.sh:_rs_persist_datapool_import
|
||||
// and survives the ScriptTerminalModal being closed.
|
||||
const DataPoolsBlock: React.FC<{ section: DataPoolsImport }> = ({ section }) => {
|
||||
const total =
|
||||
section.ok.length +
|
||||
section.forced.length +
|
||||
section.partial.length +
|
||||
section.missing.length +
|
||||
section.failed.length
|
||||
if (total === 0) return null
|
||||
|
||||
const Row: React.FC<{
|
||||
label: string
|
||||
tone: "ok" | "warn" | "info" | "error"
|
||||
items: string[]
|
||||
help?: string
|
||||
}> = ({ label, tone, items, help }) => {
|
||||
if (items.length === 0) return null
|
||||
const toneClass =
|
||||
tone === "ok"
|
||||
? "text-emerald-400"
|
||||
: tone === "warn"
|
||||
? "text-amber-400"
|
||||
: tone === "error"
|
||||
? "text-red-400"
|
||||
: "text-blue-400"
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-muted/30 px-3 py-2 text-xs">
|
||||
<div className={`font-medium ${toneClass} flex items-center gap-2`}>
|
||||
{tone === "ok" && <CheckCircle2 className="h-3.5 w-3.5" />}
|
||||
{tone === "warn" && <AlertTriangle className="h-3.5 w-3.5" />}
|
||||
{tone === "error" && <XCircle className="h-3.5 w-3.5" />}
|
||||
{tone === "info" && <CheckCircle2 className="h-3.5 w-3.5" />}
|
||||
<span>{label}</span>
|
||||
<span className="text-muted-foreground">({items.length})</span>
|
||||
</div>
|
||||
<div className="mt-1 font-mono text-muted-foreground break-all">{items.join(", ")}</div>
|
||||
{help && <div className="mt-1 text-muted-foreground">{help}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium flex items-center gap-2">
|
||||
<Cpu className="h-4 w-4" />
|
||||
ZFS data pools — auto-import
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Row label="Imported" tone="ok" items={section.ok} />
|
||||
<Row
|
||||
label="Imported (forced, foreign hostid)"
|
||||
tone="info"
|
||||
items={section.forced}
|
||||
help="New hostid grabbed onto the pool label — next boot imports clean."
|
||||
/>
|
||||
<Row
|
||||
label="Skipped (some disks missing)"
|
||||
tone="warn"
|
||||
items={section.partial}
|
||||
help="Some vdev disks weren't found by /dev/disk/by-id. Pool NOT imported to avoid a degraded auto-import. Fix the disks or import manually with zpool import."
|
||||
/>
|
||||
<Row
|
||||
label="Skipped (no disks present)"
|
||||
tone="warn"
|
||||
items={section.missing}
|
||||
help="None of the pool's disks are on this host. Move the disks over or import from a different host."
|
||||
/>
|
||||
<Row
|
||||
label="Import failed"
|
||||
tone="error"
|
||||
items={section.failed}
|
||||
help="ZFS rejected the import even with -f. Inspect with `zpool import` and the log below."
|
||||
/>
|
||||
</div>
|
||||
{section.log_path && (
|
||||
<div className="text-xs text-muted-foreground font-mono">Log: {section.log_path}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── History browser modal ─────────────────────────────────────
|
||||
|
||||
const RestoreHistoryModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, onClose }) => {
|
||||
@@ -510,6 +611,14 @@ export const RestoreProgressCard: React.FC = () => {
|
||||
}
|
||||
|
||||
const hasWarnings = state.sanity_warnings.length > 0
|
||||
const pools = state.data_pools_import
|
||||
const poolCount =
|
||||
(pools?.ok.length ?? 0) +
|
||||
(pools?.forced.length ?? 0) +
|
||||
(pools?.partial.length ?? 0) +
|
||||
(pools?.missing.length ?? 0) +
|
||||
(pools?.failed.length ?? 0)
|
||||
const poolWarnings = (pools?.partial.length ?? 0) + (pools?.missing.length ?? 0) + (pools?.failed.length ?? 0)
|
||||
const barColor =
|
||||
state.status === "failed" ? "bg-red-500" : state.status === "complete" ? "bg-emerald-500" : "bg-blue-500"
|
||||
|
||||
@@ -530,6 +639,20 @@ export const RestoreProgressCard: React.FC = () => {
|
||||
{state.sanity_warnings.length} boot warning{state.sanity_warnings.length === 1 ? "" : "s"}
|
||||
</Badge>
|
||||
)}
|
||||
{poolCount > 0 && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
poolWarnings > 0
|
||||
? "text-amber-400 border-amber-500/40 bg-amber-500/10 gap-1"
|
||||
: "text-emerald-400 border-emerald-500/40 bg-emerald-500/10 gap-1"
|
||||
}
|
||||
>
|
||||
<Cpu className="h-3 w-3" />
|
||||
{poolCount} ZFS pool{poolCount === 1 ? "" : "s"}
|
||||
{poolWarnings > 0 && ` · ${poolWarnings} need attention`}
|
||||
</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => setDetailOpen(true)}>
|
||||
|
||||
@@ -51,11 +51,18 @@ This release adds two in-dashboard improvements — a one-click Proxmox update t
|
||||
|
||||
---
|
||||
|
||||
## 🗄 Backup restore — ZFS data pools imported automatically
|
||||
|
||||
- **Separate ZFS data pools listed in the backup are now imported automatically at restore time.** Previously the restore flow correctly protected the root pool (rpool) from a stale `zpool.cache`, but any other ZFS pool a user had (for VMs, LXCs or plain data) was left up to `zfs-import-scan.service` to import at next boot — which fails when the fresh install has a different `hostid` than the one written on the pool's on-disk label. The pool would show as `FAILED Failed to start zfs-import-scan.service` and stay unavailable until the user ran `zpool import -f` manually. Now the restore iterates the backup's `storage_inventory.zfs_pools[]`, skips the root pool (already imported by the system), and runs `zpool import <name>` for every non-root pool whose disks are all present on `/dev/disk/by-id/`. If ZFS rejects the import as *foreign*, it retries with `-f` and reports the pool as forced so the operator knows the new hostid was grabbed onto the label. Pools with any missing disks are skipped with a clear warning instead of a silent import that would rebuild the pool without the missing vdev.
|
||||
|
||||
---
|
||||
|
||||
## 🙏 Acknowledgments
|
||||
|
||||
- **@pepenai** — reported the mobile dashboard freeze on his HTTPS + reverse proxy setup that led to the Service Worker cleanup path.
|
||||
- **Pepo** — reported the `401 missing_timestamp` on his PVE Test button that led to the webhook self-IP allowlist and the v4-mapped fix.
|
||||
- **@ash34** (#255) — reported the `VM/CT check unavailable: 'NoneType' object has no attribute 'get'` that led to the defensive coalescing of the `details` column.
|
||||
- **Juan C.** — reported the post-restore `zfs-import-scan.service FAILED` on a fresh install with a separate data pool, which led to the auto-import step above.
|
||||
|
||||
|
||||
## 2026-07-14
|
||||
|
||||
@@ -3397,6 +3397,219 @@ _rs_run_custom_restore() {
|
||||
# and missing on this host, regardless of strategy — installing
|
||||
# user-installed packages is a prerequisite for the restored
|
||||
# systemd units and config files to actually do anything.
|
||||
|
||||
# ─── _rs_import_data_pools ─────────────────────────────────────
|
||||
# Imports the ZFS data pools that were present in the backup but
|
||||
# aren't yet imported on this host — typical scenario is a fresh
|
||||
# PVE install onto new boot disks while the data-pool disks stay
|
||||
# in place. Never touches the root pool (already imported by the
|
||||
# system), never forces an import that's missing disks. Retries
|
||||
# with -f when the pool imports as foreign (hostid mismatch after
|
||||
# a fresh install regrabs the label) and reports which pools were
|
||||
# forced so the operator has traceability.
|
||||
_rs_import_data_pools() {
|
||||
local staging_root="$1"
|
||||
local manifest="$staging_root/manifest.json"
|
||||
[[ -f "$manifest" ]] || return 0
|
||||
command -v zpool >/dev/null 2>&1 || return 0
|
||||
command -v jq >/dev/null 2>&1 || return 0
|
||||
|
||||
# Root pool → skip (mounted at / by the system).
|
||||
local root_pool=""
|
||||
if command -v zfs >/dev/null 2>&1; then
|
||||
local root_dataset
|
||||
root_dataset=$(zfs get -Ho value name / 2>/dev/null | head -1)
|
||||
[[ "$root_dataset" == "-" ]] && root_dataset=""
|
||||
root_pool="${root_dataset%%/*}"
|
||||
fi
|
||||
|
||||
# Live pools already imported.
|
||||
local live_pools
|
||||
live_pools=$(zpool list -H -o name 2>/dev/null || true)
|
||||
|
||||
local -a ok=() forced=() partial=() missing=() failed=()
|
||||
local pool_name
|
||||
|
||||
while IFS= read -r pool_name; do
|
||||
[[ -z "$pool_name" ]] && continue
|
||||
[[ "$pool_name" == "$root_pool" ]] && continue
|
||||
if grep -qFx "$pool_name" <<<"$live_pools"; then
|
||||
continue
|
||||
fi
|
||||
|
||||
local devs_all devs_total=0 devs_present=0 devs_missing=0 dev
|
||||
devs_all=$(jq -r --arg n "$pool_name" \
|
||||
'.storage_inventory.zfs_pools[]? | select(.name==$n) | .devices_by_id[]?' \
|
||||
"$manifest" 2>/dev/null)
|
||||
[[ -z "$devs_all" ]] && continue
|
||||
while IFS= read -r dev; do
|
||||
[[ -z "$dev" ]] && continue
|
||||
((devs_total++))
|
||||
if [[ -e "/dev/disk/by-id/$dev" ]]; then
|
||||
((devs_present++))
|
||||
else
|
||||
((devs_missing++))
|
||||
fi
|
||||
done <<<"$devs_all"
|
||||
|
||||
if (( devs_present == 0 )); then
|
||||
missing+=("$pool_name")
|
||||
continue
|
||||
fi
|
||||
if (( devs_missing > 0 )); then
|
||||
partial+=("$pool_name (${devs_present}/${devs_total} $(translate 'disks present'))")
|
||||
continue
|
||||
fi
|
||||
|
||||
if zpool import "$pool_name" 2>/dev/null; then
|
||||
ok+=("$pool_name")
|
||||
elif zpool import -f "$pool_name" 2>/dev/null; then
|
||||
forced+=("$pool_name")
|
||||
else
|
||||
failed+=("$pool_name")
|
||||
fi
|
||||
done < <(jq -r '.storage_inventory.zfs_pools[]?.name' "$manifest" 2>/dev/null)
|
||||
|
||||
if (( ${#ok[@]} == 0 && ${#forced[@]} == 0 && ${#partial[@]} == 0 && \
|
||||
${#missing[@]} == 0 && ${#failed[@]} == 0 )); then
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo
|
||||
msg_info "$(translate 'Auto-importing ZFS data pools from backup...')"
|
||||
stop_spinner
|
||||
if (( ${#ok[@]} > 0 )); then
|
||||
msg_ok "$(translate 'Imported:') ${ok[*]}"
|
||||
fi
|
||||
if (( ${#forced[@]} > 0 )); then
|
||||
msg_ok "$(translate 'Imported (foreign hostid, forced):') ${forced[*]}"
|
||||
echo -e "${TAB}${BL}$(translate "New hostid grabbed onto the pool label — next boot imports clean.")${CL}"
|
||||
fi
|
||||
if (( ${#partial[@]} > 0 )); then
|
||||
msg_warn "$(translate 'Skipped (some disks missing):') ${partial[*]}"
|
||||
fi
|
||||
if (( ${#missing[@]} > 0 )); then
|
||||
msg_warn "$(translate 'Skipped (no disks of the pool are present on this host):') ${missing[*]}"
|
||||
fi
|
||||
if (( ${#failed[@]} > 0 )); then
|
||||
msg_error "$(translate 'Import failed — inspect with `zpool import`:') ${failed[*]}"
|
||||
fi
|
||||
|
||||
# Persist so the post-restore card can show the summary after the
|
||||
# reboot. The Monitor's ScriptTerminalModal loses its buffer on
|
||||
# close, and monitor_apply.sh doesn't tee to disk — without this
|
||||
# the operator has no trace once they dismiss the terminal.
|
||||
_rs_persist_datapool_import "${ok[@]}" "|FORCED|" "${forced[@]}" \
|
||||
"|PARTIAL|" "${partial[@]}" "|MISSING|" "${missing[@]}" \
|
||||
"|FAILED|" "${failed[@]}"
|
||||
}
|
||||
|
||||
# Serialise the auto-import result to restore-state.json (Monitor UI)
|
||||
# and to a raw log under /var/log/proxmenux. The state file is the
|
||||
# same one apply_cluster_postboot.sh manages — jq `*` merge preserves
|
||||
# our section when postboot later seeds its own fields. If the file
|
||||
# doesn't exist yet (operator hasn't rebooted, or restore ran without
|
||||
# a pending-reboot step) we seed placeholder fields so the UI can
|
||||
# still render just our section without crashing on undefined keys.
|
||||
_rs_persist_datapool_import() {
|
||||
command -v jq >/dev/null 2>&1 || return 0
|
||||
local mode="OK"
|
||||
local -a ok=() forced=() partial=() missing=() failed=()
|
||||
local arg
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
"|FORCED|") mode="FORCED" ;;
|
||||
"|PARTIAL|") mode="PARTIAL" ;;
|
||||
"|MISSING|") mode="MISSING" ;;
|
||||
"|FAILED|") mode="FAILED" ;;
|
||||
*)
|
||||
case "$mode" in
|
||||
OK) ok+=("$arg") ;;
|
||||
FORCED) forced+=("$arg") ;;
|
||||
PARTIAL) partial+=("$arg") ;;
|
||||
MISSING) missing+=("$arg") ;;
|
||||
FAILED) failed+=("$arg") ;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
local state_dir="/var/lib/proxmenux"
|
||||
local state_file="$state_dir/restore-state.json"
|
||||
local log_dir="/var/log/proxmenux"
|
||||
local log_file="$log_dir/restore-datapools-$(date +%Y%m%d_%H%M%S).log"
|
||||
mkdir -p "$state_dir" "$log_dir" 2>/dev/null || true
|
||||
|
||||
# Raw log — appended one line per pool with the resolved action.
|
||||
{
|
||||
echo "=== ProxMenux data-pool auto-import at $(date -Iseconds) ==="
|
||||
local p
|
||||
for p in "${ok[@]}"; do echo "OK $p"; done
|
||||
for p in "${forced[@]}"; do echo "FORCED $p (foreign hostid, imported with -f)"; done
|
||||
for p in "${partial[@]}"; do echo "PARTIAL $p (some vdev disks missing — not imported)"; done
|
||||
for p in "${missing[@]}"; do echo "MISSING $p (no disks of the pool are present on this host)"; done
|
||||
for p in "${failed[@]}"; do echo "FAILED $p (zpool import failed — inspect manually)"; done
|
||||
} >>"$log_file" 2>/dev/null || true
|
||||
|
||||
# State-file JSON section. `jq -sR` reads each array from a
|
||||
# newline-separated stdin so pool names with spaces (partial
|
||||
# entries include a count fragment) round-trip intact.
|
||||
local ok_json forced_json partial_json missing_json failed_json section
|
||||
ok_json=$(printf '%s\n' "${ok[@]:-}" | jq -Rsc 'split("\n") | map(select(length>0))')
|
||||
forced_json=$(printf '%s\n' "${forced[@]:-}" | jq -Rsc 'split("\n") | map(select(length>0))')
|
||||
partial_json=$(printf '%s\n' "${partial[@]:-}" | jq -Rsc 'split("\n") | map(select(length>0))')
|
||||
missing_json=$(printf '%s\n' "${missing[@]:-}" | jq -Rsc 'split("\n") | map(select(length>0))')
|
||||
failed_json=$(printf '%s\n' "${failed[@]:-}" | jq -Rsc 'split("\n") | map(select(length>0))')
|
||||
|
||||
section=$(jq -n \
|
||||
--arg finished_at "$(date -Iseconds)" \
|
||||
--arg log_path "$log_file" \
|
||||
--argjson ok "$ok_json" \
|
||||
--argjson forced "$forced_json" \
|
||||
--argjson partial "$partial_json" \
|
||||
--argjson missing "$missing_json" \
|
||||
--argjson failed "$failed_json" \
|
||||
'{data_pools_import: {
|
||||
ok:$ok, forced:$forced, partial:$partial,
|
||||
missing:$missing, failed:$failed,
|
||||
finished_at:$finished_at, log_path:$log_path
|
||||
}}')
|
||||
|
||||
local tmp
|
||||
tmp=$(mktemp "${state_file}.XXXXXX") || return 0
|
||||
if [[ -f "$state_file" ]]; then
|
||||
# Merge into whatever's already there.
|
||||
if jq -c ". * $section" "$state_file" > "$tmp" 2>/dev/null; then
|
||||
mv -f "$tmp" "$state_file"
|
||||
else
|
||||
rm -f "$tmp"
|
||||
fi
|
||||
else
|
||||
# Seed with placeholders so the card renders without crashing
|
||||
# if the operator never triggers the postboot flow (hot-only
|
||||
# restore or a restore path where no reboot is scheduled).
|
||||
local seed
|
||||
seed=$(jq -n \
|
||||
--arg started "$(date -Iseconds)" \
|
||||
--arg log_path "$log_file" \
|
||||
--argjson section "$section" \
|
||||
'{status:"complete",
|
||||
started_at:$started,
|
||||
finished_at:$started,
|
||||
current_step:"Data pools imported",
|
||||
steps_done:1,
|
||||
steps_total:1,
|
||||
log_path:$log_path,
|
||||
components:[],
|
||||
rollback_delta:{},
|
||||
sanity_warnings:[],
|
||||
summary:null,
|
||||
acknowledged:false} * $section')
|
||||
printf '%s\n' "$seed" > "$tmp"
|
||||
mv -f "$tmp" "$state_file"
|
||||
fi
|
||||
}
|
||||
|
||||
_rs_run_complete_extras() {
|
||||
local staging_root="$1"
|
||||
local include_guests="${2:-1}"
|
||||
@@ -3618,6 +3831,17 @@ _rs_run_complete_extras() {
|
||||
fi
|
||||
fi
|
||||
|
||||
# ─ Data ZFS pools — auto-import ───────────────────────────
|
||||
# Import ZFS pools that were present in the backup and whose
|
||||
# disks are still on this host. Skips the root pool (already
|
||||
# imported by the system) and any pool missing disks. When a
|
||||
# pool imports but ZFS marks it foreign (hostid mismatch from
|
||||
# a fresh install), retries with -f and flags it for the
|
||||
# operator. Field-reported by Juan C.: fresh PVE install →
|
||||
# restore → separate data pool stayed unavailable because
|
||||
# zfs-import-scan.service failed on hostid mismatch.
|
||||
_rs_import_data_pools "$staging_root"
|
||||
|
||||
# ─ Guest configs — only in full strategies ────────────────
|
||||
if [[ "$include_guests" == "1" ]]; then
|
||||
local nodes_root="$staging_root/rootfs/etc/pve/nodes"
|
||||
|
||||
@@ -50,11 +50,18 @@ Esta versión suma dos mejoras visibles en el propio dashboard — un botón par
|
||||
|
||||
---
|
||||
|
||||
## 🗄 Restauración de backup — importación automática de pools ZFS de datos
|
||||
|
||||
- **Los pools ZFS de datos listados en el backup ahora se importan automáticamente durante la restauración.** Anteriormente el flujo protegía correctamente el pool raíz (rpool) de un `zpool.cache` obsoleto, pero cualquier otro pool ZFS que un usuario tuviera (para VMs, LXCs o simplemente datos) quedaba a merced de `zfs-import-scan.service` para importarse en el siguiente arranque — y ese servicio falla cuando la instalación fresh tiene un `hostid` distinto al que está grabado en la etiqueta on-disk del pool. El pool aparecía como `FAILED Failed to start zfs-import-scan.service` y quedaba indisponible hasta que el usuario ejecutara de manera manual `zpool import -f` Ahora la restauración itera el `storage_inventory.zfs_pools[]` del backup, excluye el pool raíz (ya montado por el sistema) y ejecuta `zpool import <nombre>` para cada pool no-raíz cuyos discos estén todos presentes en `/dev/disk/by-id/`. Si ZFS rechaza el import por *foreign*, se reintenta con `-f` y se reporta el pool como forzado para que el operador sepa que el nuevo hostid quedó grabado en la etiqueta. Los pools a los que les falta algún disco se omiten con un aviso claro, en lugar de un import silencioso que reconstruiría el pool sin el vdev ausente.
|
||||
|
||||
---
|
||||
|
||||
## 🙏 Agradecimientos
|
||||
|
||||
- **@pepenai** — reportó el bloqueo del dashboard en móvil en su setup con HTTPS + reverse proxy, lo que llevó al path de limpieza del Service Worker.
|
||||
- **Pepo** — reportó el `401 missing_timestamp` en su botón Test de PVE, lo que llevó al allowlist de IPs propias del host y a la corrección v4-mapped.
|
||||
- **@ash34** (#255) — reportó el `VM/CT check unavailable: 'NoneType' object has no attribute 'get'` que llevó al coalescing defensivo de la columna `details`.
|
||||
- **Juan C.** — reportó el `zfs-import-scan.service FAILED` post-restauración en una instalación fresh con un pool de datos separado, lo que llevó al paso de auto-import descrito arriba.
|
||||
|
||||
|
||||
## 2026-07-14
|
||||
|
||||
Reference in New Issue
Block a user