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

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

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

View File

@@ -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. #}

View 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">&#9662;</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">&#9662;</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">&#9662;</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">&#9662;</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 %}

View 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 %}

View File

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