Files
pxe-server/webapp/templates/updates_dell.html
cproudlock 2ed2cbe2e9 Webapp redesign: fixes from the functional regression test
- Unattend: a command's '...' menu stayed open after Move up/down and
  covered the next row's menu, so the next click could stage removing the
  wrong command. The menu now closes and focus returns to its button.
- '...' menus close on an outside click, on Escape and when another
  opens (static/app.js), for every page.
- Drivers: Remove asks to confirm again, as before the redesign. Long zip
  paths in a row body wrap (the page scrolled sideways at 390 px).
- Rollback review returns to the page it was opened from (Dell page or
  Updates overview), for both Cancel and the rollback itself.
- To-do: the ServiceNow period offers a year again (366 days).
- Imaging floor: a search that matches a log-only bay opens that group;
  its open state survives the live refresh. 'Clear all' states the
  number of session files it deletes, hidden finished ones included.
- Dashboard: the image key pattern is valid under the browser's v flag
  (client-side validation was silently skipped).
- Import: allowed_import_source() compares real paths, so
  '<upload dir>/../..' is refused. This path traversal predates the
  redesign; the import moves the source tree, so it mattered.
2026-10-14 14:48:10 -04:00

536 lines
32 KiB
HTML

{% extends "base.html" %}
{% import '_ui.html' as ui %}
{% block title %}Dell drivers and BIOS - PXE Manager{% endblock %}
{% macro size(n) -%}
{%- if not n -%}-{%- elif n >= 1000000000 -%}{{ '%.2f' | format(n / 1000000000) }} GB{%- elif n >= 1000000 -%}{{ '%.0f' | format(n / 1000000) }} MB{%- else -%}{{ '%.0f' | format(n / 1000) }} KB{%- endif -%}
{%- endmacro %}
{% macro drv_chip(r) -%}
{% if r.status == 'update' and r.warnings %}{{ ui.chip('Update: decide', 'warn') }}
{% elif r.status == 'update' %}{{ ui.chip('Update', 'act') }}
{% elif r.status == 'current' %}{{ ui.chip('Current', 'ok') }}
{% elif r.status == 'no-match' %}{{ ui.chip('No match', 'off') }}
{% elif r.status == 'unknown-version' %}{{ ui.chip('Unknown version', 'neutral') }}
{% else %}{{ ui.chip(r.status, 'neutral') }}{% endif %}
{%- endmacro %}
{% macro bios_chip(s) -%}
{% if s == 'update' %}{{ ui.chip('Update', 'act') }}
{% elif s == 'current' %}{{ ui.chip('Current', 'ok') }}
{% elif s == 'different-file-family' %}{{ ui.chip('Different file', 'err') }}
{% elif s == 'ambiguous' %}{{ ui.chip('Ambiguous', 'err') }}
{% elif s == 'no-match' %}{{ ui.chip('No match', 'off') }}
{% elif s == 'error' %}{{ ui.chip('Catalog error', 'err') }}
{% else %}{{ ui.chip(s, 'neutral') }}{% endif %}
{%- endmacro %}
{% macro stage_chip(s) -%}
{% if s == 'ready' %}{{ ui.chip('Ready', 'ok') }}
{% elif s == 'applied' %}{{ ui.chip('Applied', 'neutral') }}
{% elif s in ('error', 'interrupted', 'cancelled') %}{{ ui.chip(s | capitalize, 'err') }}
{% else %}{{ ui.chip(s | capitalize, 'run') }}{% endif %}
{%- endmacro %}
{# One driver-pack row. The checkbox sits outside <summary> so ticking it
never toggles the row. #}
{% macro drv_row(r) -%}
<div class="ui-row dl-row" data-status="{{ r.status }}">
{% if r.pack and r.status != 'current' or r.pack and r.warnings %}
<div class="pick"><input type="checkbox" class="form-check-input" name="drv" value="{{ r.id }}" id="pick-{{ r.id }}" {% if r.default %}checked{% endif %}>
<label for="pick-{{ r.id }}" class="vh">Download the pack for {{ r.tokens | join(', ') }}</label></div>
{% endif %}
<details>
<summary class="dgrid">
<span class="tip" data-tip="{{ r.tokens | join(', ') }}"><span class="trunc">{{ r.tokens | join(', ') }}</span></span>
<span class="mono c-chg">{% if r.pack %}{{ r.cur_ver or '?' }} to {{ r.pack.ver }}{% else %}{{ r.cur_ver or '-' }}{% endif %}</span>
{% set imgs = r.images | map('replace', 'gea-', '') | join(', ') %}
<span class="tip c-img" data-tip="{{ imgs }}"><span class="trunc sub">{{ imgs }}</span></span>
<span>{{ drv_chip(r) }}</span>
<span class="chev" aria-hidden="true">&#9662;</span>
</summary>
<div class="dbody">
{% for w in r.warnings %}<div class="notice">{{ w }}</div>{% endfor %}
<dl class="kv">
<dt>Current file</dt><dd class="mono">{{ r.current }}</dd>
{% if r.pack %}
<dt>Dell pack</dt><dd><span class="mono">{{ r.pack.name }}</span><div class="sub">{{ r.pack.ver }}, {{ r.pack.date }}, {{ size(r.pack.size) }}</div></dd>
<dt>Stages as</dt><dd class="mono">{{ r.pack.zip }}</dd>
<dt>Pack covers</dt><dd>{{ r.pack.models | join(', ') }}</dd>
{% endif %}
<dt>Models (WMI)</dt><dd>{{ r.tokens | join(', ') }}</dd>
<dt>Images</dt><dd>{{ r.images | join(', ') }}</dd>
</dl>
{% for n in r.notes %}<div class="sub">{{ n }}</div>{% endfor %}
</div>
</details>
</div>
{%- endmacro %}
{% macro bios_row(r) -%}
<div class="ui-row dl-row" data-status="{{ r.status }}">
{% if r.candidates and (r.status != 'current') %}
<div class="pick"><input type="checkbox" class="form-check-input" name="bios" value="{{ r.id }}" id="pick-{{ r.id }}" {% if r.default %}checked{% endif %}>
<label for="pick-{{ r.id }}" class="vh">Download the BIOS for line {{ r.sub }}</label></div>
{% endif %}
<details>
<summary class="dgrid">
<span class="tip" data-tip="{{ r.line }}"><span class="trunc mono">{{ r.sub }}</span></span>
<span class="mono c-chg">{% if r.candidates %}{{ r.cur_ver }} to {{ r.candidates[0].version }}{% if r.candidates | length > 1 %} (+{{ r.candidates | length - 1 }}){% endif %}{% else %}{{ r.cur_ver or '-' }}{% endif %}</span>
<span class="tip c-img" data-tip="{{ r.current }}"><span class="trunc sub mono">{{ r.current }}</span></span>
<span>{{ bios_chip(r.status) }}</span>
<span class="chev" aria-hidden="true">&#9662;</span>
</summary>
<div class="dbody">
{% for w in r.warnings %}<div class="notice">{{ w }}</div>{% endfor %}
<dl class="kv">
<dt>Manifest line</dt><dd class="mono">{{ r.line }}</dd>
<dt>Current file</dt><dd><span class="mono">{{ r.current }}</span> <span class="sub">{{ r.cur_ver }}</span></dd>
<dt>Dell latest</dt>
<dd>
{% if r.candidates | length > 1 %}
<label for="pick_{{ r.id }}" class="vh">BIOS file for line {{ r.sub }}</label>
<select class="field" id="pick_{{ r.id }}" name="pick_{{ r.id }}" style="max-width: 100%">
{% for c in r.candidates %}<option value="{{ loop.index0 }}">{{ c.file }} ({{ c.version }}, {{ c.date }}) - {{ c.models | join(', ') | truncate(70) }}</option>{% endfor %}
</select>
{% elif r.candidates %}
{% set c = r.candidates[0] %}
<span class="mono">{{ c.file }}</span>
<div class="sub">{{ c.version }}, {{ c.date }}, {{ size(c.size) }}; {{ c.models | join(', ') }}</div>
<div class="sub mono" style="overflow-wrap: anywhere">sha256 {{ c.sha256 }}</div>
{% else %}<span class="muted">none found</span>{% endif %}
</dd>
</dl>
{% for n in r.notes %}<div class="sub">{{ n }}</div>{% endfor %}
</div>
</details>
</div>
{%- endmacro %}
{% block extra_head %}
<style>
.sec-h { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 8px 16px; padding: 18px 24px 12px; }
.sec-h .sub { margin-top: 2px; }
summary.sec-h { flex-wrap: nowrap; }
.state-line { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; margin-top: 6px; }
.strip { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; padding: 12px 20px; font-size: 13px; }
.strip form { margin: 0 0 0 auto; }
.pulse { width: 10px; height: 10px; border-radius: 5px; background: var(--run); flex: none; animation: pulse 1.4s ease-in-out infinite; }
@keyframes pulse { 50% { opacity: .35; } }
@media (prefers-reduced-motion: reduce) { .pulse { animation: none; } }
.dl-row > .pick { position: absolute; left: 20px; top: 13px; z-index: 1; }
.dl-row > .pick input { width: 18px; height: 18px; margin: 0; }
.dgrid { display: grid; grid-template-columns: minmax(0, 1fr) 150px minmax(0, 200px) 130px 14px; align-items: center; gap: 0 16px;
padding: 12px 24px 12px 56px; font-size: 14px; min-height: 24px; }
.dgrid .c-chg { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 13px; }
.dhead { padding-top: 4px; padding-bottom: 6px; font-size: 12px; }
.dbody { padding: 2px 24px 16px 56px; font-size: 13px; display: flex; flex-direction: column; gap: 8px; }
.kv { display: grid; grid-template-columns: 130px minmax(0, 1fr); gap: 4px 16px; margin: 0; }
.kv dt { font-weight: 400; color: var(--muted); }
.kv dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
details.fold { border-top: 1px solid var(--line); }
details.fold > summary { padding: 0 24px; min-height: 44px; }
.list-foot { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px; padding: 14px 24px; border-top: 1px solid var(--line); }
.list-foot .sub { flex: 1 1 260px; }
.sgrid { display: grid; grid-template-columns: minmax(0, 1fr) 150px 80px 170px 14px; align-items: center; gap: 0 16px;
padding: 12px 24px 12px 56px; font-size: 14px; min-height: 24px; }
.stat { display: flex; align-items: center; gap: 8px; min-width: 0; }
.bar { flex: 1 1 auto; height: 6px; border-radius: 3px; background: var(--track-empty); overflow: hidden; min-width: 30px; }
.bar > i { display: block; height: 100%; background: var(--run); }
.pv { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.pv li { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); gap: 4px 10px; align-items: baseline; padding: 8px 12px; border: 1px solid var(--line-2); border-radius: 8px; font-size: 13px; }
.pv li .mono { overflow-wrap: anywhere; min-width: 0; }
.pv .old { color: var(--del-text); } .pv .new { color: var(--add-text); }
.pv .why { grid-column: 1 / -1; }
.agrid { display: grid; grid-template-columns: 170px 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 210px; font-size: 13px; line-height: 1.55; display: flex; flex-direction: column; gap: 8px; }
.agrid .when { white-space: nowrap; }
.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; }
.mgrid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr); gap: 4px 16px; padding: 10px 24px; border-top: 1px solid var(--line); font-size: 13px; }
.mgrid .mono { overflow-wrap: anywhere; }
@container (max-width: 860px) {
.dgrid { grid-template-columns: minmax(0, 1fr) 150px auto 14px; }
.dgrid .c-img, .dhead .c-img { display: none; }
.sgrid { grid-template-columns: minmax(0, 1fr) 70px 190px 14px; }
.sgrid .c-kind { display: none; }
.mgrid { grid-template-columns: minmax(0, 1fr); }
}
@container (max-width: 560px) {
.dgrid, .sgrid { grid-template-columns: minmax(0, 1fr) auto 14px; padding: 12px 16px 12px 50px; gap: 4px 10px; }
.dgrid .c-chg { grid-column: 1 / 2; grid-row: 2; font-size: 12px; color: var(--muted); }
.sgrid .c-size { display: none; }
.sgrid .stat { grid-column: 1 / -1; grid-row: 2; }
.dhead { display: none; }
.dl-row > .pick { left: 16px; }
.dbody { padding: 2px 16px 14px; }
.kv { grid-template-columns: minmax(0, 1fr); }
.kv dd { margin-bottom: 6px; }
.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; }
.sec-h, .list-foot { padding-left: 16px; padding-right: 16px; }
.pv li { grid-template-columns: minmax(0, 1fr); }
.mgrid { padding: 10px 16px; }
}
</style>
{% endblock %}
{% block content %}
{% set chk = prog.check %}
{% set dl = prog.download %}
{% set n_drv = scan.drivers | selectattr('status', 'equalto', 'update') | list | length if scan else 0 %}
{% set n_bios = scan.bios | selectattr('status', 'equalto', 'update') | list | length if scan else 0 %}
<header class="ui-head">
<div>
<div class="eyebrow-t">Updates</div>
<h1>Dell drivers and BIOS</h1>
<div class="state-line">
{% if prog.check_running %}{{ ui.chip('Checking', 'run') }}
{% elif not scan %}{{ ui.chip('Not checked yet', 'off') }}
{% elif n_drv or n_bios %}{{ ui.chip(n_drv ~ ' driver / ' ~ n_bios ~ ' BIOS update' ~ ('s' if n_drv + n_bios != 1), 'act') }}
{% else %}{{ ui.chip('Up to date', 'ok') }}{% endif %}
{% if scan %}<span class="sub">Checked {{ scan.checked | local_dt }}</span>{% endif %}
</div>
</div>
<div class="actions">
<details class="more">
<summary class="fbtn" style="background: var(--surface); min-height: 36px">Speed 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() }}">
<label for="maxMbps" style="font-size: 13px; font-weight: 600">Download speed cap (MB/s)</label>
<div style="display: flex; gap: 8px"><input id="maxMbps" 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">Shared by every download on the Updates pages; 0 means no cap. The site link is about 400 Mbps (50 MB/s).</span>
</form>
</details>
{% if prog.check_running %}
<form method="post" action="{{ url_for('dell_updates.cancel') }}" style="margin: 0">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="job" value="check">
<button type="submit" class="ui-btn">Stop check</button>
</form>
{% else %}
<form method="post" action="{{ url_for('dell_updates.check') }}" style="margin: 0">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="ui-btn pri" {% if prog.download_running %}disabled title="A download is running"{% endif %}>Check now</button>
</form>
{% endif %}
</div>
</header>
<div class="ui-stack">
{# ---- Progress and job outcomes ------------------------------------------ #}
{% if prog.check_running %}
<div class="panel strip" id="checkJob" role="status">
<span class="pulse" aria-hidden="true"></span>
<span>Checking Dell's catalogs: <span id="checkStep">{{ chk.step or 'Starting' }}...</span></span>
</div>
{% elif chk.state == 'error' %}
<div class="errbox">Last check failed: {{ chk.error }}</div>
{% elif chk.state == 'interrupted' %}
<div class="notice">The last check stopped before it finished. Run it again.</div>
{% endif %}
{% if prog.download_running %}
<div class="panel strip" role="status">
<span class="pulse" aria-hidden="true"></span>
<span>Downloading at {{ '%g MB/s' | format(settings.max_mbps) if settings.max_mbps else 'full speed' }}. Progress per item is under <a href="#staged">Staged downloads</a>; you can leave this page.</span>
<form method="post" action="{{ url_for('dell_updates.cancel') }}"
onsubmit="return confirm('Stop the download? Partial files are kept and resume next time.');">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="job" value="download">
<button type="submit" class="ui-btn sm">Cancel download</button>
</form>
</div>
{% endif %}
{% if scan and history and history[0].at and scan.checked and history[0].at > scan.checked %}
<div class="notice">These results are from before the last apply ({{ history[0].at | local_dt }}), so rows it changed can still
show as updates. Run Check now again to refresh them.</div>
{% endif %}
<details>
<summary class="disc"><span class="chev" aria-hidden="true">&#9662;</span>How Dell updates work</summary>
<p class="sub" style="margin: 4px 0 0; max-width: 900px; line-height: 1.6">
Compares every Win11 entry in the three images' <span class="mono">HardwareDriver.json</span> and every line of the WinPE
BIOS manifest <span class="mono">models.txt</span> with Dell's current catalogs. Downloads are checked against Dell's sha256,
driver packs are repacked into the zip PESetup expects, and nothing on the share changes until you preview and apply.
Applying puts the new file next to the one it replaces, rewrites the maps and <span class="mono">models.txt</span> (backed
up first, never reordered) and never deletes old zips or BIOS files.
</p>
</details>
{% if not scan %}
{% if not prog.check_running %}
<section class="panel" style="padding: 22px 24px">
<p class="muted" style="margin: 0">Not checked yet. Check now fetches Dell's two catalog files (under 1 MB) and the small
per-model BIOS catalogs it needs, then compares.</p>
</section>
{% endif %}
{% else %}
{% set cats = scan.catalogs or {} %}
{% for e in scan.map_errors %}<div class="notice bad">{{ e }}</div>{% endfor %}
{% if scan.cab_errors %}<div class="notice bad">{{ scan.cab_errors | length }} per-model catalog(s) failed to load; their BIOS lines show as Catalog error.</div>{% endif %}
<form method="post" action="{{ url_for('dell_updates.download') }}" id="dlForm" class="ui-stack"
onsubmit="var n=this.querySelectorAll('input[name=drv]:checked,input[name=bios]:checked').length; if(!n){alert('Select at least one row.');return false;} return confirm('Download ' + n + ' selected row(s) from downloads.dell.com at the speed cap? Driver packs are 1 to 4 GB each.');">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
{# ---- Driver packs ------------------------------------------------------ #}
{% set cur_d = scan.drivers | selectattr('status', 'equalto', 'current') | list %}
<section class="panel ui-list" id="drvTable" aria-labelledby="drv-h">
<div class="sec-h">
<div><h2 id="drv-h" class="sec">Win11 driver packs</h2>
<div class="sub">One row per zip in use; standard and engineer share their zips. Updates are ticked unless a warning needs a decision.</div></div>
<span class="sub">{{ scan.drivers | length }} pack{{ 's' if scan.drivers | length != 1 }}</span>
</div>
<div class="dgrid dhead ui-rowhead" aria-hidden="true"><span>Models</span><span>Version</span><span class="c-img">Images</span><span>Status</span><span></span></div>
{% for r in scan.drivers if r.status != 'current' %}{{ drv_row(r) }}{% endfor %}
{% if cur_d %}
<details class="fold">
<summary class="disc"><span class="chev" aria-hidden="true">&#9662;</span>Show {{ cur_d | length }} current pack{{ 's' if cur_d | length != 1 }}</summary>
{% for r in cur_d %}{{ drv_row(r) }}{% endfor %}
</details>
{% endif %}
<div class="hidden-bar">An entry whose current pack carries NVIDIA drivers is skipped at apply time if the Dell pack has no NVIDIA INFs.</div>
</section>
{# ---- BIOS -------------------------------------------------------------- #}
{% set cur_b = scan.bios | selectattr('status', 'equalto', 'current') | list %}
<section class="panel ui-list" id="biosTable" aria-labelledby="bios-h">
<div class="sec-h">
<div><h2 id="bios-h" class="sec">BIOS (models.txt)</h2>
<div class="sub">One row per manifest line. check-bios.cmd flashes the file of the first line whose text is in the machine's model name.</div></div>
<span class="sub">{{ scan.bios | length }} line{{ 's' if scan.bios | length != 1 }}</span>
</div>
<div class="dgrid dhead ui-rowhead" aria-hidden="true"><span>Line</span><span>Version</span><span class="c-img">Current file</span><span>Status</span><span></span></div>
{% for r in scan.bios if r.status != 'current' %}{{ bios_row(r) }}{% endfor %}
{% if cur_b %}
<details class="fold">
<summary class="disc"><span class="chev" aria-hidden="true">&#9662;</span>Show {{ cur_b | length }} current line{{ 's' if cur_b | length != 1 }}</summary>
{% for r in cur_b %}{{ bios_row(r) }}{% endfor %}
</details>
{% endif %}
<div class="hidden-bar">Each Dell model is credited to the first line that catches it. Applying changes only the file and version fields of the ticked lines; their order and the comments stay as they are.</div>
<div class="list-foot">
<button type="submit" class="ui-btn pri" {% if prog.download_running or prog.check_running %}disabled{% endif %}>Download selected</button>
<span class="sub">Ticked driver packs and BIOS lines. Runs in the background at {{ '%g MB/s' | format(settings.max_mbps) if settings.max_mbps else 'full speed (no cap)' }};
you can leave this page. Already staged items are not fetched again.</span>
</div>
</section>
</form>
<div class="sub">Driver pack catalog dated <span class="mono">{{ cats.drivers | local_dt }}</span>, catalog index dated
<span class="mono">{{ cats.index | local_dt }}</span>. Maps read: {{ (scan.images or []) | join(', ') or 'none' }}.</div>
{# ---- Missing coverage ---------------------------------------------------- #}
{% if scan.missing %}
<section class="panel ui-list" aria-labelledby="miss-h">
<details>
<summary class="sec-h" style="align-items: center">
<div><h2 id="miss-h" class="sec" style="font-size: 15px">Seen models without coverage</h2>
<div class="sub">For information only: new entries are added by hand (see Hardware coverage).</div></div>
<span style="display: flex; align-items: center; gap: 10px">{{ ui.chip(scan.missing | length ~ ' model' ~ ('s' if scan.missing | length != 1), 'neutral') }}<span class="chev" aria-hidden="true">&#9662;</span></span>
</summary>
{% for m in scan.missing %}
<div class="mgrid">
<div><span class="mono">{{ m.model }}</span><div class="sub">SKU {{ m.skus | join(', ') or '-' }}. Missing
{% if m.no_drivers %}drivers ({{ m.no_drivers | map('replace', 'gea-', '') | join(', ') }}){% endif %}{% if m.no_drivers and m.no_bios %}, {% endif %}{% if m.no_bios %}BIOS line{% endif %}</div></div>
<div><span class="sub">Dell pack</span><div>{% if m.pack %}<span class="mono">{{ m.pack.name }}</span> ({{ m.pack.ver }}, {{ size(m.pack.size) }}){% elif m.no_drivers %}<span class="muted">none found</span>{% else %}-{% endif %}</div></div>
<div><span class="sub">Dell BIOS</span><div>{% if m.bios %}<span class="mono">{{ m.bios.file }}</span> ({{ m.bios.version }}){% elif m.no_bios %}<span class="muted">none found</span>{% else %}-{% endif %}</div></div>
</div>
{% endfor %}
</details>
</section>
{% endif %}
{% endif %}
{# ---- Staged downloads ---------------------------------------------------- #}
<section class="panel ui-list" id="staged" aria-labelledby="st-h">
<div class="sec-h">
<div><h2 id="st-h" class="sec">Staged downloads</h2>
<div class="sub">Tick what to apply, then preview. Nothing on the share changes until you apply.</div></div>
{% if dl.state %}<span class="sub">Last run {{ dl.state }}{% if dl.finished %} {{ dl.finished | local_dt }}{% endif %}</span>{% endif %}
</div>
{% if dl.error %}<div class="errbox" style="margin: 0 24px 12px">{{ dl.error }}</div>{% endif %}
{% if staged %}
<form method="post" action="{{ url_for('dell_updates.preview') }}">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
{% for it in staged %}
{% set label = it.zip if it.kind == 'driver' else it.name %}
<div class="ui-row dl-row">
{% if it.status in ('ready', 'error', 'cancelled', 'interrupted', 'applied') %}
<div class="pick"><input type="checkbox" class="form-check-input" name="key" value="{{ it.key }}" id="key-{{ it.key }}" {% if it.status == 'ready' %}checked{% endif %}>
<label for="key-{{ it.key }}" class="vh">Select {{ label }}</label></div>
{% endif %}
<details {% if it.error %}open{% endif %}>
<summary class="sgrid">
<span class="tip" data-tip="{{ label }}"><span class="trunc mono">{{ label }}</span></span>
<span class="sub c-kind">{{ 'Driver pack' if it.kind == 'driver' else 'BIOS' }} {{ it.ver }}</span>
<span class="sub c-size">{{ size(it.size_file or it.size) }}</span>
<span id="st-{{ it.key }}" class="stat">
{{ stage_chip(it.status) }}
{% if it.status in ('queued', 'downloading', 'verified', 'extracting', 'zipping') %}
<span class="bar" aria-hidden="true"><i style="width: {{ it.percent or 0 }}%"></i></span>
<span class="sub js-pct">{{ it.percent if it.percent is not none else 0 }}%</span>
{% endif %}
</span>
<span class="chev" aria-hidden="true">&#9662;</span>
</summary>
<div class="dbody">
{% if it.error %}<div class="errbox">{{ it.error }}</div>{% endif %}
<dl class="kv">
{% if it.kind == 'driver' %}
<dt>Dell file</dt><dd class="mono">{{ it.name }}</dd>
<dt>Replaces</dt><dd>{% for c in it.replaces %}<div class="mono">{{ c }}</div>{% endfor %}</dd>
{% if it.infs %}<dt>Contents</dt><dd>{{ it.infs }} INFs, {{ it.nvidia_infs }} NVIDIA</dd>{% endif %}
{% else %}
<dt>BIOS lines</dt><dd>{% for ln in it.lines %}<div class="mono">{{ ln.sub }}</div>{% endfor %}</dd>
{% endif %}
<dt>Version</dt><dd>{{ it.ver }}</dd>
{% if it.applied %}<dt>Applied</dt><dd class="mono">apply {{ it.applied }}</dd>{% endif %}
</dl>
</div>
</details>
</div>
{% endfor %}
<div class="list-foot">
<button type="submit" class="ui-btn pri" {% if prog.download_running %}disabled{% endif %}>Preview apply</button>
<button type="submit" class="ui-btn danger" formaction="{{ url_for('dell_updates.discard') }}"
onclick="return confirm('Delete the ticked items from the staging area? Files on the share are not touched.');"
{% if prog.download_running %}disabled{% endif %}>Discard selected</button>
</div>
</form>
{% else %}
<div class="hidden-bar">Nothing staged. Tick rows above and use Download selected.</div>
{% endif %}
</section>
{# ---- Preview ------------------------------------------------------------- #}
{% if preview %}
<section class="panel" id="preview" aria-labelledby="pv-h" style="padding: 18px 24px; display: flex; flex-direction: column; gap: 12px">
<h2 id="pv-h" class="sec">Preview: what Apply will change</h2>
{% for image, rows in preview.drivers.items() %}
<div>
<div style="font-weight: 600; margin-bottom: 6px">{{ image }} <span class="sub mono">HardwareDriver.json</span></div>
{% if rows %}
<ul class="pv">
{% for r in rows %}
<li><span class="mono old">{{ r.old }}</span><span class="sub">to</span><span class="mono new">{{ r.new }}</span>
<span class="sub why">{{ r.models }} | <span class="mono">{{ r.dir }}</span></span></li>
{% endfor %}
</ul>
{% else %}<div class="sub">No change.</div>{% endif %}
</div>
{% endfor %}
<div>
<div style="font-weight: 600; margin-bottom: 6px">models.txt</div>
{% if preview.bios %}
<ul class="pv">{% for b in preview.bios %}<li><span class="mono old">{{ b.old }}</span><span class="sub">to</span><span class="mono new">{{ b.new }}</span></li>{% endfor %}</ul>
{% else %}<div class="sub">No change.</div>{% endif %}
</div>
{% if preview.errors %}<div class="errbox">{{ preview.errors | join('; ') }}</div>{% endif %}
{% for s in preview.skipped %}<div class="notice">Will skip {{ s }}</div>{% endfor %}
{% if preview.missing %}<div class="sub">Not ready, ignored: {{ preview.missing | join(', ') }}</div>{% endif %}
<form method="post" action="{{ url_for('dell_updates.apply') }}" style="display: flex; flex-wrap: wrap; gap: 10px; justify-content: flex-end; margin: 0"
onsubmit="return confirm('Apply these changes to the live share? The maps and models.txt are backed up first; old files are kept.');">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
{% for k in preview_keys %}<input type="hidden" name="key" value="{{ k }}">{% endfor %}
<a href="{{ url_for('dell_updates.page') }}" class="ui-btn">Cancel</a>
<button type="submit" class="ui-btn pri">Apply</button>
</form>
</section>
{% endif %}
{# ---- History ------------------------------------------------------------- #}
{% if history %}
<section class="panel ui-list" aria-labelledby="h-h">
<div class="sec-h"><div><h2 id="h-h" class="sec">Applied</h2>
<div class="sub">Rollback opens a review page first; it restores only files nobody has changed since the apply.</div></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 class="tip" data-tip="{{ h.title }}"><span class="trunc">{{ h.title }}</span></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.backups %}<div class="sub">Backups: {% for p, b in h.backups.items() %}<span class="mono">{{ b.rsplit('/', 1)[-1] }}</span>{{ ', ' if not loop.last }}{% endfor %}</div>{% 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>.</div>
</div>
</details>
{% if can %}<div class="act"><a class="ui-btn" href="{{ url_for('dell_updates.rollback_review', apply_id=h.id, back='dell') }}">Review rollback</a></div>{% endif %}
</div>
{% endfor %}
</section>
{% endif %}
</div>
{% if prog.check_running or prog.download_running %}
<script>
// Poll the background jobs; update progress in place, reload when they end.
(function () {
var URL = "{{ url_for('dell_updates.status') }}";
// A preview is a POST response: reloading it would resubmit the form,
// so there the poller only stops when the jobs end.
var RELOAD = {{ 'false' if preview else 'true' }};
function tick() {
fetch(URL, { credentials: 'same-origin' })
.then(function (r) { return r.json(); })
.then(function (j) {
if (!j.check_running && !j.download_running) {
if (RELOAD) location.replace("{{ url_for('dell_updates.page') }}");
return;
}
var step = document.getElementById('checkStep');
if (step && j.check.step) step.textContent = j.check.step + '...';
Object.keys(j.download.items || {}).forEach(function (k) {
var it = j.download.items[k], cell = document.getElementById('st-' + k);
if (!cell) return;
var chip = cell.querySelector('.chip');
if (chip && it.status) chip.textContent = it.status.charAt(0).toUpperCase() + it.status.slice(1);
var bar = cell.querySelector('.bar > i'), pct = cell.querySelector('.js-pct');
if (bar && it.percent != null) bar.style.width = it.percent + '%';
if (pct && it.percent != null) pct.textContent = it.percent + '%';
});
setTimeout(tick, 3000);
})
.catch(function () { setTimeout(tick, 6000); });
}
setTimeout(tick, 2000);
})();
</script>
{% endif %}
{% endblock %}