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 %} +
+
+
+ Open +
+
+ + {# ---- 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 + + {% for r in ipxe.rows %}{% endfor %} +
Boot files on the TFTP root
{{ r.name }}{{ (r.info or {}).get('version') or ('not installed' if not r.info else '-') }}
+
+
+
+
+ History +
+
+ + {# ---- 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 +
    +
  1. Upload the vendor release (.iso, .img, .img.gz or .iso.gz).
  2. +
  3. If its preferences format changed, upload the merged preferences file; staging waits for it.
  4. +
  5. Test-boot the staged release from the PXE menu, then activate.
  6. +
+
+ +
+ + {# ---- 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 %} + + + +
+ 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()