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"])
|
||||
def images_import():
|
||||
usb_mounts = system.find_usb_mounts()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -66,7 +66,8 @@
|
||||
</div>
|
||||
<div class="ui-group">
|
||||
<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('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. #}
|
||||
|
||||
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(),
|
||||
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/<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"])
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user