Webapp redesign phase 4: Updates overview and rollback review

- GET /updates/: one card per updater (Dell, iPXE, Blancco, BPRT) with
  its state, a Check now / Open action and details in accordions; the
  shared download cap is a small popover.
- Dell applies list: one line per apply titled by its first change
  ('Latitude 5350 pack A11 to A12', model looked up in the maps) and
  'and n more'; the chip says how many written files are unchanged since
  the apply. The body lists every change, skipped item, error and file.
- dell_updates.file_status(): per file an apply wrote, unchanged /
  changed / missing by content hash, the same test rollback uses.
  describe(), history_view() and rollback_preview() build on it.
- GET /updates/dell/rollback/<id>: review a rollback before doing it,
  with the files that would be restored and a diff of each. The POST
  route is unchanged; both forms can return to the overview.
This commit is contained in:
cproudlock
2026-10-14 13:54:41 -04:00
parent 731d7b044d
commit cc1e76f3ab
6 changed files with 391 additions and 2 deletions

View File

@@ -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