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:
@@ -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"])
|
@app.route("/images/import", methods=["GET", "POST"])
|
||||||
def images_import():
|
def images_import():
|
||||||
usb_mounts = system.find_usb_mounts()
|
usb_mounts = system.find_usb_mounts()
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import fcntl
|
import fcntl
|
||||||
import glob
|
import glob
|
||||||
|
import difflib
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
@@ -1348,6 +1349,108 @@ def _rollback(apply_id):
|
|||||||
return restored, refused
|
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):
|
def _audit(action, detail):
|
||||||
try:
|
try:
|
||||||
from services.audit import audit
|
from services.audit import audit
|
||||||
|
|||||||
@@ -66,7 +66,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="ui-group">
|
<div class="ui-group">
|
||||||
<div class="ui-navhead">Updates</div>
|
<div class="ui-navhead">Updates</div>
|
||||||
{{ 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('iPXE boot files', url_for('ipxe.page'), ep.startswith('ipxe.')) }}
|
||||||
{{ navlink('Blancco releases', url_for('blancco_update.page'), ep.startswith('blancco_update.')) }}
|
{{ 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. #}
|
{# Plain href: the page still renders if views.bprt_sync is not registered yet. #}
|
||||||
|
|||||||
172
webapp/templates/updates_overview.html
Normal file
172
webapp/templates/updates_overview.html
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% import '_ui.html' as ui %}
|
||||||
|
{% block title %}Updates - PXE Manager{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_head %}
|
||||||
|
<style>
|
||||||
|
.up-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(250px, 100%), 1fr)); gap: 16px; }
|
||||||
|
.up-tile { display: flex; flex-direction: column; gap: 10px; padding: 20px 22px; }
|
||||||
|
.up-tile h2 { margin: 0; font-size: 15px; font-weight: 600; color: var(--muted); }
|
||||||
|
.up-state { display: flex; align-items: center; gap: 10px; font-size: 20px; font-weight: 600; }
|
||||||
|
.up-state .ring { width: 12px; height: 12px; border-radius: 6px; flex: none; }
|
||||||
|
.up-foot { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: auto; padding-top: 4px; }
|
||||||
|
.up-foot form { margin: 0; }
|
||||||
|
.mini { width: 100%; border-collapse: collapse; font-size: 13px; margin-top: 4px; }
|
||||||
|
.mini th { text-align: left; font-weight: 400; white-space: nowrap; }
|
||||||
|
.mini td, .mini th { padding: 5px 14px 5px 0; border-top: 1px solid var(--line); }
|
||||||
|
.agrid { display: grid; grid-template-columns: 150px minmax(0, 1fr) 130px 14px; align-items: center; gap: 0 16px;
|
||||||
|
padding: 12px 180px 12px 24px; font-size: 14px; min-height: 24px; }
|
||||||
|
.abody { padding: 2px 24px 16px 190px; font-size: 13px; line-height: 1.55; display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.abody ul { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.abody li { display: flex; flex-wrap: wrap; gap: 4px 12px; align-items: baseline; }
|
||||||
|
.abody .mono { overflow-wrap: anywhere; min-width: 0; }
|
||||||
|
@container (max-width: 760px) {
|
||||||
|
.agrid { grid-template-columns: minmax(0, 1fr) auto 14px; padding: 12px 16px; }
|
||||||
|
.agrid .when { grid-column: 1 / -1; }
|
||||||
|
.ui-row > .act { position: static; padding: 0 16px 10px; display: flex; justify-content: flex-end; }
|
||||||
|
.abody { padding: 2px 16px 14px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% call ui.head('Updates overview', 'Updates') %}
|
||||||
|
<details class="more">
|
||||||
|
<summary class="fbtn" style="background: var(--surface); min-height: 36px">Download cap
|
||||||
|
<strong class="mono">{{ ('%g MB/s' | format(settings.max_mbps)) if settings.max_mbps else 'none' }}</strong><span class="chev" aria-hidden="true">▾</span></summary>
|
||||||
|
<form class="menu" method="post" action="{{ url_for('dell_updates.save_settings') }}" style="width: min(300px, calc(100vw - 32px)); padding: 14px; gap: 10px">
|
||||||
|
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<input type="hidden" name="next" value="overview">
|
||||||
|
<label for="cap" style="font-size: 13px; font-weight: 600">Download speed cap (MB/s)</label>
|
||||||
|
<div style="display: flex; gap: 8px"><input id="cap" name="max_mbps" type="number" min="0" max="1000" step="0.5"
|
||||||
|
value="{{ '%g' | format(settings.max_mbps) }}" aria-describedby="cap-help" class="field mono" style="width: 90px">
|
||||||
|
<button type="submit" class="ui-btn pri">Save</button></div>
|
||||||
|
<span id="cap-help" class="sub" style="white-space: normal">0 means no cap. Applies to Dell and iPXE downloads; Blancco and BPRT are uploads.</span>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
{% endcall %}
|
||||||
|
|
||||||
|
<div class="ui-stack">
|
||||||
|
<div class="up-grid">
|
||||||
|
{# ---- Dell ---- #}
|
||||||
|
<section class="panel up-tile" aria-labelledby="dell-h">
|
||||||
|
<h2 id="dell-h">Dell drivers and BIOS</h2>
|
||||||
|
{% set n_up = dell.drv_updates + dell.bios_updates %}
|
||||||
|
{% if not dell.checked %}
|
||||||
|
<div class="up-state"><span class="ring" style="background: var(--track-done)"></span>Not checked yet</div>
|
||||||
|
<span class="sub">Check the Dell catalogs for newer driver packs and BIOS.</span>
|
||||||
|
{% elif n_up %}
|
||||||
|
<div class="up-state"><span class="ring" style="background: var(--act-track)"></span>{{ n_up }} update{{ 's' if n_up != 1 }}</div>
|
||||||
|
<span class="sub">{{ dell.drv_updates }} driver pack{{ 's' if dell.drv_updates != 1 }}, {{ dell.bios_updates }} BIOS. Checked {{ dell.checked | local_dt }}.</span>
|
||||||
|
{% else %}
|
||||||
|
<div class="up-state"><span class="ring" style="background: var(--ok)"></span>All current</div>
|
||||||
|
<span class="sub">{{ dell.drivers }} driver packs, {{ dell.bios }} BIOS lines. Checked {{ dell.checked | local_dt }}.</span>
|
||||||
|
{% endif %}
|
||||||
|
<div class="up-foot">
|
||||||
|
<form method="post" action="{{ url_for('dell_updates.check') }}"><input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button type="submit" class="ui-btn pri">Check now</button></form>
|
||||||
|
<a class="ui-btn ghost" href="{{ url_for('dell_updates.page') }}">Open</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{# ---- iPXE ---- #}
|
||||||
|
<section class="panel up-tile" aria-labelledby="ipxe-h">
|
||||||
|
<h2 id="ipxe-h">iPXE boot files</h2>
|
||||||
|
<div class="up-state">
|
||||||
|
<span class="ring" style="background: {{ 'var(--act-track)' if ipxe.status == 'update' else ('var(--ok)' if ipxe.status in ('current', 'newer') else 'var(--track-done)') }}"></span>
|
||||||
|
{{ ipxe.version or 'Unknown version' }}
|
||||||
|
{% if ipxe.status == 'update' %}{{ ui.chip(ipxe.latest.tag ~ ' available', 'act') }}{% endif %}
|
||||||
|
</div>
|
||||||
|
<span class="sub">{% if ipxe.latest %}Latest upstream {{ ipxe.latest.tag }}, checked {{ ipxe.latest.checked | local_dt }}.{% else %}Upstream not checked yet.{% endif %}</span>
|
||||||
|
<details><summary class="disc"><span class="chev" aria-hidden="true">▾</span>Boot files on the TFTP root</summary>
|
||||||
|
<table class="mini"><caption class="vh">Boot files on the TFTP root</caption><tbody>
|
||||||
|
{% for r in ipxe.rows %}<tr><th scope="row" class="mono">{{ r.name }}</th><td>{{ (r.info or {}).get('version') or ('not installed' if not r.info else '-') }}</td></tr>{% endfor %}
|
||||||
|
</tbody></table>
|
||||||
|
</details>
|
||||||
|
<div class="up-foot">
|
||||||
|
<form method="post" action="{{ url_for('ipxe.check') }}"><input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button type="submit" class="ui-btn pri">Check now</button></form>
|
||||||
|
<a class="ui-btn ghost" href="{{ url_for('ipxe.page') }}">History</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{# ---- Blancco ---- #}
|
||||||
|
<section class="panel up-tile" aria-labelledby="bl-h">
|
||||||
|
<h2 id="bl-h">Blancco releases</h2>
|
||||||
|
{% set cur = blancco.current or {} %}
|
||||||
|
<div class="up-state"><span class="ring" style="background: {{ 'var(--fail)' if cur.missing else 'var(--ok)' }}"></span>{{ cur.version or 'Unknown' }} live</div>
|
||||||
|
<span class="sub">{% 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 %}</span>
|
||||||
|
<details><summary class="disc"><span class="chev" aria-hidden="true">▾</span>How an update works</summary>
|
||||||
|
<ol class="sub" style="margin: 4px 0 0; padding-left: 18px; line-height: 1.6">
|
||||||
|
<li>Upload the vendor release (.iso, .img, .img.gz or .iso.gz).</li>
|
||||||
|
<li>If its preferences format changed, upload the merged preferences file; staging waits for it.</li>
|
||||||
|
<li>Test-boot the staged release from the PXE menu, then activate.</li>
|
||||||
|
</ol>
|
||||||
|
</details>
|
||||||
|
<div class="up-foot"><a class="ui-btn pri" href="{{ url_for('blancco_update.page') }}">Upload release</a><a class="ui-btn ghost" href="{{ url_for('blancco_update.page') }}">History</a></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{# ---- BPRT ---- #}
|
||||||
|
<section class="panel up-tile" aria-labelledby="bprt-h">
|
||||||
|
<h2 id="bprt-h">BPRT sync</h2>
|
||||||
|
{% set live = bprt.live or {} %}
|
||||||
|
{% if not live.exists %}
|
||||||
|
<div class="up-state"><span class="ring" style="background: var(--fail)"></span>No live BPRT folder</div>
|
||||||
|
{% elif live.missing_required %}
|
||||||
|
<div class="up-state"><span class="ring" style="background: var(--fail)"></span>{{ live.missing_required | length }} required file{{ 's' if live.missing_required | length != 1 }} missing</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="up-state"><span class="ring" style="background: var(--ok)"></span>Required files present</div>
|
||||||
|
{% endif %}
|
||||||
|
<span class="sub">{{ 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 %}</span>
|
||||||
|
{% if live.missing_required %}<div class="notice bad">Missing: {{ live.missing_required | join(', ') }}</div>{% endif %}
|
||||||
|
<div class="up-foot"><a class="ui-btn pri" href="/updates/bprt/">Open BPRT sync</a></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="panel ui-list" aria-labelledby="act-h">
|
||||||
|
<div style="padding: 18px 24px 10px"><h2 id="act-h" class="sec">Dell applies</h2>
|
||||||
|
<div class="sub" style="margin-top: 2px">iPXE, Blancco and BPRT keep their own history; open it from their cards.</div></div>
|
||||||
|
{% for h in history %}
|
||||||
|
{% set can = not h.rolled_back and h.unchanged > 0 %}
|
||||||
|
<div class="ui-row">
|
||||||
|
<details>
|
||||||
|
<summary class="agrid">
|
||||||
|
<span class="mono sub when">{{ h.at | local_dt }}</span>
|
||||||
|
<span>{{ h.title }}</span>
|
||||||
|
<span>
|
||||||
|
{% 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 %}
|
||||||
|
</span>
|
||||||
|
<span class="chev" aria-hidden="true">▾</span>
|
||||||
|
</summary>
|
||||||
|
<div class="abody">
|
||||||
|
<ul>{% for c in h.changes %}<li>{{ c }}</li>{% endfor %}
|
||||||
|
{% for d in h.drivers %}<li class="sub"><span class="mono">{{ d.old }}</span> to <span class="mono">{{ d.new }}</span></li>{% endfor %}</ul>
|
||||||
|
{% if h.skipped %}<div class="notice">Skipped: {{ h.skipped | join('; ') }}</div>{% endif %}
|
||||||
|
{% if h.errors %}<div class="errbox">{{ h.errors | join('; ') }}</div>{% endif %}
|
||||||
|
{% if h.files %}
|
||||||
|
<ul>
|
||||||
|
{% for f in h.files %}
|
||||||
|
<li><span class="mono">{{ f.label }}</span>
|
||||||
|
{% if f.state == 'unchanged' %}<span class="muted">unchanged since this apply</span>
|
||||||
|
{% elif f.state == 'changed' %}<span>content differs from what this apply wrote{% if f.mtime %} (last modified {{ f.mtime | local_dt }}){% endif %}</span>
|
||||||
|
{% else %}<span style="color: var(--fail-text)">missing</span>{% endif %}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
{% if h.rolled_back %}<div class="sub">Rolled back {{ h.rolled_back | local_dt }}.</div>{% endif %}
|
||||||
|
<div class="sub">Apply <span class="mono">{{ h.id }}</span>. Rollback is offered only for files nobody has changed since the apply (same content hash).</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{% if can %}<div class="act"><a class="ui-btn" href="{{ url_for('dell_updates.rollback_review', apply_id=h.id) }}">Review rollback</a></div>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="hidden-bar"><span>No Dell apply recorded yet.</span></div>
|
||||||
|
{% endfor %}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
68
webapp/templates/updates_rollback.html
Normal file
68
webapp/templates/updates_rollback.html
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% import '_ui.html' as ui %}
|
||||||
|
{% block title %}Review rollback - PXE Manager{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_head %}
|
||||||
|
<style>
|
||||||
|
.rb-files { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.rb-files li { display: flex; flex-wrap: wrap; gap: 6px 12px; align-items: center; justify-content: space-between;
|
||||||
|
padding: 10px 12px; border: 1px solid var(--line-2); border-radius: 8px; font-size: 13px; }
|
||||||
|
.rb-files .mono { overflow-wrap: anywhere; min-width: 0; }
|
||||||
|
.rb-diff { border: 1px solid var(--line-2); border-radius: 10px; overflow: hidden; }
|
||||||
|
.rb-diff .h { padding: 8px 12px; background: var(--surface-2); border-bottom: 1px solid var(--line-2); font-size: 13px; font-weight: 600; }
|
||||||
|
.rb-diff pre { margin: 0; max-height: 50vh; overflow: auto; font-size: 12px; line-height: 1.45; font-family: var(--font-mono); }
|
||||||
|
.rb-diff pre span { display: block; padding: 0 12px; white-space: pre-wrap; word-break: break-all; }
|
||||||
|
.rb-diff .d-add { background: var(--add-bg); color: var(--add-text); }
|
||||||
|
.rb-diff .d-del { background: var(--del-bg); color: var(--del-text); }
|
||||||
|
.rb-diff .d-hunk { color: var(--accent); background: var(--surface-2); }
|
||||||
|
.rb-diff .d-meta { color: var(--muted); }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{{ ui.head('Roll back the Dell apply of ' ~ (rec.at | local_dt), 'Updates', rec.title ~ ' | apply ' ~ rec.id) }}
|
||||||
|
|
||||||
|
<div class="ui-stack" style="max-width: 980px">
|
||||||
|
{% if rec.rolled_back %}
|
||||||
|
<div class="notice info">This apply was already rolled back {{ rec.rolled_back | local_dt }}.</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<section class="panel" style="padding: 18px 22px; display: flex; flex-direction: column; gap: 12px" aria-labelledby="f-h">
|
||||||
|
<h2 id="f-h" class="sec" style="font-size: 15px">Files this apply wrote</h2>
|
||||||
|
<ul class="rb-files">
|
||||||
|
{% for f in rec.files %}
|
||||||
|
<li><span class="mono">{{ f.path }}</span>
|
||||||
|
{% 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 %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
<p class="sub" style="margin: 0">"Unchanged" means the content still matches what the apply wrote. A file changed since keeps its current content.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% for f in rec.files if f.state == 'unchanged' and f.diff %}
|
||||||
|
<div class="rb-diff">
|
||||||
|
<div class="h">{{ f.label }}: now, then after rollback</div>
|
||||||
|
<pre>{% for line in f.diff.split('\n') %}<span class="{{ 'd-meta' if line[:3] in ('---', '+++') else ('d-hunk' if line[:2] == '@@' else ('d-add' if line[:1] == '+' else ('d-del' if line[:1] == '-' else ''))) }}">{{ line or ' ' }}</span>{% endfor %}</pre>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<ul class="rb-files">
|
||||||
|
<li><span>The current files are backed up first, as <span class="mono">.bak-[timestamp]-pre-rollback</span> next to each one.</span></li>
|
||||||
|
<li><span>The new driver zips and BIOS files stay on the share, unused.</span></li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div style="display: flex; flex-wrap: wrap; gap: 10px; justify-content: flex-end">
|
||||||
|
<a class="ui-btn" href="{{ url_for('updates_overview') }}">Cancel</a>
|
||||||
|
{% if rec.unchanged and not rec.rolled_back %}
|
||||||
|
<form method="post" action="{{ url_for('dell_updates.rollback') }}" style="margin: 0">
|
||||||
|
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<input type="hidden" name="apply_id" value="{{ rec.id }}">
|
||||||
|
<input type="hidden" name="next" value="overview">
|
||||||
|
<button type="submit" class="ui-btn pri">Roll back {{ rec.unchanged }} file{{ 's' if rec.unchanged != 1 }}</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -28,7 +28,7 @@ def _render(preview=None, preview_keys=()):
|
|||||||
scan=dell_updates.last_scan(),
|
scan=dell_updates.last_scan(),
|
||||||
prog=prog,
|
prog=prog,
|
||||||
staged=items,
|
staged=items,
|
||||||
history=dell_updates.history()[:15],
|
history=dell_updates.history_view(15),
|
||||||
settings=updates_common.settings(),
|
settings=updates_common.settings(),
|
||||||
preview=preview,
|
preview=preview,
|
||||||
preview_keys=list(preview_keys),
|
preview_keys=list(preview_keys),
|
||||||
@@ -157,6 +157,18 @@ def discard():
|
|||||||
return _back()
|
return _back()
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/rollback/<apply_id>")
|
||||||
|
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"])
|
@bp.route("/rollback", methods=["POST"])
|
||||||
def rollback():
|
def rollback():
|
||||||
apply_id = request.form.get("apply_id", "")
|
apply_id = request.form.get("apply_id", "")
|
||||||
@@ -170,10 +182,14 @@ def rollback():
|
|||||||
"on the share, unused.", "success")
|
"on the share, unused.", "success")
|
||||||
for msg in refused:
|
for msg in refused:
|
||||||
flash(f"Not restored: {msg}.", "danger")
|
flash(f"Not restored: {msg}.", "danger")
|
||||||
|
if request.form.get("next") == "overview":
|
||||||
|
return redirect(url_for("updates_overview"))
|
||||||
return _back()
|
return _back()
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/settings", methods=["POST"])
|
@bp.route("/settings", methods=["POST"])
|
||||||
def save_settings():
|
def save_settings():
|
||||||
save_speed_cap() # shared with the other Updates pages (views/__init__.py)
|
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()
|
return _back()
|
||||||
|
|||||||
Reference in New Issue
Block a user