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

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