diff --git a/webapp/app.py b/webapp/app.py
index f05cd9e..2d3ed1c 100644
--- a/webapp/app.py
+++ b/webapp/app.py
@@ -130,6 +130,35 @@ def dashboard():
)
+@app.route("/updates/")
+def updates_overview():
+ """One card per updater (Dell, iPXE, Blancco, BPRT) and the Dell applies
+ with their rollback state. Each card links to its own page."""
+ from services import blancco_update, bprt_sync, dell_updates, ipxe_update, updates_common
+ scan = dell_updates.last_scan()
+ dell = {"checked": scan.get("checked") if scan else None,
+ "drivers": len(scan.get("drivers") or []) if scan else 0,
+ "bios": len(scan.get("bios") or []) if scan else 0,
+ "drv_updates": sum(1 for r in (scan.get("drivers") or []) if r.get("status") == "update") if scan else 0,
+ "bios_updates": sum(1 for r in (scan.get("bios") or []) if r.get("status") == "update") if scan else 0}
+ rows = ipxe_update.installed()
+ by_name = {r["name"]: r["info"] for r in rows}
+ latest = ipxe_update.last_check()
+ efi = by_name.get("ipxe.efi") or {}
+ ipxe = {"rows": rows, "version": efi.get("version"), "latest": latest,
+ "status": ipxe_update.compare(latest["tag"], efi.get("version")) if latest else None}
+ try:
+ blancco = {"current": blancco_update.current(), "staged": blancco_update.staged()}
+ except Exception as ex: # a broken release folder must not take the page down
+ blancco = {"current": {}, "staged": [], "error": str(ex)}
+ try:
+ bprt = {"live": bprt_sync.live_summary(), "batches": bprt_sync.batches()}
+ except Exception as ex:
+ bprt = {"live": {}, "batches": [], "error": str(ex)}
+ return render_template("updates_overview.html", dell=dell, ipxe=ipxe, blancco=blancco, bprt=bprt,
+ history=dell_updates.history_view(10), settings=updates_common.settings())
+
+
@app.route("/images/import", methods=["GET", "POST"])
def images_import():
usb_mounts = system.find_usb_mounts()
diff --git a/webapp/services/dell_updates.py b/webapp/services/dell_updates.py
index cc300b9..667c13e 100644
--- a/webapp/services/dell_updates.py
+++ b/webapp/services/dell_updates.py
@@ -34,6 +34,7 @@ from __future__ import annotations
import fcntl
import glob
+import difflib
import hashlib
import json
import os
@@ -1348,6 +1349,108 @@ def _rollback(apply_id):
return restored, refused
+# --- history for the Updates pages -------------------------------------------------
+
+def _file_label(path):
+ """Short name of a file an apply wrote: 'gea-standard/.../HardwareDriver.json'
+ or 'BIOS/models.txt'."""
+ parts = path.replace("\\", "/").split("/")
+ if parts[-1] == "HardwareDriver.json":
+ image = next((p for p in parts if p in IMAGES), parts[-4] if len(parts) > 3 else "")
+ return f"{image}/.../HardwareDriver.json"
+ return "/".join(parts[-2:])
+
+
+def file_status(rec):
+ """Per file an apply wrote: does it still hold exactly what the apply
+ wrote (content hash, the test rollback uses)?
+ [{"path", "label", "backup", "state": unchanged|changed|missing, "mtime"}]"""
+ out = []
+ for path, backup in (rec.get("backups") or {}).items():
+ row = {"path": path, "label": _file_label(path), "backup": backup, "state": "missing", "mtime": None}
+ try:
+ with open(path, "rb") as f:
+ now_hash = hashlib.sha256(f.read()).hexdigest()
+ row["mtime"] = os.stat(path).st_mtime
+ row["state"] = "unchanged" if now_hash == (rec.get("after") or {}).get(path) else "changed"
+ except OSError:
+ pass
+ out.append(row)
+ return out
+
+
+_PACK_VER = re.compile(r"_(a\d+)", re.I)
+
+
+def _model_for_zip(zip_name, maps):
+ """First model token of the map entry that uses zip_name, or ''."""
+ for m in maps.values():
+ for e in m["entries"]:
+ if isinstance(e, dict) and str(e.get("fileName") or e.get("FileName") or "").lower() == str(zip_name).lower():
+ return str(e.get("modelswminame") or "").split(",")[0].strip()
+ return ""
+
+
+def describe(rec, maps=None):
+ """One line per change of an apply, for history lists: a driver pack
+ 'Latitude 5350 pack A11 to A12' (model from the maps, version from the
+ zip names) or a BIOS manifest line."""
+ maps = read_maps() if maps is None else maps
+ out = []
+ for d in rec.get("drivers") or []:
+ model = _model_for_zip(d.get("new", ""), maps) or _model_for_zip(d.get("old", ""), maps)
+ a, b = _PACK_VER.search(d.get("old", "") or ""), _PACK_VER.search(d.get("new", "") or "")
+ if model and a and b:
+ out.append(f"{model} pack {a.group(1).upper()} to {b.group(1).upper()}")
+ else:
+ out.append(f"{d.get('old', '')} to {d.get('new', '')}")
+ for b in rec.get("bios") or []:
+ out.append(f"BIOS line {str(b.get('new', '')).strip()}")
+ return out
+
+
+def history_view(limit=15):
+ """history() with, per apply: "changes" (describe), "title" (first change
+ and "and n more"), "files" (file_status), "unchanged" and "total"."""
+ maps = read_maps()
+ out = []
+ for rec in history()[:limit]:
+ r = dict(rec)
+ r["changes"] = describe(rec, maps)
+ n = len(r["changes"])
+ r["title"] = (r["changes"][0] + (f" and {n - 1} more" if n > 1 else "")) if n else "No change"
+ r["files"] = file_status(rec)
+ r["unchanged"] = sum(1 for f in r["files"] if f["state"] == "unchanged")
+ r["total"] = len(r["files"])
+ out.append(r)
+ return out
+
+
+def rollback_preview(apply_id, max_lines=400):
+ """What a rollback of apply_id would do, without doing it: the apply
+ record, file_status, and for each unchanged file a unified diff from
+ the current content to the backup it would restore."""
+ rec = next((h for h in history_view(limit=1000) if h.get("id") == apply_id), None)
+ if rec is None:
+ raise ValueError("unknown apply")
+ for f in rec["files"]:
+ f["diff"] = ""
+ if f["state"] != "unchanged":
+ continue
+ try:
+ with open(f["path"], encoding="utf-8-sig", errors="replace") as cur, \
+ open(f["backup"], encoding="utf-8-sig", errors="replace") as old:
+ lines = list(difflib.unified_diff(cur.read().splitlines(), old.read().splitlines(),
+ "now", "after rollback", n=2, lineterm=""))
+ except OSError as ex:
+ f["state"], f["error"] = "missing", str(ex)
+ continue
+ if len(lines) > max_lines:
+ lines = lines[:max_lines] + [f"... {len(lines) - max_lines} more lines"]
+ f["diff"] = "\n".join(lines)
+ return rec
+
+
def _audit(action, detail):
try:
from services.audit import audit
diff --git a/webapp/templates/base.html b/webapp/templates/base.html
index 4951f58..4ce3899 100644
--- a/webapp/templates/base.html
+++ b/webapp/templates/base.html
@@ -66,7 +66,8 @@
Updates
- {{ navlink('Dell drivers and BIOS', url_for('dell_updates.page'), ep.startswith('dell_updates.')) }}
+ {{ navlink('Updates overview', url_for('updates_overview'), ep in ('updates_overview', 'dell_updates.rollback_review')) }}
+ {{ navlink('Dell drivers and BIOS', url_for('dell_updates.page'), ep.startswith('dell_updates.') and ep != 'dell_updates.rollback_review') }}
{{ navlink('iPXE boot files', url_for('ipxe.page'), ep.startswith('ipxe.')) }}
{{ navlink('Blancco releases', url_for('blancco_update.page'), ep.startswith('blancco_update.')) }}
{# Plain href: the page still renders if views.bprt_sync is not registered yet. #}
diff --git a/webapp/templates/updates_overview.html b/webapp/templates/updates_overview.html
new file mode 100644
index 0000000..26a068a
--- /dev/null
+++ b/webapp/templates/updates_overview.html
@@ -0,0 +1,172 @@
+{% extends "base.html" %}
+{% import '_ui.html' as ui %}
+{% block title %}Updates - PXE Manager{% endblock %}
+
+{% block extra_head %}
+
+{% endblock %}
+
+{% block content %}
+{% call ui.head('Updates overview', 'Updates') %}
+
+ Download cap
+ {{ ('%g MB/s' | format(settings.max_mbps)) if settings.max_mbps else 'none' }}▾
+
+
+{% endcall %}
+
+
+
+ {# ---- Dell ---- #}
+
+ Dell drivers and BIOS
+ {% set n_up = dell.drv_updates + dell.bios_updates %}
+ {% if not dell.checked %}
+ Not checked yet
+ Check the Dell catalogs for newer driver packs and BIOS.
+ {% elif n_up %}
+ {{ n_up }} update{{ 's' if n_up != 1 }}
+ {{ dell.drv_updates }} driver pack{{ 's' if dell.drv_updates != 1 }}, {{ dell.bios_updates }} BIOS. Checked {{ dell.checked | local_dt }}.
+ {% else %}
+ All current
+ {{ dell.drivers }} driver packs, {{ dell.bios }} BIOS lines. Checked {{ dell.checked | local_dt }}.
+ {% endif %}
+
+
+
+ {# ---- iPXE ---- #}
+
+ iPXE boot files
+
+
+ {{ ipxe.version or 'Unknown version' }}
+ {% if ipxe.status == 'update' %}{{ ui.chip(ipxe.latest.tag ~ ' available', 'act') }}{% endif %}
+
+ {% if ipxe.latest %}Latest upstream {{ ipxe.latest.tag }}, checked {{ ipxe.latest.checked | local_dt }}.{% else %}Upstream not checked yet.{% endif %}
+ ▾Boot files on the TFTP root
+ Boot files on the TFTP root
+ {% for r in ipxe.rows %}| {{ r.name }} | {{ (r.info or {}).get('version') or ('not installed' if not r.info else '-') }} |
{% endfor %}
+
+
+
+
+
+ {# ---- Blancco ---- #}
+
+ Blancco releases
+ {% set cur = blancco.current or {} %}
+ {{ cur.version or 'Unknown' }} live
+ {% if cur.missing %}Missing release files: {{ cur.missing | join(', ') }}.{% elif blancco.staged %}{{ blancco.staged | length }} staged release{{ 's' if blancco.staged | length != 1 }} waiting.{% else %}Nothing staged.{% endif %}
+ ▾How an update works
+
+ - Upload the vendor release (.iso, .img, .img.gz or .iso.gz).
+ - If its preferences format changed, upload the merged preferences file; staging waits for it.
+ - Test-boot the staged release from the PXE menu, then activate.
+
+
+
+
+
+ {# ---- BPRT ---- #}
+
+ BPRT sync
+ {% set live = bprt.live or {} %}
+ {% if not live.exists %}
+ No live BPRT folder
+ {% elif live.missing_required %}
+ {{ live.missing_required | length }} required file{{ 's' if live.missing_required | length != 1 }} missing
+ {% else %}
+ Required files present
+ {% endif %}
+ {{ live.count or 0 }} files, {{ (live.bytes or 0) | filesizeformat }}. {% if bprt.batches %}{{ bprt.batches | length }} incoming batch{{ 'es' if bprt.batches | length != 1 }} waiting.{% else %}No batch waiting.{% endif %}
+ {% if live.missing_required %}Missing: {{ live.missing_required | join(', ') }}
{% endif %}
+
+
+
+
+
+ Dell applies
+
iPXE, Blancco and BPRT keep their own history; open it from their cards.
+ {% for h in history %}
+ {% set can = not h.rolled_back and h.unchanged > 0 %}
+
+
+
+ {{ h.at | local_dt }}
+ {{ h.title }}
+
+ {% if h.rolled_back %}{{ ui.chip('Rolled back', 'neutral') }}
+ {% elif h.errors and not h.total %}{{ ui.chip('Apply failed', 'err') }}
+ {% elif not h.total %}{{ ui.chip('Nothing written', 'off') }}
+ {% elif h.unchanged == h.total %}{{ ui.chip(h.unchanged ~ '/' ~ h.total ~ ' unchanged', 'ok') }}
+ {% elif h.unchanged %}{{ ui.chip(h.unchanged ~ '/' ~ h.total ~ ' unchanged', 'warn') }}
+ {% else %}{{ ui.chip('0/' ~ h.total ~ ' unchanged', 'off') }}{% endif %}
+
+ ▾
+
+
+
{% for c in h.changes %}- {{ c }}
{% endfor %}
+ {% for d in h.drivers %}- {{ d.old }} to {{ d.new }}
{% endfor %}
+ {% if h.skipped %}
Skipped: {{ h.skipped | join('; ') }}
{% endif %}
+ {% if h.errors %}
{{ h.errors | join('; ') }}
{% endif %}
+ {% if h.files %}
+
+ {% for f in h.files %}
+ - {{ f.label }}
+ {% if f.state == 'unchanged' %}unchanged since this apply
+ {% elif f.state == 'changed' %}content differs from what this apply wrote{% if f.mtime %} (last modified {{ f.mtime | local_dt }}){% endif %}
+ {% else %}missing{% endif %}
+ {% endfor %}
+
+ {% endif %}
+ {% if h.rolled_back %}
Rolled back {{ h.rolled_back | local_dt }}.
{% endif %}
+
Apply {{ h.id }}. Rollback is offered only for files nobody has changed since the apply (same content hash).
+
+
+ {% if can %}
{% endif %}
+
+ {% else %}
+ No Dell apply recorded yet.
+ {% endfor %}
+
+
+{% endblock %}
diff --git a/webapp/templates/updates_rollback.html b/webapp/templates/updates_rollback.html
new file mode 100644
index 0000000..c783cdd
--- /dev/null
+++ b/webapp/templates/updates_rollback.html
@@ -0,0 +1,68 @@
+{% extends "base.html" %}
+{% import '_ui.html' as ui %}
+{% block title %}Review rollback - PXE Manager{% endblock %}
+
+{% block extra_head %}
+
+{% endblock %}
+
+{% block content %}
+{{ ui.head('Roll back the Dell apply of ' ~ (rec.at | local_dt), 'Updates', rec.title ~ ' | apply ' ~ rec.id) }}
+
+
+ {% if rec.rolled_back %}
+
This apply was already rolled back {{ rec.rolled_back | local_dt }}.
+ {% endif %}
+
+
+ Files this apply wrote
+
+ {% for f in rec.files %}
+ - {{ f.path }}
+ {% if f.state == 'unchanged' %}{{ ui.chip('Unchanged since apply: will be restored', 'ok') }}
+ {% elif f.state == 'changed' %}{{ ui.chip('Changed since: left alone', 'off') }}
+ {% else %}{{ ui.chip('Missing: left alone', 'err') }}{% endif %}
+
+ {% endfor %}
+
+ "Unchanged" means the content still matches what the apply wrote. A file changed since keeps its current content.
+
+
+ {% for f in rec.files if f.state == 'unchanged' and f.diff %}
+
+
{{ f.label }}: now, then after rollback
+
{% for line in f.diff.split('\n') %}{{ line or ' ' }}{% endfor %}
+
+ {% endfor %}
+
+
+ - The current files are backed up first, as .bak-[timestamp]-pre-rollback next to each one.
+ - The new driver zips and BIOS files stay on the share, unused.
+
+
+
+
Cancel
+ {% if rec.unchanged and not rec.rolled_back %}
+
+ {% endif %}
+
+
+{% endblock %}
diff --git a/webapp/views/dell_updates.py b/webapp/views/dell_updates.py
index 6b7f851..afe5208 100644
--- a/webapp/views/dell_updates.py
+++ b/webapp/views/dell_updates.py
@@ -28,7 +28,7 @@ def _render(preview=None, preview_keys=()):
scan=dell_updates.last_scan(),
prog=prog,
staged=items,
- history=dell_updates.history()[:15],
+ history=dell_updates.history_view(15),
settings=updates_common.settings(),
preview=preview,
preview_keys=list(preview_keys),
@@ -157,6 +157,18 @@ def discard():
return _back()
+@bp.route("/rollback/
")
+def rollback_review(apply_id):
+ """Review a rollback before doing it: which files would be restored
+ (content unchanged since the apply) and the diff of each."""
+ try:
+ rec = dell_updates.rollback_preview(apply_id)
+ except ValueError:
+ flash("No apply with that id.", "warning")
+ return redirect(url_for("updates_overview"))
+ return render_template("updates_rollback.html", rec=rec)
+
+
@bp.route("/rollback", methods=["POST"])
def rollback():
apply_id = request.form.get("apply_id", "")
@@ -170,10 +182,14 @@ def rollback():
"on the share, unused.", "success")
for msg in refused:
flash(f"Not restored: {msg}.", "danger")
+ if request.form.get("next") == "overview":
+ return redirect(url_for("updates_overview"))
return _back()
@bp.route("/settings", methods=["POST"])
def save_settings():
save_speed_cap() # shared with the other Updates pages (views/__init__.py)
+ if request.form.get("next") == "overview":
+ return redirect(url_for("updates_overview"))
return _back()