Webapp redesign phase 6: the remaining pages

Restyled to the same row pattern (one line, details in an expandable
body, one action plus a menu, tokens for light and dark):
- hardware coverage, audit log (family chips, destructive actions in
  red, client-side filters, first 300 rows), Clonezilla backups,
  enrollment packages (active package marked and undeletable, MCL flags
  with a visible legend)
- image Overview, OS and packages, Unattend (sticky save bar), Import
- Dell, iPXE, Blancco and BPRT update pages; Dell history links to the
  rollback review instead of rolling back on one click
- boot menu editor (tabs, rows with a menu, lint chips that jump to the
  line, tokens instead of hard-coded dark colours) and the imaging
  session detail page

Fixes found on the way:
- BPRT batch Activate never asked to confirm: a hidden input named
  'confirm' shadowed window.confirm in the inline handler, so the form
  submitted without a prompt. It now calls window.confirm.
- Backup upload refused when a backup with that name exists, unless
  'Replace the existing backup' is ticked (it overwrote silently).
- app.js: em dashes in comments replaced (plain ASCII).
This commit is contained in:
cproudlock
2026-10-14 14:29:03 -04:00
parent a6f95fcca1
commit fd00d5ff89
17 changed files with 3913 additions and 3130 deletions

View File

@@ -560,6 +560,10 @@ def clonezilla_upload():
os.makedirs(config.CLONEZILLA_SHARE, exist_ok=True) os.makedirs(config.CLONEZILLA_SHARE, exist_ok=True)
dest = os.path.join(config.CLONEZILLA_SHARE, filename) dest = os.path.join(config.CLONEZILLA_SHARE, filename)
# A backup is a whole disk image: never replace one by accident.
if os.path.exists(dest) and request.form.get("overwrite") != "1":
flash(f"{filename} already exists. Tick Replace the existing backup to overwrite it.", "danger")
return redirect(url_for("clonezilla_backups"))
f.save(dest) f.save(dest)
audit("BACKUP_UPLOAD", filename) audit("BACKUP_UPLOAD", filename)
flash(f"Uploaded {filename} successfully.", "success") flash(f"Uploaded {filename} successfully.", "success")

View File

@@ -203,7 +203,7 @@ document.addEventListener('DOMContentLoaded', function () {
}); });
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// AutoLogon Enabled toggle — keep hidden input in sync // AutoLogon Enabled toggle - keep hidden input in sync
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
var autologonToggle = document.getElementById('autologonEnabledToggle'); var autologonToggle = document.getElementById('autologonEnabledToggle');
if (autologonToggle) { if (autologonToggle) {
@@ -216,7 +216,7 @@ document.addEventListener('DOMContentLoaded', function () {
} }
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// OOBE toggle switches — keep hidden input in sync // OOBE toggle switches - keep hidden input in sync
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
document.querySelectorAll('.oobe-toggle').forEach(function (cb) { document.querySelectorAll('.oobe-toggle').forEach(function (cb) {
cb.addEventListener('change', function () { cb.addEventListener('change', function () {

View File

@@ -167,6 +167,8 @@
var rd = rawDirty(); var rd = rawDirty();
var head = document.querySelector('.image-head'); var head = document.querySelector('.image-head');
if (head) head.classList.toggle('dirty', fd || rd); if (head) head.classList.toggle('dirty', fd || rd);
var page = document.getElementById('unaPage');
if (page) page.classList.toggle('dirty', fd || rd);
if (reviewBtn) reviewBtn.disabled = !(fd || rd); if (reviewBtn) reviewBtn.disabled = !(fd || rd);
var now = sectionState(f); var now = sectionState(f);
var groups = {}; var groups = {};
@@ -415,7 +417,7 @@
return box; return box;
} }
var bad = lint.new_errors > 0; var bad = lint.new_errors > 0;
var a = el('div', 'alert mb-0 ' + (bad ? 'alert-danger' : 'alert-warning')); var a = el('div', 'notice una-lintbox' + (bad ? ' bad' : ''));
a.appendChild(el('strong', '', bad a.appendChild(el('strong', '', bad
? 'This change adds ' + lint.new_errors + ' lint error' + (lint.new_errors === 1 ? '' : 's') + ? 'This change adds ' + lint.new_errors + ' lint error' + (lint.new_errors === 1 ? '' : 's') +
' and will not be saved. One error invalidates the whole answer file for its pass.' ' and will not be saved. One error invalidates the whole answer file for its pass.'
@@ -423,8 +425,8 @@
var ul = el('ul', 'una-lint-list'); var ul = el('ul', 'una-lint-list');
lint.findings.forEach(function (f) { lint.findings.forEach(function (f) {
var li = el('li'); var li = el('li');
li.appendChild(el('span', 'badge ' + (f.level === 'ERROR' ? 'badge-danger' : 'badge-warning'), f.level)); li.appendChild(el('span', 'chip ' + (f.level === 'ERROR' ? 'c-err' : 'c-warn'), f.level));
if (f['new']) li.appendChild(el('span', 'badge badge-info ms-1', 'new')); if (f['new']) li.appendChild(el('span', 'chip c-edit', 'new'));
li.appendChild(document.createTextNode(' ' + f.message)); li.appendChild(document.createTextNode(' ' + f.message));
ul.appendChild(li); ul.appendChild(li);
}); });
@@ -462,7 +464,7 @@
if (!r.ok) { out.innerHTML = ''; fail(r.data.error || 'Preview failed.'); return; } if (!r.ok) { out.innerHTML = ''; fail(r.data.error || 'Preview failed.'); return; }
var n = PxeImage.renderDiff(out, r.data.files); var n = PxeImage.renderDiff(out, r.data.files);
if ((mode === 'form' && rd) || (mode === 'raw' && fd)) { if ((mode === 'form' && rd) || (mode === 'raw' && fd)) {
out.insertBefore(el('div', 'alert alert-info', out.insertBefore(el('div', 'notice info',
(mode === 'form' ? 'Your Raw XML edits are' : 'Your Form edits are') + (mode === 'form' ? 'Your Raw XML edits are' : 'Your Form edits are') +
' not part of this save and are lost when the page reloads after it.'), out.firstChild); ' not part of this save and are lost when the page reloads after it.'), out.firstChild);
} }

View File

@@ -1,60 +1,176 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Audit Log - PXE Server Manager{% endblock %} {% import '_ui.html' as ui %}
{% block title %}Audit log - PXE Manager{% endblock %}
{# Audit log, newest first. One line per entry: time, source, action chip and
the details truncated with a tip; the full details are in the row body.
Chips are coloured by action family; destructive actions use c-err. The
whole log is rendered; the script shows the first 300 matches and a
"Show more" control. #}
{% set families = [
('images', 'Images', 'w11'),
('drivers', 'Drivers and BIOS', 'off'),
('enrollment', 'Enrollment', 'ok'),
('blancco', 'Blancco', 'neutral'),
('imaging', 'Imaging', 'w10'),
('boot', 'Boot files', 'neutral'),
('backups', 'Backups', 'w10'),
('other', 'Other', 'neutral'),
] %}
{% set fam_kind = {} %}{% for k, l, c in families %}{% set _ = fam_kind.update({k: c}) %}{% endfor %}
{% macro family(a) -%}
{%- if a.startswith('IMAGING_') -%}imaging
{%- elif a.startswith(('IMAGE_', 'UNATTEND_', 'SHOPFLOOR_MENU', 'STARTNET', 'PACKAGE_')) -%}images
{%- elif a.startswith(('DRIVER_', 'DELL_', 'HARDWARE_', 'BIOS_')) -%}drivers
{%- elif a.startswith(('ENROLLMENT_', 'PPKG_', 'MCL_', 'BPRT_')) -%}enrollment
{%- elif a.startswith(('BLANCCO_', 'REPORT_')) -%}blancco
{%- elif a.startswith('IPXE_') -%}boot
{%- elif a.startswith('BACKUP_') -%}backups
{%- else -%}other{%- endif -%}
{%- endmacro %}
{% block extra_head %}
<style>
.agrid { display: grid; grid-template-columns: 156px 116px 210px minmax(0, 1fr) 14px; align-items: center;
gap: 0 14px; padding: 9px 24px; font-size: 14px; min-height: 24px; }
.ahead { font-size: 12px; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); padding-top: 6px; padding-bottom: 6px; }
.a-when, .a-src { font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.a-when { color: var(--muted); }
.a-act { min-width: 0; }
.a-act .chip { max-width: 100%; overflow: hidden; text-overflow: ellipsis; vertical-align: middle; }
.abody { padding: 2px 24px 14px 310px; font-size: 13px; display: flex; flex-direction: column; gap: 8px; }
.abody .full { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; }
.abody .raw { margin: 0; font-size: 12px; color: var(--muted); white-space: pre-wrap; overflow-wrap: anywhere; }
.al-empty { padding: 28px 24px; text-align: center; color: var(--muted); border-top: 1px solid var(--line); }
.afoot { justify-content: space-between; }
@container (max-width: 820px) {
.agrid { grid-template-columns: 148px minmax(0, 1fr) 14px; }
.agrid .a-src { display: none; }
.agrid .a-det { grid-column: 1 / -1; grid-row: 2; }
.agrid .chev { grid-column: 3; grid-row: 1; }
.ahead { display: none; }
.abody { padding-left: 24px; }
}
@container (max-width: 520px) {
.agrid { grid-template-columns: auto minmax(0, 1fr) 14px; padding: 10px 16px; gap: 4px 10px; }
.agrid .a-when { overflow: visible; }
.agrid .a-when { grid-column: 1; grid-row: 1; }
.agrid .a-act { grid-column: 2; grid-row: 1; text-align: right; }
.abody { padding: 2px 16px 12px; }
}
</style>
{% endblock %}
{% block content %} {% block content %}
<div class="page-header"> {{ ui.head('Audit log', 'Tools', (entries | length) ~ ' entr' ~ ('ies' if entries | length != 1 else 'y') ~ ', newest first.') }}
<h1>Audit Log</h1>
<div class="header-actions">
<span class="badge badge-secondary badge-lg">{{ entries|length }} entries</span>
</div>
</div>
<div class="section-card"> <section class="panel ui-list" aria-labelledby="al-h">
<div class="section-title">Activity History</div> <h2 id="al-h" class="vh">Activity history</h2>
{% if entries %} {% if entries %}
<div class="table-container"> {# Parse once, so the filter pills can show counts. #}
<table class="data-table"> {% set rows = [] %}
<thead> {% for entry in entries %}
<tr> {% set parts = entry.split(' ', 1) %}
<th style="width: 180px;">Timestamp</th> {% set r = {'raw': entry, 'when': '', 'src': '', 'action': '', 'detail': entry} %}
<th style="width: 130px;">Source</th> {% if parts | length == 2 %}
<th style="width: 180px;">Action</th> {% set _ = r.update({'when': parts[0].split(',')[0], 'detail': parts[1]}) %}
<th>Details</th> {% set meta = parts[1].split('] ', 1) %}
</tr> {% if meta | length == 2 %}
</thead> {% set _ = r.update({'src': meta[0].lstrip('['), 'detail': meta[1]}) %}
<tbody> {% set ad = meta[1].split(': ', 1) %}
{% for entry in entries %} {% if ad | length == 2 %}{% set _ = r.update({'action': ad[0], 'detail': ad[1]}) %}{% endif %}
<tr> {% endif %}
{% set parts = entry.split(' ', 1) %} {% endif %}
{% if parts|length == 2 %} {% set fam = family(r.action) if r.action else 'other' %}
{% set meta = parts[1].split('] ', 1) %} {% set _ = r.update({'fam': fam, 'bad': r.action and ('DELETE' in r.action or 'ROLLBACK' in r.action or 'REMOVE' in r.action)}) %}
<td class="mono">{{ parts[0] }}</td> {% set _ = rows.append(r) %}
{% if meta|length == 2 %} {% endfor %}
<td class="mono">{{ meta[0].lstrip('[') }}</td> {% set bad_count = rows | selectattr('bad') | list | length %}
{% set action_detail = meta[1].split(': ', 1) %}
{% if action_detail|length == 2 %} <div style="display: flex; flex-wrap: wrap; align-items: center; gap: 10px; padding: 16px 24px">
<td><span class="badge badge-primary">{{ action_detail[0] }}</span></td> <div role="search" style="flex: 1 1 220px; min-width: 160px; display: flex">
<td>{{ action_detail[1] }}</td> <label for="aq" class="vh">Filter by time, source, action or details</label>
{% else %} <input id="aq" type="search" class="field" style="flex: 1; min-width: 0" placeholder="Time, source, action or details">
<td colspan="2">{{ meta[1] }}</td> </div>
{% endif %} <div role="group" aria-label="Action family" id="afilters" style="display: flex; gap: 6px; flex-wrap: wrap">
{% else %} <button type="button" class="fbtn" data-f="" aria-pressed="true">All</button>
<td colspan="3">{{ parts[1] }}</td> {% if bad_count %}<button type="button" class="fbtn" data-f="destructive" aria-pressed="false">Destructive {{ bad_count }}</button>{% endif %}
{% endif %} {% for k, label, c in families %}
{% else %} {% set n = rows | selectattr('fam', 'equalto', k) | list | length %}
<td colspan="4">{{ entry }}</td> {% if n %}<button type="button" class="fbtn" data-f="{{ k }}" aria-pressed="false">{{ label }} {{ n }}</button>{% endif %}
{% endif %} {% endfor %}
</tr> </div>
{% endfor %} </div>
</tbody> <div class="agrid ahead" aria-hidden="true"><span>Time</span><span class="a-src">Source</span><span>Action</span><span class="a-det">Details</span><span></span></div>
</table>
{% for r in rows %}
<div class="ui-row arow" data-f="{{ r.fam }}{{ ' destructive' if r.bad }}" data-q="{{ r.raw | lower }}">
<details>
<summary class="agrid">
<span class="a-when mono">{{ r.when or '-' }}</span>
<span class="a-src mono">{{ r.src or '-' }}</span>
<span class="a-act">{% if r.action %}<span class="tip inl" data-tip="{{ r.action }}" style="max-width: 100%">{{ ui.chip(r.action, 'err' if r.bad else fam_kind[r.fam]) }}</span>{% else %}<span class="sub">-</span>{% endif %}</span>
<span class="a-det tip" data-tip="{{ r.detail }}"><span class="trunc">{{ r.detail or '-' }}</span></span>
<span class="chev" aria-hidden="true">&#9662;</span>
</summary>
<div class="abody">
<p class="full">{{ r.detail or '(no details)' }}</p>
<p class="raw mono">{% if r.src %}From <span>{{ r.src }}</span> | {% endif %}{{ r.raw }}</p>
</div>
</details>
</div>
{% endfor %}
<div class="hidden-bar" id="anomatch" hidden><span>No entry matches.</span></div>
<div class="hidden-bar afoot" id="amore" hidden>
<span id="amoreText" aria-live="polite"></span>
<span style="display: flex; gap: 8px">
<button type="button" class="ui-btn sm" id="amoreBtn">Show 300 more</button>
<button type="button" class="ui-btn sm ghost" id="aallBtn">Show all</button>
</span>
</div> </div>
{% else %} {% else %}
<div class="text-center text-muted py-5"> <div class="al-empty">
<i class="bi bi-list-check" style="font-size: 2rem; opacity: 0.4;"></i> No audit log entries yet. Image imports, unattend edits, backup operations and other changes are logged here.
<p class="mt-2 mb-1">No audit log entries yet.</p>
<p class="small mb-0">Actions like image imports, unattend edits, and backup operations will be logged here.</p>
</div> </div>
{% endif %} {% endif %}
</div> </section>
{% endblock %}
{% block extra_scripts %}
<script>
(function () {
var q = document.getElementById('aq');
if (!q) return;
var STEP = 300, limit = STEP, f = '';
var rows = Array.prototype.slice.call(document.querySelectorAll('.arow'));
var more = document.getElementById('amore'), moreText = document.getElementById('amoreText');
function apply() {
var needle = q.value.trim().toLowerCase(), matched = 0;
rows.forEach(function (r) {
var ok = (!needle || r.getAttribute('data-q').indexOf(needle) >= 0) &&
(!f || (' ' + r.getAttribute('data-f') + ' ').indexOf(' ' + f + ' ') >= 0);
if (ok) matched++;
r.hidden = !ok || matched > limit;
});
document.getElementById('anomatch').hidden = matched > 0;
var shown = Math.min(matched, limit);
more.hidden = matched <= limit;
moreText.textContent = 'Showing ' + shown + ' of ' + matched + (needle || f ? ' matching' : '') + ' entries';
}
function reset() { limit = STEP; apply(); }
q.addEventListener('input', reset);
document.getElementById('amoreBtn').addEventListener('click', function () { limit += STEP; apply(); });
document.getElementById('aallBtn').addEventListener('click', function () { limit = rows.length; apply(); });
document.querySelectorAll('#afilters .fbtn').forEach(function (b) {
b.addEventListener('click', function () {
f = b.getAttribute('data-f') || '';
document.querySelectorAll('#afilters .fbtn').forEach(function (x) { x.setAttribute('aria-pressed', x === b ? 'true' : 'false'); });
reset();
});
});
apply();
})();
</script>
{% endblock %} {% endblock %}

View File

@@ -1,100 +1,107 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Clonezilla Backups - PXE Server Manager{% endblock %} {% import '_ui.html' as ui %}
{% block title %}Clonezilla backups - PXE Manager{% endblock %}
{# Clonezilla backups: one line per zip on the share (machine number, file,
size, date). Download is the row's action; Delete is in the menu and opens
the confirm modal. #}
{% block extra_head %}
<style>
.bgrid { display: grid; grid-template-columns: 180px minmax(0, 1fr) 90px 190px; align-items: center;
gap: 0 14px; padding: 11px 160px 11px 24px; font-size: 14px; min-height: 36px; }
.bhead { font-size: 12px; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); padding-top: 6px; padding-bottom: 6px; min-height: 0; }
.b-mach { font-weight: 600; }
.b-size { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
.b-when { font-size: 13px; color: var(--muted); white-space: nowrap; }
.bl-empty { padding: 28px 24px; text-align: center; color: var(--muted); border-top: 1px solid var(--line); }
.bnote { padding: 18px 24px; font-size: 13px; color: var(--text-2); display: flex; flex-direction: column; gap: 6px; }
.bnote p { margin: 0; }
@container (max-width: 820px) {
.bgrid { grid-template-columns: minmax(0, 1fr) 80px; padding: 11px 160px 11px 18px; gap: 2px 12px; }
.bgrid .b-file, .bgrid .b-when { grid-column: 1 / -1; }
.bhead { display: none; }
}
@container (max-width: 520px) {
/* The file is the machine number plus .zip; the expandable-free row keeps machine, size and date. */
.bgrid { grid-template-columns: minmax(0, 1fr); padding: 12px 150px 12px 16px; }
.bgrid .b-file { display: none; }
.b-mach .trunc { white-space: normal; overflow-wrap: anywhere; }
.b-size { text-align: left; }
.ui-row > .act { right: 12px; top: 12px; }
}
</style>
{% endblock %}
{% block content %} {% block content %}
<div class="page-header"> {% call ui.head('Clonezilla backups', 'Tools', (backups | length) ~ ' backup' ~ ('s' if backups | length != 1) ~ ' on the share') %}
<h1>Clonezilla Backups</h1> <button type="button" class="ui-btn pri" data-bs-toggle="modal" data-bs-target="#uploadModal">Upload backup</button>
<div class="header-actions"> {% endcall %}
<button class="pxe-btn pxe-btn-primary" data-bs-toggle="modal" data-bs-target="#uploadModal">
<i class="bi bi-upload"></i> Upload Backup
</button>
</div>
</div>
<div class="card"> <div class="ui-stack">
<div class="card-header d-flex align-items-center"> <section class="panel ui-list" aria-labelledby="bl-h">
<i class="bi bi-hdd-stack me-2"></i> <h2 id="bl-h" class="vh">Machine backups</h2>
Machine Backups {% if backups %}
<span class="badge badge-secondary ms-2">{{ backups|length }}</span> <div class="bgrid bhead" aria-hidden="true"><span>Machine</span><span class="b-file">File</span><span class="b-size">Size</span><span class="b-when">Modified</span></div>
</div> {% for b in backups %}
<div class="card-body p-0"> <div class="ui-row">
{% if backups %} <div class="bgrid">
<div class="table-container"> <span class="b-mach mono tip" data-tip="{{ b.machine }}"><span class="trunc">{{ b.machine }}</span></span>
<table class="data-table mb-0"> <span class="b-file tip" data-tip="{{ b.filename }}"><span class="trunc mono sub">{{ b.filename }}</span></span>
<thead> <span class="b-size">{{ "%.1f"|format(b.size / 1073741824) }} GB</span>
<tr> <span class="b-when mono">{{ b.modified | timestamp_fmt }}</span>
<th>Machine #</th>
<th>Filename</th>
<th>Size</th>
<th>Last Modified</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
{% for b in backups %}
<tr>
<td><strong>{{ b.machine }}</strong></td>
<td><span class="mono">{{ b.filename }}</span></td>
<td>{{ "%.1f"|format(b.size / 1073741824) }} GB</td>
<td>{{ b.modified | timestamp_fmt }}</td>
<td class="actions text-end text-nowrap">
<a href="{{ url_for('clonezilla_download', filename=b.filename) }}"
class="pxe-btn pxe-btn-secondary btn-row-action" title="Download">
<i class="bi bi-download"></i> Download
</a>
<button type="button" class="pxe-btn pxe-btn-danger btn-row-action"
data-bs-toggle="modal" data-bs-target="#deleteModal"
data-filename="{{ b.filename }}" data-machine="{{ b.machine }}" title="Delete">
<i class="bi bi-trash"></i> Delete
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div> </div>
{% else %} <div class="act">
<div class="text-center text-muted py-5"> <a class="ui-btn" href="{{ url_for('clonezilla_download', filename=b.filename) }}">Download</a>
<i class="bi bi-hdd-stack" style="font-size: 2rem; opacity: 0.4;"></i> {% call ui.more(b.machine) %}
<p class="mt-2 mb-0">No backups found. Upload a Clonezilla backup .zip to get started.</p> <button type="button" class="copy-btn" data-copy-text="{{ b.filename }}">Copy file name</button>
<div class="sep"></div>
<button type="button" class="del" data-bs-toggle="modal" data-bs-target="#deleteModal"
data-filename="{{ b.filename }}" data-machine="{{ b.machine }}">Delete backup...</button>
{% endcall %}
</div> </div>
{% endif %}
</div> </div>
</div> {% endfor %}
{% else %}
<div class="bl-empty">No backups found. Upload a Clonezilla backup .zip to get started.</div>
{% endif %}
</section>
<div class="section-card mt-3"> <section class="panel bnote" aria-labelledby="bn-h">
<div class="section-title">Backup Naming Convention</div> <h2 id="bn-h" class="sec" style="font-size: 15px">Naming</h2>
<p class="mb-0"> <p>Name backup files with the machine number, for example <span class="mono">6501.zip</span>. The machine column is the file name without <span class="mono">.zip</span>.</p>
Name backup files with the machine number (e.g., <span class="mono">6501.zip</span>). <p>The Samba share <span class="mono">\\pxe-server\clonezilla</span> is also on the network for direct Clonezilla save and restore.</p>
The Samba share <span class="mono">\\pxe-server\clonezilla</span> is also available on the network for direct Clonezilla save/restore operations. </section>
</p>
</div> </div>
<!-- Upload Modal --> <!-- Upload Modal -->
<div class="modal fade" id="uploadModal" tabindex="-1"> <div class="modal fade" id="uploadModal" tabindex="-1" aria-labelledby="uploadTitle">
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">
<form action="{{ url_for('clonezilla_upload') }}" method="post" enctype="multipart/form-data"> <form action="{{ url_for('clonezilla_upload') }}" method="post" enctype="multipart/form-data">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title">Upload Backup</h5> <h3 class="modal-title" id="uploadTitle">Upload backup</h3>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="form-field mb-0"> <div class="form-field mb-0">
<label for="backupFile" class="form-label">Backup File (.zip)</label> <label for="backupFile" class="form-label">Backup file (.zip)</label>
<input type="file" class="form-control" id="backupFile" name="backup_file" <input type="file" class="form-control" id="backupFile" name="backup_file"
accept=".zip" required> accept=".zip" required>
<div class="form-text"> <div class="form-text">
Name the file with the machine number (e.g., <span class="mono">6501.zip</span>). Name the file with the machine number (e.g. <span class="mono">6501.zip</span>).
A file with the same name as an existing backup is refused unless you tick the box below.
</div> </div>
</div> </div>
<div class="form-check mt-2">
<input class="form-check-input" type="checkbox" name="overwrite" value="1" id="backupOverwrite">
<label class="form-check-label" for="backupOverwrite">Replace the existing backup with the same name</label>
</div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="pxe-btn pxe-btn-ghost" data-bs-dismiss="modal">Cancel</button> <button type="button" class="ui-btn" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="pxe-btn pxe-btn-primary"> <button type="submit" class="ui-btn pri">Upload</button>
<i class="bi bi-upload"></i> Upload
</button>
</div> </div>
</form> </form>
</div> </div>
@@ -102,24 +109,22 @@
</div> </div>
<!-- Delete Confirmation Modal --> <!-- Delete Confirmation Modal -->
<div class="modal fade" id="deleteModal" tabindex="-1"> <div class="modal fade" id="deleteModal" tabindex="-1" aria-labelledby="deleteTitle">
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">
<form id="deleteForm" method="post"> <form id="deleteForm" method="post">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title">Confirm Delete</h5> <h3 class="modal-title" id="deleteTitle">Delete backup</h3>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<p>Are you sure you want to delete the backup for machine <strong id="deleteMachine"></strong>?</p> <p>Are you sure you want to delete the backup for machine <strong id="deleteMachine"></strong>?</p>
<p class="text-muted mb-0">This action cannot be undone.</p> <p class="muted mb-0">This action cannot be undone.</p>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="pxe-btn pxe-btn-ghost" data-bs-dismiss="modal">Cancel</button> <button type="button" class="ui-btn pri" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="pxe-btn pxe-btn-danger"> <button type="submit" class="ui-btn danger">Delete</button>
<i class="bi bi-trash"></i> Delete
</button>
</div> </div>
</form> </form>
</div> </div>

View File

@@ -1,213 +1,315 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Enrollment Packages - PXE Server Manager{% endblock %} {% import '_ui.html' as ui %}
{% block title %}Enrollment packages - PXE Manager{% endblock %}
{# Enrollment packages, in sections: the active SFLD PPKG (ppkg.conf), the
SFLD packages (the active one is marked and cannot be deleted), the MCL
packages in _shared/Applications/BPRT with their duplicate/ignored/used
flags, the MCL archive, and which images link to the shared BPRT. #}
{% set link_chip = {
'ok': ('Linked', 'ok'),
'copy': ('Own copy', 'err'),
'elsewhere': ('Links elsewhere', 'err'),
'none': ('No BPRT', 'off'),
} %}
{% block extra_head %}
<style>
.phead { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; padding: 18px 24px 12px; }
.phead .sp { margin-left: auto; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.ptext { padding: 0 24px 14px; font-size: 13px; color: var(--text-2); display: flex; flex-direction: column; gap: 8px; max-width: 90ch; }
.ptext p { margin: 0; }
.notice > span, .ptext { overflow-wrap: anywhere; min-width: 0; }
.colhead { font-size: 12px; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); padding-top: 6px; padding-bottom: 6px; min-height: 0; }
.when { font-size: 13px; color: var(--muted); white-space: nowrap; }
.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
.empty { padding: 22px 24px; color: var(--muted); border-top: 1px solid var(--line); font-size: 13px; }
/* Active PPKG */
.active { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; padding: 18px 24px; }
.active .file { font-size: 16px; font-weight: 600; overflow-wrap: anywhere; }
/* SFLD rows */
.sgrid { display: grid; grid-template-columns: minmax(0, 1fr) 90px 190px; align-items: center; gap: 0 14px;
padding: 11px 160px 11px 24px; font-size: 14px; min-height: 36px; }
.s-name { display: flex; align-items: center; gap: 8px; min-width: 0; }
.s-name .tip { flex: 0 1 auto; }
/* MCL rows */
.mgrid { display: grid; grid-template-columns: 210px minmax(0, 1fr) 70px 80px 14px; align-items: center; gap: 0 14px;
padding: 11px 72px 11px 24px; font-size: 14px; min-height: 24px; }
.m-pre { display: flex; align-items: center; gap: 6px; min-width: 0; }
.m-pre .tip { flex: 0 1 auto; }
.flags { display: flex; gap: 6px; flex-wrap: nowrap; }
.mbody { padding: 2px 72px 14px 24px; font-size: 13px; display: flex; flex-direction: column; gap: 8px; }
.mfacts { display: flex; flex-wrap: wrap; gap: 4px 20px; color: var(--muted); font-size: 12px; }
.mfacts .mono { overflow-wrap: anywhere; color: var(--text-2); }
.legend { display: flex; flex-direction: column; gap: 6px; padding: 0 24px 14px; font-size: 13px; }
.legend div { display: flex; gap: 8px; align-items: baseline; }
.upform { display: flex; flex-wrap: wrap; gap: 10px 12px; align-items: flex-end; padding: 0 24px 18px; }
.upform .form-field { min-width: min(22rem, 100%); flex: 0 1 28rem; margin: 0; }
/* Archive rows */
.agrid { display: grid; grid-template-columns: 150px minmax(0, 1fr) 70px 80px; align-items: center; gap: 0 14px;
padding: 11px 130px 11px 24px; font-size: 14px; min-height: 36px; }
/* Image links */
.lgrid { display: grid; grid-template-columns: 170px 130px minmax(0, 1fr); align-items: center; gap: 4px 14px; padding: 11px 24px; font-size: 14px; }
.lgrid .mono { overflow-wrap: anywhere; font-size: 13px; }
@container (max-width: 760px) {
.colhead { display: none; }
.sgrid { grid-template-columns: minmax(0, 1fr) auto; padding: 12px 16px 4px; gap: 2px 12px; }
.sgrid .when { grid-column: 1 / -1; }
.mgrid { grid-template-columns: minmax(0, 1fr) auto 14px; padding: 12px 64px 12px 16px; gap: 4px 10px; }
.mgrid .m-file { grid-column: 1 / -1; grid-row: 2; }
.mgrid .m-size { display: none; }
.mbody { padding: 2px 16px 12px; }
.agrid { grid-template-columns: minmax(0, 1fr) auto; padding: 12px 16px 4px; gap: 2px 12px; }
.agrid .a-file { grid-column: 1 / -1; }
.agrid .a-size { display: none; }
.ui-row > .act.wide { position: static; display: flex; justify-content: flex-end; padding: 0 16px 10px; }
.lgrid { grid-template-columns: minmax(0, 1fr) auto; padding: 11px 16px; }
.lgrid .mono, .lgrid .lnote { grid-column: 1 / -1; }
.phead, .active { padding-left: 16px; padding-right: 16px; }
.ptext, .legend, .upform { padding-left: 16px; padding-right: 16px; }
}
</style>
{% endblock %}
{% block content %} {% block content %}
<div class="page-header"> {% call ui.head('Enrollment packages', 'Tools', 'Shop floor (SFLD) and Standard / Engineer (MCL) provisioning packages.') %}
<h1><i class="bi bi-pc-display"></i> Enrollment Packages</h1> <button type="button" class="ui-btn pri" data-bs-toggle="modal" data-bs-target="#uploadModal">Upload SFLD package</button>
<div class="header-actions"> {% endcall %}
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#uploadModal">
<i class="bi bi-upload"></i> Upload Package
</button>
</div>
</div>
{% if active_ppkg and active_ppkg.SOURCE_PPKG %} <div class="ui-stack">
<div class="alert alert-info">
<strong>Active PPKG:</strong> <span class="mono">{{ active_ppkg.SOURCE_PPKG }}</span>
&nbsp;<span class="badge badge-info">{{ active_ppkg.PPKG_VER }}</span>
{% if active_ppkg.PPKG_EXP %}<span class="badge badge-secondary">exp {{ active_ppkg.PPKG_EXP }}</span>{% endif %}
<span class="form-text">- startnet.cmd boots with this (from <span class="mono">ppkg.conf</span>).</span>
</div>
{% endif %}
<div class="card"> {# ---------- Active PPKG ---------- #}
<div class="card-header"> <section class="panel" aria-labelledby="act-h">
<span><i class="bi bi-box-seam"></i> Shop Floor (SFLD) Provisioning Packages</span> <div class="phead" style="padding-bottom: 0"><h2 id="act-h" class="sec">Active SFLD package</h2></div>
<span class="badge badge-secondary">{{ packages|length }}</span> {% if active_ppkg and active_ppkg.SOURCE_PPKG %}
<div class="active">
<span class="file mono">{{ active_ppkg.SOURCE_PPKG }}</span>
{% if active_ppkg.PPKG_VER %}{{ ui.chip(active_ppkg.PPKG_VER, 'run') }}{% endif %}
{% if active_ppkg.PPKG_EXP %}{{ ui.chip('Expires ' ~ active_ppkg.PPKG_EXP, 'neutral') }}{% endif %}
<span class="sub" style="flex-basis: 100%">startnet.cmd boots with this (from <span class="mono">ppkg.conf</span> on the enrollment share).</span>
</div> </div>
<div class="card-body p-0"> {% else %}
{% if packages %} <div class="active"><div class="notice" style="flex: 1"><span aria-hidden="true" style="font-weight: 700">!</span><span>No active package is set in <span class="mono">ppkg.conf</span>. Upload a standard <span class="mono">GCCH_Prod_SFLD_v&lt;ver&gt;.ppkg</span> to set one.</span></div></div>
<div class="table-container"> {% endif %}
<table class="table table-hover mb-0"> </section>
<thead>
<tr> {# ---------- SFLD packages ---------- #}
<th>Filename</th> <section class="panel ui-list" aria-labelledby="sfld-h">
<th>Size</th> <div class="phead">
<th>Last Modified</th> <h2 id="sfld-h" class="sec">Shop floor (SFLD) packages</h2>
<th class="text-end">Actions</th> <span class="sub">{{ packages | length }}</span>
</tr> </div>
</thead> {% if packages %}
<tbody> <div class="sgrid colhead" aria-hidden="true"><span>File</span><span class="num">Size</span><span class="when">Modified</span></div>
{% for p in packages %} {% for p in packages %}
{% set is_active = active_ppkg and active_ppkg.SOURCE_PPKG and p.filename|lower == active_ppkg.SOURCE_PPKG|lower %} {% set is_active = active_ppkg and active_ppkg.SOURCE_PPKG and p.filename|lower == active_ppkg.SOURCE_PPKG|lower %}
<tr> <div class="ui-row">
<td><span class="mono"><i class="bi bi-file-earmark-zip"></i> {{ p.filename }}</span>{% if is_active %} <span class="badge badge-info">Active</span>{% endif %}</td> <div class="sgrid">
<td>{{ "%.1f"|format(p.size / 1048576) }} MB</td> <span class="s-name">
<td>{{ p.modified | timestamp_fmt }}</td> <span class="tip" data-tip="{{ p.filename }}"><span class="trunc mono">{{ p.filename }}</span></span>
<td class="actions text-end text-nowrap"> {% if is_active %}{{ ui.chip('Active', 'ok') }}{% endif %}
<a href="{{ url_for('enrollment_download', filename=p.filename) }}" </span>
class="btn btn-sm btn-outline-primary btn-row-action" title="Download"> <span class="num">{{ "%.1f"|format(p.size / 1048576) }} MB</span>
<i class="bi bi-download"></i> Download <span class="when mono">{{ p.modified | timestamp_fmt }}</span>
</a>
{% if is_active %}
<button type="button" class="btn btn-sm btn-outline-secondary btn-row-action" disabled>
<i class="bi bi-lock"></i> Active
</button>
{% else %}
<button type="button" class="btn btn-sm btn-outline-danger btn-row-action"
data-bs-toggle="modal" data-bs-target="#deleteModal"
data-filename="{{ p.filename }}" title="Delete">
<i class="bi bi-trash"></i> Delete
</button>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div> </div>
{% else %} <div class="act wide">
<div class="text-center text-muted py-5"> <a class="ui-btn" href="{{ url_for('enrollment_download', filename=p.filename) }}">Download</a>
<i class="bi bi-inbox" style="font-size: 2.5rem; opacity: 0.5;"></i> {% call ui.more(p.filename) %}
<p class="mt-2 mb-0">No enrollment packages found. Upload a <span class="mono">.ppkg</span> file to get started.</p> <button type="button" class="copy-btn" data-copy-text="{{ p.filename }}">Copy file name</button>
<div class="sep"></div>
{% if is_active %}
<button type="button" disabled>Active: cannot delete</button>
{% else %}
<button type="button" class="del" data-bs-toggle="modal" data-bs-target="#deleteModal"
data-filename="{{ p.filename }}">Delete package...</button>
{% endif %}
{% endcall %}
</div> </div>
{% endif %}
</div> </div>
</div> {% endfor %}
{% else %}
<div class="empty">No enrollment packages found. Upload a <span class="mono">.ppkg</span> file to get started.</div>
{% endif %}
<details class="hidden-bar" style="display: block">
<summary class="disc" style="min-height: 28px">About SFLD packages <span class="chev" aria-hidden="true">&#9662;</span></summary>
<div class="ptext" style="padding: 6px 0 4px">
<p>GCCH enrollment <span class="mono">.ppkg</span> provisioning packages are copied to
<span class="mono">C:\Enrollment\</span> on the target machine after imaging. At OOBE, connect to a network with
internet, press <strong>Windows key 5 times</strong>, then browse to <span class="mono">C:\Enrollment\</span> and
select the package. No USB stick needed.</p>
<p><strong>Naming:</strong> use <span class="mono">with-office.ppkg</span> and <span class="mono">without-office.ppkg</span>
to match the WinPE enrollment menu options.</p>
</div>
</details>
</section>
<div class="pxe-card"> {# ---------- MCL packages ---------- #}
<h3 class="pxe-card-title"><i class="bi bi-info-circle"></i> About Enrollment Packages</h3> {% set mcl_users = mcl_links | selectattr('state', 'equalto', 'ok') | map(attribute='image') | list %}
<p> {% set any_dup = mcl_packages | selectattr('conflict') | list | length %}
GCCH enrollment <span class="mono">.ppkg</span> provisioning packages are copied to {% set any_ign = mcl_packages | rejectattr('prefix') | list | length %}
<span class="mono">C:\Enrollment\</span> on the target machine after imaging. {% set any_used = mcl_packages | selectattr('prefix') | rejectattr('placeholder') | list | length %}
At OOBE, connect to a network with internet, press <strong>Windows key 5 times</strong>, <section class="panel ui-list" id="mcl" aria-labelledby="mcl-h">
then browse to <span class="mono">C:\Enrollment\</span> and select the package. No USB stick needed. <div class="phead">
</p> <h2 id="mcl-h" class="sec">Standard / Engineer (MCL) packages</h2>
<p class="mb-0"> <span class="sub">{{ mcl_packages | length }}</span>
<strong>Naming convention:</strong> Use <span class="mono">with-office.ppkg</span> and
<span class="mono">without-office.ppkg</span> to match the WinPE enrollment menu options.
</p>
</div>
<div class="card" id="mcl">
<div class="card-header">
<span><i class="bi bi-box-seam"></i> Standard / Engineer (MCL) Provisioning Packages</span>
<span class="badge badge-secondary">{{ mcl_packages|length }}</span>
</div> </div>
<div class="card-body"> <div class="ptext">
{% set mcl_users = mcl_links | selectattr('state', 'equalto', 'ok') | map(attribute='image') | list %} <p>Packages live in <span class="mono">_shared/Applications/BPRT</span>, which {{ mcl_users | join(', ') if mcl_users else 'no image' }}
<p class="text-muted"> link{{ 's' if mcl_users|length == 1 }} to. The bulk-enroll scripts pick the package by its first three name fields
Packages live in <span class="mono">_shared/Applications/BPRT</span>, which {{ mcl_users | join(', ') if mcl_users else 'no image' }} link{{ 's' if mcl_users|length == 1 }} to. (tenant, environment, purpose) and fail enrollment if two match, so an upload <strong>replaces</strong> the package with
The bulk-enroll scripts pick the package by its first three name fields (tenant, environment, purpose) and fail the same fields; the old one moves to the archive below and can be restored. Every PC built from those images copies
enrollment if two match, so an upload <strong>replaces</strong> the package with the same fields; the old one moves this folder while it images, so upload, archive or restore only when no build is in progress.</p>
to the archive below and can be restored. Every PC built from those images copies this folder while it images, so <p>A whole new vendor drop (packages, Office zips and scripts from MediaCreator Lite) goes through
upload, archive or restore only when no build is in progress. <a href="/updates/bprt/">BPRT Sync</a> instead: it uploads only the changed files from the work PC, checks them and
</p> swaps the folder in one step, keeping the site's own scripts.</p>
<p class="text-muted"> </div>
<i class="bi bi-arrow-left-right"></i> A whole new vendor drop (packages, Office zips and scripts from MediaCreator Lite) {% for l in mcl_links if l.state in ('copy', 'elsewhere') %}
goes through <a href="/updates/bprt/">BPRT Sync</a> instead: it uploads only the changed files from the work PC, checks <div style="padding: 0 24px 10px">
them and swaps the folder in one step, keeping the site's own scripts. <div class="notice"><span aria-hidden="true" style="font-weight: 700">!</span><span>
</p>
{% for l in mcl_links if l.state in ('copy', 'elsewhere') %}
<div class="alert alert-warning py-2 mb-2">
<strong>{{ l.image }}</strong> <strong>{{ l.image }}</strong>
{% if l.state == 'copy' %}has its own copy of BPRT (<span class="mono">{{ l.path }}</span> is a real folder, not a link to {% if l.state == 'copy' %}has its own copy of BPRT (<span class="mono">{{ l.path }}</span> is a real folder, not a link to
<span class="mono">_shared/Applications/BPRT</span>; an image import does this), <span class="mono">_shared/Applications/BPRT</span>; an image import does this),
{% else %}links BPRT to <span class="mono">{{ l.target or '?' }}</span> instead of <span class="mono">_shared/Applications/BPRT</span>, {% else %}links BPRT to <span class="mono">{{ l.target or '?' }}</span> instead of <span class="mono">_shared/Applications/BPRT</span>,
{% endif %}so packages changed here do not reach its PCs. Move that folder aside and link it back to the shared one. {% endif %}so packages changed here do not reach its PCs. Move that folder aside and link it back to the shared one.
</div> </span></div>
{% endfor %}
<form action="{{ url_for('mcl_ppkg_upload') }}" method="post" enctype="multipart/form-data" class="d-flex flex-wrap gap-2 align-items-end mb-3">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="form-field mb-0" style="min-width: min(22rem, 100%);">
<label for="mclFile" class="form-label">Package (.ppkg)</label>
<input type="file" class="form-control" id="mclFile" name="ppkg_file" accept=".ppkg" required>
<div class="form-text">Vendor name, unchanged, e.g. <span class="mono">GCCH_Prod_MCL-PERS_Exp_XXXXXXXX_v2.19.ppkg</span></div>
</div>
<button type="submit" class="btn btn-primary"><i class="bi bi-upload"></i> Upload and replace</button>
</form>
{% if mcl_packages %}
<div class="table-container">
<table class="table table-hover mb-0">
<thead>
<tr><th>Tenant / purpose</th><th>Filename</th><th>Version</th><th>Size</th><th>Last Modified</th><th class="text-end"></th></tr>
</thead>
<tbody>
{% for p in mcl_packages %}
<tr>
<td class="mono">{{ p.prefix or '-' }}
{% if p.conflict %}<span class="badge badge-danger" title="This prefix also matches {{ p.conflict_with | join(', ') }}; enrollment for it will fail">Duplicate</span>{% endif %}
{% if not p.prefix %}<span class="badge badge-warning" title="Name does not match the vendor pattern; the enroll scripts ignore it">Ignored</span>
{% elif not p.placeholder %}<span class="badge badge-warning" title="No Exp_XXXXXXXX placeholder in the name: this looks like a package already used on a PC, and the enroll script cannot inject a new token into it">Used</span>{% endif %}
</td>
<td class="mono">{{ p.filename }}</td>
<td><span class="badge badge-info">{{ p.version or '?' }}</span></td>
<td>{{ "%.1f"|format(p.size / 1048576) }} MB</td>
<td>{{ p.modified | timestamp_fmt }}</td>
<td class="text-end">
<form action="{{ url_for('mcl_ppkg_archive') }}" method="post" style="display:inline;"
onsubmit="return confirm('Move this package to the archive? Enrollment for its tenant and purpose fails until another package is uploaded or restored.');">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="filename" value="{{ p.filename }}">
<button type="submit" class="btn btn-sm btn-outline-danger btn-row-action" title="Move to the archive"><i class="bi bi-archive"></i></button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted mb-0">No MCL packages found in BPRT.</p>
{% endif %}
{% if mcl_archive %}
<h6 class="mt-4">Archive</h6>
<div class="table-container">
<table class="table table-sm mb-0">
<thead><tr><th>Archived</th><th>Filename</th><th>Version</th><th>Size</th><th class="text-end"></th></tr></thead>
<tbody>
{% for p in mcl_archive %}
<tr>
<td class="mono">{{ p.batch }}</td>
<td class="mono">{{ p.filename }}</td>
<td>{{ p.version or '?' }}</td>
<td>{{ "%.1f"|format(p.size / 1048576) }} MB</td>
<td class="text-end">
<form action="{{ url_for('mcl_ppkg_restore') }}" method="post" style="display:inline;"
onsubmit="return confirm('Restore this package? The current package for the same tenant and purpose moves to the archive.');">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="batch" value="{{ p.batch }}">
<input type="hidden" name="filename" value="{{ p.filename }}">
<button type="submit" class="btn btn-sm btn-outline-primary btn-row-action"><i class="bi bi-arrow-counterclockwise"></i> Restore</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div> </div>
{% endfor %}
<form action="{{ url_for('mcl_ppkg_upload') }}" method="post" enctype="multipart/form-data" class="upform">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="form-field">
<label for="mclFile" class="form-label">Package (.ppkg)</label>
<input type="file" class="form-control" id="mclFile" name="ppkg_file" accept=".ppkg" required>
<div class="form-text">Vendor name, unchanged, e.g. <span class="mono">GCCH_Prod_MCL-PERS_Exp_XXXXXXXX_v2.19.ppkg</span></div>
</div>
<button type="submit" class="ui-btn pri" style="margin-bottom: 22px">Upload and replace</button>
</form>
{% if any_dup or any_ign or any_used %}
<div class="legend" aria-label="What the flags mean">
{% if any_dup %}<div>{{ ui.chip('Duplicate', 'err') }}<span>Its prefix also matches another package: enrollment for that tenant and purpose fails.</span></div>{% endif %}
{% if any_ign %}<div>{{ ui.chip('Ignored', 'warn') }}<span>The name does not match the vendor pattern, so the enroll scripts ignore it.</span></div>{% endif %}
{% if any_used %}<div>{{ ui.chip('Used', 'warn') }}<span>No <span class="mono">Exp_XXXXXXXX</span> placeholder in the name: it looks already used on a PC, and the enroll script cannot inject a new token into it.</span></div>{% endif %}
</div>
{% endif %}
{% if mcl_packages %}
<div class="mgrid colhead" aria-hidden="true"><span>Tenant / purpose</span><span class="m-file">File</span><span>Version</span><span class="num m-size">Size</span><span></span></div>
{% for p in mcl_packages %}
<div class="ui-row">
<details>
<summary class="mgrid">
<span class="m-pre">
<span class="tip" data-tip="{{ p.prefix or 'No prefix' }}"><span class="trunc mono">{{ p.prefix or '-' }}</span></span>
<span class="flags">
{% if p.conflict %}{{ ui.chip('Duplicate', 'err') }}{% endif %}
{% if not p.prefix %}{{ ui.chip('Ignored', 'warn') }}{% elif not p.placeholder %}{{ ui.chip('Used', 'warn') }}{% endif %}
</span>
</span>
<span class="m-file tip" data-tip="{{ p.filename }}"><span class="trunc mono sub">{{ p.filename }}</span></span>
<span>{{ ui.chip(p.version or '?', 'neutral') }}</span>
<span class="num m-size">{{ "%.1f"|format(p.size / 1048576) }} MB</span>
<span class="chev" aria-hidden="true">&#9662;</span>
</summary>
<div class="mbody">
{% if p.conflict %}<div class="notice bad"><span aria-hidden="true" style="font-weight: 700">!</span><span>This prefix also matches {{ p.conflict_with | join(', ') }}; enrollment for it will fail.</span></div>{% endif %}
{% if not p.prefix %}<div class="notice"><span>Name does not match the vendor pattern; the enroll scripts ignore it.</span></div>
{% elif not p.placeholder %}<div class="notice"><span>No <span class="mono">Exp_XXXXXXXX</span> placeholder in the name: this looks like a package already used on a PC, and the enroll script cannot inject a new token into it.</span></div>{% endif %}
<div class="mfacts"><span>File <span class="mono">{{ p.filename }}</span></span><span>{{ "%.1f"|format(p.size / 1048576) }} MB</span><span>Modified {{ p.modified | timestamp_fmt }}</span></div>
</div>
</details>
<div class="act">
{% call ui.more(p.filename) %}
<button type="button" class="copy-btn" data-copy-text="{{ p.filename }}">Copy file name</button>
<div class="sep"></div>
<form action="{{ url_for('mcl_ppkg_archive') }}" method="post"
onsubmit="return confirm('Move this package to the archive? Enrollment for its tenant and purpose fails until another package is uploaded or restored.');">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="filename" value="{{ p.filename }}">
<button type="submit" class="del">Move to archive...</button>
</form>
{% endcall %}
</div>
</div>
{% endfor %}
{% else %}
<div class="empty">No MCL packages found in BPRT.</div>
{% endif %}
</section>
{# ---------- MCL archive ---------- #}
{% if mcl_archive %}
<section class="panel ui-list" aria-labelledby="arc-h">
<div class="phead">
<h2 id="arc-h" class="sec">MCL archive</h2>
<span class="sub">{{ mcl_archive | length }}</span>
</div>
<div class="agrid colhead" aria-hidden="true"><span>Archived</span><span class="a-file">File</span><span>Version</span><span class="num a-size">Size</span></div>
{% for p in mcl_archive %}
<div class="ui-row">
<div class="agrid">
<span class="mono when">{{ p.batch }}</span>
<span class="a-file tip" data-tip="{{ p.filename }}"><span class="trunc mono sub">{{ p.filename }}</span></span>
<span>{{ ui.chip(p.version or '?', 'neutral') }}</span>
<span class="num a-size">{{ "%.1f"|format(p.size / 1048576) }} MB</span>
</div>
<div class="act wide">
<form action="{{ url_for('mcl_ppkg_restore') }}" method="post" style="margin: 0"
onsubmit="return confirm('Restore this package? The current package for the same tenant and purpose moves to the archive.');">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="batch" value="{{ p.batch }}">
<input type="hidden" name="filename" value="{{ p.filename }}">
<button type="submit" class="ui-btn">Restore</button>
</form>
</div>
</div>
{% endfor %}
</section>
{% endif %}
{# ---------- Image links ---------- #}
{% if mcl_links %}
<section class="panel ui-list" aria-labelledby="lnk-h">
<div class="phead">
<h2 id="lnk-h" class="sec">Image links to the shared BPRT</h2>
</div>
{% for l in mcl_links %}
{% set c = link_chip.get(l.state, (l.state, 'neutral')) %}
<div class="ui-row lgrid">
<span>{{ l.image }}</span>
<span>{{ ui.chip(c[0], c[1]) }}</span>
<span class="mono">{% if l.state == 'ok' %}{{ l.path }}{% elif l.state == 'elsewhere' %}{{ l.path }} links to {{ l.target or '?' }}{% elif l.state == 'copy' %}{{ l.path }} is a real folder{% else %}<span class="sub">No Deploy/Applications/BPRT</span>{% endif %}</span>
</div>
{% endfor %}
</section>
{% endif %}
</div> </div>
<!-- Upload Modal --> <!-- Upload Modal -->
<div class="modal fade" id="uploadModal" tabindex="-1"> <div class="modal fade" id="uploadModal" tabindex="-1" aria-labelledby="uploadTitle">
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">
<form action="{{ url_for('enrollment_upload') }}" method="post" enctype="multipart/form-data"> <form action="{{ url_for('enrollment_upload') }}" method="post" enctype="multipart/form-data">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title">Upload Enrollment Package</h5> <h3 class="modal-title" id="uploadTitle">Upload SFLD package</h3>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="form-field"> <div class="form-field">
<label for="ppkgFile" class="form-label">Provisioning Package (.ppkg)</label> <label for="ppkgFile" class="form-label">Provisioning package (.ppkg)</label>
<input type="file" class="form-control" id="ppkgFile" name="ppkg_file" <input type="file" class="form-control" id="ppkgFile" name="ppkg_file"
accept=".ppkg" required> accept=".ppkg" required>
<div class="form-text"> <div class="form-text">
A standard <span class="mono">GCCH_Prod_SFLD_v&lt;ver&gt;.ppkg</span> upload is set as the active PPKG automatically A standard <span class="mono">GCCH_Prod_SFLD_v&lt;ver&gt;.ppkg</span> upload is set as the active PPKG automatically
(written to <span class="mono">ppkg.conf</span> on the share; startnet.cmd reads it at boot - no boot.wim edit). (written to <span class="mono">ppkg.conf</span> on the share; startnet.cmd reads it at boot, no boot.wim edit).
</div> </div>
</div> </div>
<div class="form-field"> <div class="form-field">
@@ -218,8 +320,8 @@
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> <button type="button" class="ui-btn" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary"><i class="bi bi-upload"></i> Upload</button> <button type="submit" class="ui-btn pri">Upload</button>
</div> </div>
</form> </form>
</div> </div>
@@ -227,22 +329,22 @@
</div> </div>
<!-- Delete Confirmation Modal --> <!-- Delete Confirmation Modal -->
<div class="modal fade" id="deleteModal" tabindex="-1"> <div class="modal fade" id="deleteModal" tabindex="-1" aria-labelledby="deleteTitle">
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">
<form id="deleteForm" method="post"> <form id="deleteForm" method="post">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title">Confirm Delete</h5> <h3 class="modal-title" id="deleteTitle">Delete package</h3>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<p>Are you sure you want to delete <strong id="deleteFilename" class="mono"></strong>?</p> <p>Are you sure you want to delete <strong id="deleteFilename" class="mono"></strong>?</p>
<p class="text-muted mb-0">This action cannot be undone.</p> <p class="muted mb-0">This action cannot be undone.</p>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> <button type="button" class="ui-btn pri" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-danger"><i class="bi bi-trash"></i> Delete</button> <button type="submit" class="ui-btn danger">Delete</button>
</div> </div>
</form> </form>
</div> </div>

View File

@@ -1,88 +1,224 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Hardware Coverage - PXE Server Manager{% endblock %} {% import '_ui.html' as ui %}
{% block title %}Hardware coverage - PXE Manager{% endblock %}
{% macro driver_badge(d) -%} {# Hardware coverage: one line per model WinPE has reported. The line shows
{% if d.status == 'ok' %}<span class="badge badge-success" title="{{ d.file }}">OK</span> the model, one coverage chip per image, the BIOS state and when it was
{% elif d.status == 'no-entry' %}<span class="badge badge-danger" title="No HardwareDriver.json entry matches this model">Missing</span> last seen; the body holds SKUs, serials, PC types, BIOS versions and the
{% elif d.status == 'zip-missing' %}<span class="badge badge-danger" title="Entry {{ d.file }} matches, but the zip is not on the server">Zip missing</span> driver files each image resolves to. #}
{% else %}<span class="text-muted">-</span>{% endif %}
{% set drv_chip = {
'ok': ('OK', 'ok'),
'no-entry': ('Missing', 'err'),
'zip-missing': ('Zip missing', 'err'),
} %}
{# Chip for one image's driver status; the tip names the file or the reason. #}
{% macro driver_chip(d) -%}
{% set c = drv_chip.get(d.status) %}
{% if c %}
<span class="tip inl" data-tip="{% if d.status == 'ok' %}{{ d.file }}{% elif d.status == 'no-entry' %}No HardwareDriver.json entry matches this model{% else %}Entry {{ d.file }} matches, but the zip is not on the server{% endif %}">{{ ui.chip(c[0], c[1]) }}</span>
{%- else %}<span class="sub" title="Image not on this server">-</span>{% endif %}
{%- endmacro %} {%- endmacro %}
{% macro bios_badge(b) -%} {% macro bios_chip(b) -%}
{% if b.status == 'current' %}<span class="badge badge-success">{{ b.target }} (current)</span> {% if b.status == 'current' %}{{ ui.chip('Current ' ~ b.target, 'ok') }}
{% elif b.status == 'update' %}<span class="badge badge-info" title="{{ b.file }}">Flashes to {{ b.target }}</span> {% elif b.status == 'update' %}{{ ui.chip('Flashes to ' ~ b.target, 'neutral') }}
{% elif b.status == 'file-missing' %}<span class="badge badge-danger" title="{{ b.file }} not in the BIOS folder">File missing</span> {% elif b.status == 'file-missing' %}{{ ui.chip('File missing', 'err') }}
{% else %}<span class="badge badge-danger">No BIOS entry</span>{% endif %} {% else %}{{ ui.chip('No BIOS entry', 'err') }}{% endif %}
{%- endmacro %} {%- endmacro %}
{# A newer Dell pack/BIOS for the file this model uses, from the last Updates > Dell scan. #} {# A newer Dell pack/BIOS for the file this model uses, from the last Updates > Dell scan. #}
{% macro update_hint(found, file) -%} {% macro new_chip(found, file) -%}
{% if file and file | lower in found %} {% if file and file | lower in found %}
<div class="mt-1"><a href="{{ url_for('dell_updates.page') }}" class="badge badge-info text-decoration-none" title="{{ file }}: Dell has {{ found[file | lower] or 'a newer version' }}. Open Updates to download it.">Update {{ found[file | lower] }}</a></div> <a class="tip inl newlink" href="{{ url_for('dell_updates.page') }}" data-tip="{{ file }}: Dell has {{ found[file | lower] or 'a newer version' }}. Open Updates to download it.">{{ ui.chip('New', 'run') }}<span class="vh"> Dell version {{ found[file | lower] }}</span></a>
{% endif %} {% endif %}
{%- endmacro %} {%- endmacro %}
{% block extra_head %}
<style>
.hgrid { display: grid; grid-template-columns: minmax(0, 1fr) 336px 170px 184px 14px; align-items: center;
gap: 0 14px; padding: 11px 72px 11px 24px; font-size: 14px; min-height: 24px; }
.hhead { font-size: 12px; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); padding-top: 6px; padding-bottom: 6px; }
.hhead .cov span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.h-model { display: flex; align-items: center; gap: 8px; min-width: 0; }
.h-model .tip { flex: 0 1 auto; }
.h-model .mfr { flex: none; font-size: 12px; color: var(--muted); white-space: nowrap; }
.cov { display: grid; grid-template-columns: repeat(3, 104px); gap: 0 12px; align-items: center; }
.cov > span { display: flex; align-items: center; gap: 4px; min-width: 0; }
.hgrid .ilab { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.h-bios { display: flex; align-items: center; gap: 4px; min-width: 0; }
.h-seen { font-size: 13px; color: var(--muted); white-space: nowrap; }
.newlink { text-decoration: none; }
.newlink .chip { font-size: 11px; padding: 1px 6px; }
.hbody { padding: 2px 72px 16px 24px; font-size: 13px; display: flex; flex-direction: column; gap: 12px; }
.hfacts { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(200px, 100%), 1fr)); gap: 10px 24px; margin: 0; }
.hfacts dt { font-size: 12px; font-weight: 400; color: var(--muted); }
.hfacts dd { margin: 0; overflow-wrap: anywhere; }
.hfiles { display: flex; flex-direction: column; gap: 6px; }
.hfile { display: grid; grid-template-columns: 190px 104px minmax(0, 1fr); gap: 4px 12px; align-items: baseline; }
.hfile .mono { overflow-wrap: anywhere; }
.hl-empty { padding: 28px 24px; text-align: center; color: var(--muted); border-top: 1px solid var(--line); }
.hintro { margin: 0; padding: 16px 24px 0; max-width: 80ch; }
@container (max-width: 1000px) {
.hgrid { grid-template-columns: minmax(0, 1fr) 336px 168px 14px; }
.hgrid .h-seen { display: none; }
}
@container (max-width: 760px) {
.hgrid { grid-template-columns: minmax(0, 1fr) 14px; padding: 12px 64px 12px 16px; gap: 6px 10px; }
.hhead { display: none; }
.hgrid .h-model { grid-column: 1; grid-row: 1; }
.hgrid .chev { grid-column: 2; grid-row: 1; }
.hgrid .cov, .hgrid .h-bios { grid-column: 1 / -1; }
.cov { display: flex; flex-wrap: wrap; gap: 6px 14px; }
.hgrid .ilab { position: static; width: auto; height: auto; overflow: visible; clip: auto; font-size: 12px; color: var(--muted); }
.hbody { padding: 2px 16px 14px; }
.hfile { grid-template-columns: minmax(0, 1fr) auto; }
.hfile .mono { grid-column: 1 / -1; }
.hintro { padding: 14px 16px 0; }
}
@container (min-width: 521px) and (max-width: 760px) {
.hgrid { grid-template-columns: auto minmax(0, 1fr) 14px; }
.hgrid .h-model { grid-column: 1 / 3; }
.hgrid .chev { grid-column: 3; }
.hgrid .cov { grid-column: 1; grid-row: 2; }
.hgrid .h-bios { grid-column: 2; grid-row: 2; padding-left: 14px; }
}
</style>
{% endblock %}
{% block content %} {% block content %}
<div class="page-header"> {% set gaps = rows | selectattr('gap') | rejectattr('dismissed') | list %}
<h1><i class="bi bi-cpu"></i> Hardware Coverage</h1> {% set dismissed = rows | selectattr('gap') | selectattr('dismissed') | list %}
<div class="header-actions"> {{ ui.head('Hardware coverage', 'Operate', (rows | length) ~ ' model' ~ ('s' if rows | length != 1) ~ ' seen' ~ (', ' ~ (gaps | length) ~ ' with open gaps' if gaps else '')) }}
<span class="badge badge-secondary badge-lg">{{ rows | length }} model{{ 's' if rows | length != 1 }} seen</span>
</div>
</div>
<p class="text-muted"> <section class="panel ui-list" aria-labelledby="hl-h">
Every model that has booted WinPE here, as reported by the machine itself (WMI model, Dell system SKU, <h2 id="hl-h" class="vh">Models seen</h2>
BIOS version), checked against each image's driver map and the WinPE BIOS manifest. A model missing a <p class="sub hintro">
Win11 driver pack or a BIOS entry also shows on the Technician To-Do until it is added or dismissed. Every model that has booted WinPE here, as the machine reported it (WMI model, Dell system SKU, BIOS version),
</p> checked against each image's driver map and the WinPE BIOS manifest. A model missing a driver pack or a BIOS
entry also shows on the Technician to-do until it is added or dismissed.
<div class="section-card"> </p>
{% if rows %} {% if rows %}
<div class="table-container"> <div style="display: flex; flex-wrap: wrap; align-items: center; gap: 10px; padding: 16px 24px">
<table class="data-table"> <div role="search" style="flex: 1 1 220px; min-width: 160px; display: flex">
<thead> <label for="hq" class="vh">Search by model, SKU, serial, PC type or driver file</label>
<tr> <input id="hq" type="search" class="field" style="flex: 1; min-width: 0" placeholder="Model, SKU, serial, PC type or file">
<th>Model (WMI)</th> </div>
<th>System SKU</th> <div role="group" aria-label="Show only" id="hfilters" style="display: flex; gap: 6px; flex-wrap: wrap">
<th>BIOS seen</th> <button type="button" class="fbtn" data-f="" aria-pressed="true">All {{ rows | length }}</button>
{% for image, os_label in images %}<th title="{{ image }}">{{ image | replace('gea-', '') }} <span class="text-muted">({{ os_label }})</span></th>{% endfor %} <button type="button" class="fbtn" data-f="gap" aria-pressed="false">Gaps {{ gaps | length }}</button>
<th>BIOS update</th> <button type="button" class="fbtn" data-f="dismissed" aria-pressed="false">Dismissed {{ dismissed | length }}</button>
<th>Last seen</th> </div>
<th class="text-end"></th>
</tr>
</thead>
<tbody>
{% for r in rows %}
<tr title="Serials: {{ r.serials | join(', ') }}{% if r.pctypes %} | Types: {{ r.pctypes | join(', ') }}{% endif %}">
<td>
<span class="mono">{{ r.model }}</span>
{% if r.manufacturer %}<div class="small text-muted">{{ r.manufacturer }}</div>{% endif %}
</td>
<td class="mono">{{ r.skus | join(', ') or '-' }}</td>
<td class="mono">{{ r.last_bios_version or '-' }}</td>
{% for image, os_label in images %}<td>{{ driver_badge(r.drivers[image]) }}{{ update_hint(hints.drivers, r.drivers[image].file) }}</td>{% endfor %}
<td>{{ bios_badge(r.bios) }}{{ update_hint(hints.bios, r.bios.file) }}</td>
<td class="mono text-nowrap">{{ r.last_seen | local_dt }}<div class="small text-muted">{{ r.count }} boot{{ 's' if r.count != 1 }}</div></td>
<td class="text-end text-nowrap">
{% if r.gap %}
<form method="post" action="{{ url_for('hardware_dismiss') }}" style="display:inline;">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="model" value="{{ r.model }}">
<input type="hidden" name="done" value="{{ '0' if r.dismissed else '1' }}">
<button type="submit" class="pxe-btn pxe-btn-ghost btn-row-action">{{ 'Reopen' if r.dismissed else 'Dismiss' }}</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div> </div>
<div class="hgrid hhead" aria-hidden="true">
<span>Model</span>
<span class="cov">{% for image, os_label in images %}<span title="{{ image }} ({{ os_label }})">{{ image | replace('gea-', '') }}</span>{% endfor %}</span>
<span>BIOS</span><span class="h-seen">Last seen</span><span></span>
</div>
{% for r in rows %}
<div class="ui-row hrow" data-f="{{ 'gap ' if r.gap and not r.dismissed }}{{ 'dismissed' if r.gap and r.dismissed }}"
data-q="{{ (r.model ~ ' ' ~ (r.manufacturer or '') ~ ' ' ~ (r.skus | join(' ')) ~ ' ' ~ (r.serials | join(' ')) ~ ' ' ~ (r.pctypes | join(' ')) ~ ' ' ~ (r.drivers.values() | map(attribute='file', default='') | join(' ')) ~ ' ' ~ (r.bios.file or '')) | lower }}">
<details>
<summary class="hgrid">
<span class="h-model">
<span class="tip" data-tip="{{ r.model }}"><span class="trunc" style="font-weight: 600">{{ r.model }}</span></span>
{% if r.manufacturer %}<span class="mfr">{{ r.manufacturer }}</span>{% endif %}
{% if r.gap and r.dismissed %}{{ ui.chip('Dismissed', 'off') }}{% endif %}
</span>
<span class="cov">
{% for image, os_label in images %}
<span><span class="ilab">{{ image | replace('gea-', '') }}</span>{{ driver_chip(r.drivers[image]) }}{{ new_chip(hints.drivers, r.drivers[image].file) }}</span>
{% endfor %}
</span>
<span class="h-bios"><span class="ilab">BIOS</span>{{ bios_chip(r.bios) }}{{ new_chip(hints.bios, r.bios.file) }}</span>
<span class="h-seen mono">{{ r.last_seen | local_dt }}</span>
<span class="chev" aria-hidden="true">&#9662;</span>
</summary>
<div class="hbody">
<dl class="hfacts">
<div><dt>Model (WMI)</dt><dd class="mono">{{ r.model }}</dd></div>
{% if r.manufacturer %}<div><dt>Manufacturer</dt><dd>{{ r.manufacturer }}</dd></div>{% endif %}
<div><dt>System SKU</dt><dd class="mono">{{ r.skus | join(', ') or '-' }}</dd></div>
<div><dt>Serials</dt><dd class="mono">{{ r.serials | join(', ') or '-' }}</dd></div>
<div><dt>PC types</dt><dd>{{ r.pctypes | join(', ') or '-' }}</dd></div>
<div><dt>BIOS versions seen</dt><dd class="mono">{{ r.bios_versions | join(', ') or '-' }}{% if r.last_bios_version %} <span class="sub">(last {{ r.last_bios_version }})</span>{% endif %}</dd></div>
<div><dt>Last seen</dt><dd>{{ r.last_seen | local_dt }}, {{ r.count }} boot{{ 's' if r.count != 1 }}</dd></div>
<div><dt>First seen</dt><dd>{{ r.first_seen | local_dt }}</dd></div>
</dl>
<div class="hfiles">
{% for image, os_label in images %}
{% set d = r.drivers[image] %}
<div class="hfile">
<span>{{ image }} <span class="sub">{{ os_label }}</span></span>
<span>{% if drv_chip.get(d.status) %}{{ ui.chip(drv_chip[d.status][0], drv_chip[d.status][1]) }}{% else %}<span class="sub">Not on server</span>{% endif %}</span>
<span>
{% if d.status == 'no-entry' %}No HardwareDriver.json entry matches this model.
{% elif d.status == 'zip-missing' %}<span class="mono">{{ d.file }}</span> matches, but the zip is not on the server.
{% elif d.file %}<span class="mono">{{ d.file }}</span>{% endif %}
{% if d.file and d.file | lower in hints.drivers %} <a href="{{ url_for('dell_updates.page') }}">Dell has {{ hints.drivers[d.file | lower] or 'a newer version' }}</a>{% endif %}
</span>
</div>
{% endfor %}
<div class="hfile">
<span>BIOS</span>
<span>{{ bios_chip(r.bios) }}</span>
<span>
{% if r.bios.status == 'none' %}No line in the WinPE BIOS manifest matches this model.
{% elif r.bios.status == 'file-missing' %}<span class="mono">{{ r.bios.file }}</span> is not in the BIOS folder.
{% elif r.bios.file %}<span class="mono">{{ r.bios.file }}</span>{% endif %}
{% if r.bios.file and r.bios.file | lower in hints.bios %} <a href="{{ url_for('dell_updates.page') }}">Dell has {{ hints.bios[r.bios.file | lower] or 'a newer version' }}</a>{% endif %}
</span>
</div>
</div>
</div>
</details>
{% if r.gap %}
<div class="act">
{% call ui.more(r.model) %}
<form method="post" action="{{ url_for('hardware_dismiss') }}">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="model" value="{{ r.model }}">
<input type="hidden" name="done" value="{{ '0' if r.dismissed else '1' }}">
<button type="submit">{{ 'Reopen gap' if r.dismissed else 'Dismiss gap' }}</button>
</form>
{% endcall %}
</div>
{% endif %}
</div>
{% endfor %}
<div class="hidden-bar" id="hnomatch" hidden><span>No model matches.</span></div>
{% else %} {% else %}
<div class="text-center text-muted py-5"> <div class="hl-empty">
<p class="mb-1">No machines have reported yet.</p> No machines have reported yet. Models appear here the next time a machine boots WinPE from this server.
<p class="small">Models appear here the next time a machine boots WinPE from this server.</p>
</div> </div>
{% endif %} {% endif %}
</div> </section>
{% endblock %}
{% block extra_scripts %}
<script>
(function () {
var q = document.getElementById('hq');
if (!q) return;
var f = '';
function apply() {
var needle = q.value.trim().toLowerCase(), any = false;
document.querySelectorAll('.hrow').forEach(function (r) {
var ok = (!needle || r.getAttribute('data-q').indexOf(needle) >= 0) && (!f || r.getAttribute('data-f').indexOf(f) >= 0);
r.hidden = !ok; if (ok) any = true;
});
document.getElementById('hnomatch').hidden = any;
}
q.addEventListener('input', apply);
document.querySelectorAll('#hfilters .fbtn').forEach(function (b) {
b.addEventListener('click', function () {
f = b.getAttribute('data-f') || '';
document.querySelectorAll('#hfilters .fbtn').forEach(function (x) { x.setAttribute('aria-pressed', x === b ? 'true' : 'false'); });
apply();
});
});
})();
</script>
{% endblock %} {% endblock %}

View File

@@ -1,270 +1,305 @@
{% extends "base.html" %} {% extends "base.html" %}
{% import '_ui.html' as ui %}
{% block title %}{{ friendly_name }} - OS and Packages{% endblock %} {% block title %}{{ friendly_name }} - OS and Packages{% endblock %}
{# OS and Packages tab. Each list is a <table> whose rows are drawn as one
line plus an expandable body, because static/app.js removes a row with
.remove-row by its closest <tr> and renumbers .order-num in the tbody.
Edits are staged in the page; the save bar's Review and save posts only
the sections that changed (views/images.py os_preview / os_save). #}
{% block extra_head %} {% block extra_head %}
<style> <style>
.osp-table td { vertical-align: top; white-space: normal; } .osp-list, .osp-list tbody, .osp-list tr, .osp-list td { display: block; }
.osp-table td.num { width: 40px; color: var(--text-light); font-weight: 600; text-align: center; } .osp-list { width: 100%; border-collapse: collapse; margin: 0; }
.osp-table .wrap { overflow-wrap: anywhere; } .osp-list td { padding: 0; }
.osp-table .sub { font-size: 12px; color: var(--text-light); } .osp-list tr.row-new > td { background: var(--edit-soft); }
.osp-table .form-control-sm { min-width: 10rem; } .osp-phead { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; padding: 16px 24px 12px; }
.osp-table tr.row-new td { background: rgba(65, 129, 255, 0.06); } .osp-phead h2 { margin: 0; font-size: 16px; font-weight: 600; }
#ospModelTable td { vertical-align: middle; } .osp-phead .sp { margin-left: auto; display: flex; align-items: center; gap: 10px; }
.osp-note { font-size: 13px; color: var(--text-light); padding: 0.75rem 1.25rem; border-top: 1px solid var(--border); } .osp-note { font-size: 13px; color: var(--text-2); padding: 12px 24px; border-top: 1px solid var(--line); line-height: 1.55; }
.osp-explain { font-size: 13.5px; } .osp-list td.osp-empty { padding: 20px 24px; text-align: center; color: var(--muted); border-top: 1px solid var(--line); font-size: 14px; }
.osp-explain p:last-child { margin-bottom: 0; } .osp-head.og, .osp-head.pg, .osp-head.mg { font-size: 12px; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); padding-top: 6px !important; padding-bottom: 6px !important; }
.dirty-flag { font-size: 12px; color: var(--warning-dark); display: none; align-items: center; gap: 0.35rem; } .osp-list .num { color: var(--muted); font-size: 13px; }
[data-theme="dark"] .dirty-flag { color: var(--warning); } .osp-list .chips { display: flex; gap: 6px; flex-wrap: nowrap; min-width: 0; }
.dirty .dirty-flag { display: inline-flex; } .osp-list .name { font-weight: 600; }
.empty-row td { text-align: center; color: var(--text-light); padding: 1.25rem; }
/* Operating systems */
.og { display: grid; grid-template-columns: 26px minmax(0, 1fr) 54px 150px 190px minmax(0, 1fr) 14px; align-items: center;
gap: 0 14px; padding: 11px 76px 11px 24px; font-size: 14px; min-height: 24px; }
/* Packages */
.pg { display: grid; grid-template-columns: 26px minmax(0, 1.2fr) minmax(0, 1fr) 64px 82px 92px 14px; align-items: center;
gap: 0 14px; padding: 11px 130px 11px 24px; font-size: 14px; min-height: 24px; }
/* Hardware models (inputs on the row, no body) */
.mg { display: grid; grid-template-columns: 26px minmax(0, 1fr) minmax(0, 1.3fr) 190px 84px; align-items: center;
gap: 0 14px; padding: 8px 24px; font-size: 14px; }
.mg .field { width: 100%; min-width: 0; height: 32px; font-size: 13px; }
.mg .ents { display: flex; align-items: center; gap: 8px; white-space: nowrap; min-width: 0; }
.mg > :last-child { justify-self: end; }
.obody { padding: 2px 24px 16px 64px; font-size: 13px; }
.obody dl { margin: 0; display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 5px 16px; }
.obody dt { color: var(--muted); font-weight: 400; }
.obody dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
.osp-list .act { display: flex; align-items: center; gap: 10px; }
.osp-list .act .form-switch { margin: 0; padding-left: 2.5em; min-height: 0; display: flex; align-items: center; }
.osp-list .act .form-check-input { margin-top: 0; cursor: pointer; }
.osp-models > summary { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; padding: 16px 24px; }
.osp-models > summary h2 { margin: 0; font-size: 16px; font-weight: 600; }
.osp-models .explain { padding: 0 24px 14px; font-size: 13px; color: var(--text-2); line-height: 1.6; max-width: 980px; }
.osp-models .explain p { margin: 0 0 8px; }
.osp-intro { margin: 0; font-size: 14px; color: var(--text-2); line-height: 1.6; max-width: 980px; }
.savebar { position: sticky; bottom: 12px; z-index: 50; display: none; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px;
padding: 12px 12px 12px 20px; border-radius: 12px; background: var(--savebar-bg); border: 1px solid var(--savebar-edge);
color: var(--savebar-text); box-shadow: 0 8px 24px rgba(0,0,0,.25); }
.dirty .savebar { display: flex; }
.savebar .ui-btn { background: transparent; color: var(--savebar-text); border-color: #8E9AAB; }
.savebar .ui-btn.pri { background: var(--savebar-pri); border-color: var(--savebar-pri); color: var(--on-savebar-pri); }
.savebar .dirty-flag { display: inline-flex; align-items: center; gap: 8px; font-size: 14px; }
.savebar .dirty-flag .dot { background: var(--act); }
@container (max-width: 1000px) {
.og { grid-template-columns: 26px minmax(0, 1fr) 150px 190px 14px; }
.og .c-id, .og .c-dest { display: none; }
.pg { grid-template-columns: 26px minmax(0, 1fr) 82px 92px 14px; }
.pg .c-file, .pg .c-id { display: none; }
.mg { grid-template-columns: 26px minmax(0, 1fr) minmax(0, 1fr) auto 84px; }
.mg .ents .sub { display: none; }
.obody { padding-left: 24px; }
}
@container (max-width: 600px) {
.og, .pg { grid-template-columns: 22px auto minmax(0, 1fr) 14px; padding: 11px 56px 11px 16px; gap: 6px 10px; }
.pg { padding-right: 108px; }
.og > .tip:not(.c-dest), .pg > .tip:not(.c-file) { grid-column: 2 / 4; grid-row: 1; }
.og .c-st, .pg .c-en { grid-column: 2; grid-row: 2; }
.og .c-wim, .pg .c-disk { grid-column: 3; grid-row: 2; flex-wrap: wrap; }
.og .chev, .pg .chev { grid-column: 4; grid-row: 1; }
.osp-head { display: none; }
.osp-list .ui-row > .act { right: 8px; gap: 4px; }
.mg { grid-template-columns: 22px minmax(0, 1fr) auto; padding: 10px 16px; gap: 6px 10px; }
.mg .c-model { grid-column: 2; grid-row: 1; }
.mg .c-fam { grid-column: 2 / -1; grid-row: 2; }
.mg .ents { grid-column: 2; grid-row: 3; padding: 0; }
.mg > :last-child { grid-column: 3; grid-row: 1; }
.obody { padding: 2px 16px 14px; }
.osp-phead, .osp-note, .osp-models > summary, .osp-models .explain { padding-left: 16px; padding-right: 16px; }
}
</style> </style>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
{% set header_actions %} {% set header_actions %}
<span class="dirty-flag" id="ospDirty"><i class="bi bi-circle-fill" style="font-size:8px;"></i> Unsaved changes</span>
{% if last_save %} {% if last_save %}
<form method="POST" action="{{ url_for('images.os_undo', image_type=image_type) }}" class="d-inline" <form method="POST" action="{{ url_for('images.os_undo', image_type=image_type) }}" class="d-inline"
onsubmit="return confirm('Put back {{ last_save.files | map(attribute='name') | join(', ') }} as they were before the save of {{ last_save.ts | local_dt }}?');"> onsubmit="return confirm('Put back {{ last_save.files | map(attribute='name') | join(', ') }} as they were before the save of {{ last_save.ts | local_dt }}?');">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-outline-secondary btn-sm" title="Last save: {{ last_save.ts | local_dt }}"> <button type="submit" class="ui-btn" title="Last save: {{ last_save.ts | local_dt }}">Undo last save</button>
<i class="bi bi-arrow-counterclockwise"></i> Undo last save
</button>
</form> </form>
{% else %}
<span class="sub">Undo last save: nothing saved from this page yet</span>
{% endif %} {% endif %}
<button type="button" class="btn btn-success btn-sm" id="ospReview" disabled>
<i class="bi bi-file-earmark-diff"></i> Review and save
</button>
{% endset %} {% endset %}
{% set header_subtitle = 'Deploy/Control' %}
{% include "_image_header.html" %} {% include "_image_header.html" %}
<p class="text-muted osp-explain"> <div class="ui-stack" id="ospPage">
<p class="osp-intro">
What PESetup puts on the disk besides drivers: the Windows image from What PESetup puts on the disk besides drivers: the Windows image from
<span class="mono">OperatingSystem.json</span>, then the offline servicing packages from <span class="mono">OperatingSystem.json</span>, then the offline servicing packages from
<span class="mono">packages.json</span> (both in <span class="mono">Deploy/Control</span>). <span class="mono">packages.json</span>. Edits are staged until <strong>Review and save</strong>,
Edits here are staged until <strong>Review and save</strong>, which shows the exact diff of every file first. which shows the exact diff of every file first.
</p> </p>
{# ==================== Operating system ==================== #} {# ==================== Operating system ==================== #}
<div class="card" data-section="operating_systems"> <section class="panel ui-list" data-section="operating_systems" aria-labelledby="osp-os-h">
<div class="card-header"> <div class="osp-phead">
<span class="d-flex align-items-center gap-2"> <h2 id="osp-os-h">Operating system</h2>
<i class="bi bi-windows"></i> Operating system {{ ui.chip(oses | length, 'neutral') }}
<span class="badge badge-secondary">{{ oses | length }}</span> <span class="sp"><span class="sub mono">OperatingSystem.json</span></span>
</span>
<span class="small text-muted fw-normal mono">OperatingSystem.json</span>
</div>
<div class="table-container">
<table class="table mb-0 osp-table" id="ospOsTable">
<thead>
<tr>
<th>#</th>
<th>Windows</th>
<th>osId</th>
<th>Status</th>
<th>install.wim</th>
<th></th>
</tr>
</thead>
<tbody>
{% for r in oses %}
{% set osv = r.osv %}
<tr data-json='{{ r.entry | tojson_ordered }}'>
<td class="num order-num">{{ loop.index }}</td>
<td>
<strong>{{ osv.productName or osv.marketingName or 'Windows' }}</strong>
<div class="sub">{{ osv.marketingName }}{% if osv.versionNumber %} | version {{ osv.versionNumber }}{% endif %}{% if osv.buildNumber %} | build {{ osv.buildNumber }}{% endif %}</div>
</td>
<td class="mono">{{ osv.id }}</td>
<td class="text-nowrap">
{% if osv.isActive %}<span class="badge badge-success">Active</span>{% else %}<span class="badge badge-secondary">Inactive</span>{% endif %}
{% if os_selection is not none and osv.id | string == os_selection | string %}<span class="badge badge-primary" title="user_selections.json OperatingSystemSelection">Selected</span>{% endif %}
</td>
<td>
<div class="mono wrap">{{ r.dest or '(no DestinationDir)' }}</div>
<div class="sub">
{% if r.wim.exists %}<span class="badge badge-success">On disk</span> {{ r.wim.size | filesizeformat }}, modified {{ r.wim.mtime | local_dt }}
{% else %}<span class="badge badge-danger">install.wim not on disk</span>{% endif %}
| index {{ osv.wimindex if osv.wimindex is defined else (r.entry.wimindex if r.entry.wimindex is defined else '-') }}
{% if osv.wim and osv.wim.size and r.wim.exists and osv.wim.size != r.wim.size %}
<span class="badge badge-warning" title="size in OperatingSystem.json: {{ osv.wim.size }}">size differs from the JSON</span>
{% endif %}
</div>
</td>
<td class="text-end">
<button type="button" class="btn btn-outline-danger btn-row-action remove-row" title="Remove this entry">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
{% endfor %}
<tr class="empty-row" {% if oses %}style="display:none"{% endif %}><td colspan="6">No operating system entries. PESetup has nothing to apply.</td></tr>
</tbody>
</table>
</div> </div>
<div class="og osp-head" aria-hidden="true"><span>#</span><span>Windows</span><span class="c-id">osId</span><span class="c-st">Status</span><span class="c-wim">install.wim</span><span class="c-dest">Folder</span><span></span></div>
<table class="osp-list" id="ospOsTable">
<tbody>
{% for r in oses %}
{% set osv = r.osv %}
{% set idx = osv.wimindex if osv.wimindex is defined else (r.entry.wimindex if r.entry.wimindex is defined else '-') %}
{% set size_differs = osv.wim and osv.wim.size and r.wim.exists and osv.wim.size != r.wim.size %}
<tr data-json='{{ r.entry | tojson_ordered }}'><td class="ui-row">
<details>
<summary class="og">
<span class="num order-num">{{ loop.index }}</span>
<span class="tip" data-tip="{{ osv.productName or osv.marketingName or 'Windows' }}{% if osv.versionNumber %} {{ osv.versionNumber }}{% endif %}"><span class="trunc"><span class="name">{{ osv.productName or osv.marketingName or 'Windows' }}</span> <span class="muted">{{ osv.versionNumber }}</span></span></span>
<span class="mono c-id">{{ osv.id }}</span>
<span class="chips c-st">
{% if osv.isActive %}{{ ui.chip('Active', 'ok') }}{% else %}{{ ui.chip('Inactive', 'off') }}{% endif %}
{% if os_selection is not none and osv.id | string == os_selection | string %}{{ ui.chip('Selected', 'w11', 'user_selections.json OperatingSystemSelection') }}{% endif %}
</span>
<span class="chips c-wim">{% if r.wim.exists %}{{ ui.chip('On disk', 'ok') }}{% else %}{{ ui.chip('Not on disk', 'err') }}{% endif %}{% if size_differs %}{{ ui.chip('Size differs', 'warn') }}{% endif %}</span>
<span class="tip right c-dest" data-tip="{{ r.dest or '(no DestinationDir)' }}"><span class="trunc mono sub">{{ r.dest or '(no DestinationDir)' }}</span></span>
<span class="chev" aria-hidden="true">&#9662;</span>
</summary>
<div class="obody"><dl>
<dt>Windows</dt><dd>{{ osv.productName or '-' }}{% if osv.marketingName %} ({{ osv.marketingName }}){% endif %}</dd>
<dt>Version</dt><dd>{{ osv.versionNumber or '-' }}{% if osv.buildNumber %}, build {{ osv.buildNumber }}{% endif %}, osId <span class="mono">{{ osv.id }}</span></dd>
<dt>DestinationDir</dt><dd class="mono">{{ r.dest or '(no DestinationDir)' }}</dd>
<dt>install.wim</dt><dd>{% if r.wim.exists %}On disk, {{ r.wim.size | filesizeformat }}, modified {{ r.wim.mtime | local_dt }}{% else %}<strong>install.wim not on disk</strong>{% endif %}, image index {{ idx }}</dd>
{% if size_differs %}<dt>Size</dt><dd>Differs from the JSON: OperatingSystem.json says {{ osv.wim.size | filesizeformat }} ({{ osv.wim.size }} bytes)</dd>{% endif %}
</dl></div>
</details>
<div class="act">
{% call ui.more('operating system ' ~ loop.index) %}<button type="button" class="remove-row del">Remove entry</button>{% endcall %}
</div>
</td></tr>
{% endfor %}
<tr class="empty-row" {% if oses %}style="display:none"{% endif %}><td class="osp-empty">No operating system entries. PESetup has nothing to apply.</td></tr>
</tbody>
</table>
<div class="osp-note"> <div class="osp-note">
PESetup applies the image index of <span class="mono">install.wim</span> from the entry's folder PESetup applies the image index of <span class="mono">install.wim</span> from the entry's folder
(<span class="mono">*destinationdir*</span> is this image's share). (<span class="mono">*destinationdir*</span> is this image's share).
{% if os_selection is not none %}The entry marked Selected is the one the media was built for {% if os_selection is not none %}The entry marked Selected is the one the media was built for
(<span class="mono">OperatingSystemSelection = {{ os_selection }}</span>).{% endif %} (<span class="mono">OperatingSystemSelection = {{ os_selection }}</span>).{% endif %}
</div> </div>
</div> </section>
{# ==================== Packages ==================== #} {# ==================== Packages ==================== #}
<div class="card" data-section="packages"> <section class="panel ui-list" data-section="packages" aria-labelledby="osp-pk-h">
<div class="card-header"> <div class="osp-phead">
<span class="d-flex align-items-center gap-2"> <h2 id="osp-pk-h">Packages</h2>
<i class="bi bi-box-seam"></i> Packages {{ ui.chip(packages | length, 'neutral') }}
<span class="badge badge-secondary">{{ packages | length }}</span> <span class="sp">
<span class="sub mono">packages.json</span>
<button type="button" class="ui-btn" data-bs-toggle="modal" data-bs-target="#packageUploadModal">Upload file</button>
</span> </span>
<div class="header-actions">
<span class="small text-muted fw-normal mono align-self-center">packages.json</span>
<button type="button" class="btn btn-sm btn-outline-primary"
data-bs-toggle="modal" data-bs-target="#packageUploadModal">
<i class="bi bi-upload"></i> Upload file
</button>
</div>
</div>
<div class="table-container">
<table class="table mb-0 osp-table" id="ospPkgTable">
<thead>
<tr>
<th>#</th>
<th>Package</th>
<th>File</th>
<th>osId</th>
<th>Enabled</th>
<th>On disk</th>
<th></th>
</tr>
</thead>
<tbody>
{% for r in packages %}
{% set p = r.entry %}
<tr data-json='{{ p | tojson_ordered }}'>
<td class="num order-num">{{ loop.index }}</td>
<td>
<strong>{{ p.name or '(no name)' }}</strong>
{% if p.comment %}<div class="sub">{{ p.comment }}</div>{% endif %}
</td>
<td>
<div class="mono wrap">{{ p.fileName or p.FileName or '' }}</div>
<div class="sub mono wrap">{{ p.destinationDir or p.DestinationDir or '' }}</div>
</td>
<td class="mono">{{ p.osId }}</td>
<td>
<div class="form-check form-switch m-0">
<input class="form-check-input pkg-enabled" type="checkbox" role="switch"
aria-label="Enabled" {% if p.enabled %}checked{% endif %}>
</div>
</td>
<td class="text-nowrap">
{% if r.file.exists %}<span class="badge badge-success">Yes</span><div class="sub">{{ r.file.size | filesizeformat }}</div>
{% else %}<span class="badge badge-danger">No</span>{% endif %}
</td>
<td class="text-end">
<button type="button" class="btn btn-outline-danger btn-row-action remove-row" title="Remove this entry">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
{% endfor %}
<tr class="empty-row" {% if packages %}style="display:none"{% endif %}><td colspan="7">No packages configured.</td></tr>
</tbody>
</table>
</div> </div>
<div class="pg osp-head" aria-hidden="true"><span>#</span><span>Package</span><span class="c-file">File</span><span class="c-id">osId</span><span class="c-en">Enabled</span><span class="c-disk">On disk</span><span></span></div>
<table class="osp-list" id="ospPkgTable">
<tbody>
{% for r in packages %}
{% set p = r.entry %}
{% set fname = p.fileName or p.FileName or '' %}
{% set pdest = p.destinationDir or p.DestinationDir or '' %}
<tr data-json='{{ p | tojson_ordered }}'><td class="ui-row">
<details>
<summary class="pg">
<span class="num order-num">{{ loop.index }}</span>
<span class="tip" data-tip="{{ p.name or '(no name)' }}"><span class="trunc name">{{ p.name or '(no name)' }}</span></span>
<span class="tip c-file" data-tip="{{ fname }}"><span class="trunc mono sub">{{ fname }}</span></span>
<span class="mono c-id">{{ p.osId }}</span>
<span class="c-en pkg-state">{% if p.enabled %}{{ ui.chip('Enabled', 'ok') }}{% else %}{{ ui.chip('Disabled', 'off') }}{% endif %}</span>
<span class="c-disk">{% if r.file.exists %}{{ ui.chip('On disk', 'ok') }}{% else %}{{ ui.chip('Missing', 'err') }}{% endif %}</span>
<span class="chev" aria-hidden="true">&#9662;</span>
</summary>
<div class="obody"><dl>
{% if p.comment %}<dt>Comment</dt><dd>{{ p.comment }}</dd>{% endif %}
<dt>File</dt><dd class="mono">{{ fname or '-' }}</dd>
<dt>DestinationDir</dt><dd class="mono">{{ pdest or '-' }}</dd>
<dt>On disk</dt><dd>{% if r.file.exists %}Yes, {{ r.file.size | filesizeformat }}{% else %}<strong>No</strong>: the file is not in that folder{% endif %}</dd>
<dt>osId</dt><dd class="mono">{{ p.osId }}</dd>
</dl></div>
</details>
<div class="act">
<div class="form-check form-switch">
<input class="form-check-input pkg-enabled" type="checkbox" role="switch"
aria-label="Enabled: {{ p.name or fname }}" {% if p.enabled %}checked{% endif %}>
</div>
{% call ui.more('package ' ~ loop.index) %}<button type="button" class="remove-row del">Remove entry</button>{% endcall %}
</div>
</td></tr>
{% endfor %}
<tr class="empty-row" {% if packages %}style="display:none"{% endif %}><td class="osp-empty">No packages configured.</td></tr>
</tbody>
</table>
<div class="osp-note"> <div class="osp-note">
A package is applied when it is enabled and its osId list contains the OS being installed. A package is applied when it is enabled and its osId list contains the OS being installed.
Removing an entry here leaves the file on disk. Removing an entry here leaves the file on disk.
</div> </div>
</div> </section>
{# ==================== Hardware models (user_selections.json) ==================== #} {# ==================== Hardware models (user_selections.json) ==================== #}
<div class="card" data-section="hardware_models"> {% set n_none = models | selectattr('fam.entries', 'equalto', 0) | list | length %}
<div class="card-header"> <details class="panel ui-list osp-models" data-section="hardware_models" {% if not models %}open{% endif %}>
<span class="d-flex align-items-center gap-2"> <summary>
<i class="bi bi-cpu"></i> Hardware Models <span class="chev" aria-hidden="true">&#9662;</span>
<span class="badge badge-secondary">{{ models | length }}</span> <h2>Hardware models</h2>
</span> {{ ui.chip(models | length, 'neutral') }}
<div class="header-actions"> {% if n_none %}{{ ui.chip(n_none ~ ' with no driver entry', 'warn') }}{% endif %}
<span class="small text-muted fw-normal mono align-self-center">Tools/user_selections.json</span> <span class="sub">Media build selection, not used while imaging</span>
<button type="button" class="btn btn-sm btn-outline-primary" id="ospAddModel" {% if not has_selections %}disabled{% endif %}> </summary>
<i class="bi bi-plus-lg"></i> Add <div class="explain">
</button> <p>
</div>
</div>
<div class="card-body osp-explain border-bottom">
<p class="mb-2">
<strong>Media build selection, not used while imaging.</strong>
Media Creator Lite saved this list when the media was built: the driver families (Family ID) whose packs it Media Creator Lite saved this list when the media was built: the driver families (Family ID) whose packs it
downloaded, and the OS it built for. <span class="mono">scripts/Download-Drivers.ps1</span> and downloaded, and the OS it built for. <span class="mono">scripts/Download-Drivers.ps1</span> and
<span class="mono">Upload-Image.ps1</span> read it to choose which driver packs and OS to fetch and upload. <span class="mono">Upload-Image.ps1</span> read it to choose which driver packs and OS to fetch and upload.
</p> </p>
<p class="text-muted"> <p>
PESetup never reads this file: at imaging it matches the PC's WMI model against PESetup never reads this file: at imaging it matches the PC's WMI model against
<a href="{{ image_tab_url(image_type, 'drivers') }}">HardwareDriver.json</a> alone, so adding a model here does not <a href="{{ image_tab_url(image_type, 'drivers') }}">HardwareDriver.json</a> alone, so adding a model here does not
give a PC drivers. The Driver entries column counts map entries with the same <span class="mono">family</span>. give a PC drivers. Driver entries counts map entries with the same <span class="mono">family</span>.
</p> </p>
<div style="display: flex; flex-wrap: wrap; gap: 10px; align-items: center">
<span class="sub mono">Tools/user_selections.json</span>
<button type="button" class="ui-btn" id="ospAddModel" {% if not has_selections %}disabled{% endif %}>Add model</button>
</div>
</div> </div>
<div class="table-container"> <div class="mg osp-head" aria-hidden="true"><span>#</span><span class="c-model">Model</span><span class="c-fam">Family ID</span><span class="ents">Driver entries</span><span></span></div>
<table class="table mb-0 osp-table" id="ospModelTable"> <table class="osp-list" id="ospModelTable">
<thead> <tbody>
<tr> {% for r in models %}
<th>#</th> <tr data-json='{{ r.m | tojson_ordered }}'><td class="ui-row">
<th>Model</th> <div class="mg">
<th>Family ID</th> <span class="num order-num">{{ loop.index }}</span>
<th>Driver entries</th> <input type="text" class="field c-model" data-field="Model" value="{{ r.m.Model }}" aria-label="Model">
<th></th> <input type="text" class="field c-fam" data-field="Id" value="{{ r.m.Id }}" aria-label="Family ID">
</tr> <span class="ents tip right"{% if r.fam.entries %} data-tip="{{ r.fam.on_disk }} of {{ r.fam.entries }} zips on disk"{% endif %}>
</thead>
<tbody>
{% for r in models %}
<tr data-json='{{ r.m | tojson_ordered }}'>
<td class="num order-num">{{ loop.index }}</td>
<td><input type="text" class="form-control form-control-sm" data-field="Model" value="{{ r.m.Model }}" aria-label="Model"></td>
<td><input type="text" class="form-control form-control-sm" data-field="Id" value="{{ r.m.Id }}" aria-label="Family ID"></td>
<td class="text-nowrap">
{% if r.fam.entries %} {% if r.fam.entries %}
<span class="badge {{ 'badge-success' if r.fam.on_disk == r.fam.entries else 'badge-warning' }}">{{ r.fam.entries }} entr{{ 'ies' if r.fam.entries != 1 else 'y' }}</span> {{ ui.chip(r.fam.entries ~ ' entr' ~ ('ies' if r.fam.entries != 1 else 'y'), 'ok' if r.fam.on_disk == r.fam.entries else 'warn') }}
<div class="sub">{{ r.fam.on_disk }} zip{{ 's' if r.fam.on_disk != 1 }} on disk</div> <span class="sub">{{ r.fam.on_disk }} zip{{ 's' if r.fam.on_disk != 1 }} on disk</span>
{% else %} {% else %}
<span class="badge badge-secondary">None</span> {{ ui.chip('None', 'off') }}
{% endif %} {% endif %}
</td> </span>
<td class="text-end"> <button type="button" class="ui-btn ghost sm remove-row" aria-label="Remove model {{ r.m.Model }}">Remove</button>
<button type="button" class="btn btn-outline-danger btn-row-action remove-row" title="Remove this model"> </div>
<i class="bi bi-trash"></i> </td></tr>
</button> {% endfor %}
</td> <tr class="empty-row" {% if models %}style="display:none"{% endif %}><td class="osp-empty">{% if has_selections %}No hardware models selected.{% else %}No user_selections.json for this image.{% endif %}</td></tr>
</tr> </tbody>
{% endfor %} </table>
<tr class="empty-row" {% if models %}style="display:none"{% endif %}><td colspan="5">{% if has_selections %}No hardware models selected.{% else %}No user_selections.json for this image.{% endif %}</td></tr> </details>
</tbody>
</table> <div class="savebar" role="region" aria-label="Unsaved changes">
<span class="dirty-flag" id="ospDirty" role="status"><span class="dot" aria-hidden="true"></span> Unsaved changes</span>
<div style="display: flex; gap: 8px">
<button type="button" class="ui-btn" id="ospDiscard">Discard</button>
<button type="button" class="ui-btn pri" id="ospReview" disabled>Review and save</button>
</div> </div>
</div> </div>
</div>
<!-- Package upload modal (unchanged from the old Configuration page) --> <!-- Package upload modal -->
<div class="modal fade" id="packageUploadModal" tabindex="-1"> <div class="modal fade" id="packageUploadModal" tabindex="-1" aria-labelledby="pkgUploadTitle">
<div class="modal-dialog"> <div class="modal-dialog">
<form method="POST" action="{{ url_for('images_packages_upload', image_type=image_type) }}" enctype="multipart/form-data"> <form method="POST" action="{{ url_for('images_packages_upload', image_type=image_type) }}" enctype="multipart/form-data">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h3 class="modal-title">Upload package</h3> <h3 class="modal-title" id="pkgUploadTitle">Upload package</h3>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="form-field"> <div class="form-field">
<label class="form-label">Package file</label> <label class="form-label" for="pkgUploadFile">Package file</label>
<input type="file" class="form-control" name="package_file" required> <input type="file" class="form-control" name="package_file" id="pkgUploadFile" required>
<div class="form-text">Lands in <code>{{ image_type }}/Deploy/Packages/</code>.</div> <div class="form-text">Lands in <span class="mono">{{ image_type }}/Deploy/Packages/</span>.</div>
</div> </div>
<div class="form-field"> <div class="form-field">
<label class="form-label">Destination directory (optional)</label> <label class="form-label" for="pkgUploadDest">Destination directory (optional)</label>
<input type="text" class="form-control mono" name="destination_dir" <input type="text" class="form-control mono" name="destination_dir" id="pkgUploadDest"
placeholder="*destinationdir*\Packages"> placeholder="*destinationdir*\Packages">
<div class="form-text">Setting this also appends an entry to <code>packages.json</code>.</div> <div class="form-text">Setting this also appends an entry to <span class="mono">packages.json</span>.</div>
</div> </div>
<div class="form-check"> <div class="form-check">
<input class="form-check-input" type="checkbox" name="overwrite" value="1" id="pkgOverwriteCheck"> <input class="form-check-input" type="checkbox" name="overwrite" value="1" id="pkgOverwriteCheck">
@@ -272,8 +307,8 @@
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> <button type="button" class="ui-btn" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Upload</button> <button type="submit" class="ui-btn pri">Upload</button>
</div> </div>
</div> </div>
</form> </form>
@@ -289,11 +324,13 @@
var dirty = {}; var dirty = {};
var reviewBtn = document.getElementById('ospReview'); var reviewBtn = document.getElementById('ospReview');
var head = document.querySelector('.image-head'); var head = document.querySelector('.image-head');
var page = document.getElementById('ospPage');
function markDirty(section) { function markDirty(section) {
dirty[section] = true; dirty[section] = true;
reviewBtn.disabled = false; reviewBtn.disabled = false;
if (head) head.classList.add('dirty'); if (head) head.classList.add('dirty');
if (page) page.classList.add('dirty');
} }
function rowsOf(tableId) { function rowsOf(tableId) {
@@ -315,7 +352,15 @@
document.querySelectorAll('[data-section]').forEach(function (card) { document.querySelectorAll('[data-section]').forEach(function (card) {
var section = card.getAttribute('data-section'); var section = card.getAttribute('data-section');
card.addEventListener('input', function () { markDirty(section); }); card.addEventListener('input', function () { markDirty(section); });
card.addEventListener('change', function () { markDirty(section); }); card.addEventListener('change', function (e) {
markDirty(section);
// Keep the row's Enabled chip in step with its switch.
if (e.target.classList.contains('pkg-enabled')) {
var st = e.target.closest('tr').querySelector('.pkg-state');
if (st) st.innerHTML = e.target.checked
? '<span class="chip c-ok">Enabled</span>' : '<span class="chip c-off">Disabled</span>';
}
});
card.addEventListener('click', function (e) { card.addEventListener('click', function (e) {
// app.js removes the row and renumbers (delegated on document, so it // app.js removes the row and renumbers (delegated on document, so it
// runs after this); refresh the empty row once it is gone. // runs after this); refresh the empty row once it is gone.
@@ -334,11 +379,13 @@
var tr = document.createElement('tr'); var tr = document.createElement('tr');
tr.className = 'row-new'; tr.className = 'row-new';
tr.innerHTML = tr.innerHTML =
'<td class="num order-num"></td>' + '<td class="ui-row"><div class="mg">' +
'<td><input type="text" class="form-control form-control-sm" data-field="Model" placeholder="Model name" aria-label="Model"></td>' + '<span class="num order-num"></span>' +
'<td><input type="text" class="form-control form-control-sm" data-field="Id" placeholder="Driver family ID" aria-label="Family ID"></td>' + '<input type="text" class="field c-model" data-field="Model" placeholder="Model name" aria-label="Model">' +
'<td><span class="badge badge-info">New</span></td>' + '<input type="text" class="field c-fam" data-field="Id" placeholder="Driver family ID" aria-label="Family ID">' +
'<td class="text-end"><button type="button" class="btn btn-outline-danger btn-row-action remove-row" title="Remove this model"><i class="bi bi-trash"></i></button></td>'; '<span class="ents"><span class="chip c-edit">New</span></span>' +
'<button type="button" class="ui-btn ghost sm remove-row" aria-label="Remove this model">Remove</button>' +
'</div></td>';
tbody.insertBefore(tr, tbody.querySelector('.empty-row')); tbody.insertBefore(tr, tbody.querySelector('.empty-row'));
rowsOf('ospModelTable').forEach(function (r, i) { r.querySelector('.order-num').textContent = i + 1; }); rowsOf('ospModelTable').forEach(function (r, i) { r.querySelector('.order-num').textContent = i + 1; });
refreshEmpty(tbody.closest('table')); refreshEmpty(tbody.closest('table'));
@@ -391,6 +438,15 @@
}); });
}); });
var discardBtn = document.getElementById('ospDiscard');
if (discardBtn) {
discardBtn.addEventListener('click', function () {
if (!confirm('Discard the unsaved changes on this page?')) return;
dirty = {};
window.location.reload();
});
}
window.addEventListener('beforeunload', function (e) { window.addEventListener('beforeunload', function (e) {
if (Object.keys(dirty).length) { e.preventDefault(); e.returnValue = ''; } if (Object.keys(dirty).length) { e.preventDefault(); e.returnValue = ''; }
}); });

View File

@@ -1,261 +1,313 @@
{% extends "base.html" %} {% extends "base.html" %}
{% import '_ui.html' as ui %}
{% block title %}{{ friendly_name }} - Overview{% endblock %} {% block title %}{{ friendly_name }} - Overview{% endblock %}
{# Overview of one image: what needs attention first (each item links to the
tab that fixes it), a calm row of counts, the control files one line each
(description in the row body), then compact lists of hardware gaps, newer
Dell packs and missing zips. Deep links into the Drivers tab use its
?q=, ?os=, ?missing=1 and ?findings=1 filters. #}
{% block extra_head %} {% block extra_head %}
<style> <style>
.ov-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; } /* Needs attention */
.ov-tile { .ov-attn { list-style: none; margin: 0; padding: 0; }
display: block; text-decoration: none; color: var(--text); .ov-attn li { display: grid; grid-template-columns: 74px minmax(0, 1fr) auto; gap: 4px 14px; align-items: start;
background: var(--bg-card); border: 1px solid var(--border); border-radius: 0.25rem; padding: 12px 24px; border-top: 1px solid var(--line); font-size: 14px; line-height: 1.5; }
border-left: 4px solid var(--border); .ov-attn li .chip { justify-self: start; margin-top: 1px; }
padding: 0.9rem 1rem; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06); .ov-attn li a { white-space: nowrap; font-size: 13px; font-weight: 600; text-decoration: none; }
transition: border-color 0.15s; .ov-attn li a:hover { text-decoration: underline; }
.ov-clear { padding: 12px 24px 16px; border-top: 1px solid var(--line); font-size: 14px; color: var(--text-2); display: flex; gap: 10px; align-items: center; }
.ov-phead { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; padding: 16px 24px 12px; }
.ov-phead h2 { margin: 0; font-size: 16px; font-weight: 600; }
.ov-phead .sp { margin-left: auto; font-size: 13px; }
/* Summary counts */
.ov-stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); }
@container (max-width: 760px) { .ov-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
.ov-stat { display: flex; flex-direction: column; gap: 4px; padding: 16px 20px; text-decoration: none; color: var(--text);
border-top: 1px solid var(--line); border-left: 1px solid var(--line); margin: 0 0 -1px -1px; min-width: 0; }
a.ov-stat:hover { background: var(--surface-2); color: var(--text); }
.ov-stat .lbl { font-size: 13px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ov-stat .st { display: flex; flex-wrap: wrap; gap: 6px; }
.ov-stat .val { font-size: 24px; font-weight: 600; line-height: 1.2; }
.ov-stat .val.txt { font-size: 15px; line-height: 1.35; }
.ov-stat .det { font-size: 12px; color: var(--muted); }
.ov-stats-wrap { overflow: hidden; border-radius: 0 0 14px 14px; container-type: inline-size; }
/* Control files: one line each */
.cf-grid { display: grid; grid-template-columns: minmax(0, 1fr) 190px 80px 72px 14px; align-items: center; gap: 0 14px;
padding: 11px 100px 11px 24px; font-size: 14px; min-height: 24px; }
.cf-head { font-size: 12px; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); padding-top: 6px; padding-bottom: 6px; }
.cf-grid .r { text-align: right; white-space: nowrap; }
.cf-grid .when { white-space: nowrap; font-size: 13px; color: var(--text-2); }
.cf-body { padding: 0 24px 14px 24px; font-size: 13px; color: var(--text-2); display: flex; flex-direction: column; gap: 4px; }
.cf-body .mono { overflow-wrap: anywhere; color: var(--muted); }
.ui-row > .act { top: 8px; }
/* Compact lists */
.ov-cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(420px, 100%), 1fr)); gap: 22px; align-items: start; }
.cl { list-style: none; margin: 0; padding: 0; }
.cl li { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: 0 12px; align-items: center; padding: 10px 24px; border-top: 1px solid var(--line); font-size: 14px; }
.cl li .when { font-size: 12px; color: var(--muted); white-space: nowrap; }
.cl li a.rowlink { color: var(--text); text-decoration: none; min-width: 0; }
.cl li a.rowlink:hover { color: var(--accent); }
.cl .pos { color: var(--muted); font-size: 12px; }
.cl-more { padding: 10px 24px 14px; border-top: 1px solid var(--line); font-size: 13px; }
.cl-empty { padding: 12px 24px 16px; border-top: 1px solid var(--line); font-size: 13px; color: var(--muted); }
@container (max-width: 760px) {
.cf-grid { grid-template-columns: minmax(0, 1fr) auto 14px; padding: 11px 90px 11px 16px; }
.cf-grid .c-size, .cf-grid .c-bak { display: none; }
.cf-head { display: none; }
.cf-body { padding: 0 16px 14px; }
}
@container (max-width: 480px) {
.cf-grid { grid-template-columns: minmax(0, 1fr) 14px; }
.cf-grid .when { grid-column: 1; grid-row: 2; }
.cf-grid .chev { grid-column: 2; grid-row: 1; }
}
@media (max-width: 600px) {
.ov-attn li { grid-template-columns: auto minmax(0, 1fr); padding: 12px 16px; }
.ov-attn li a { grid-column: 2; }
.ov-phead, .ov-clear, .cl li, .cl-more, .cl-empty { padding-left: 16px; padding-right: 16px; }
.cl li { grid-template-columns: minmax(0, 1fr) auto; }
.cl li .when { grid-column: 1; }
} }
a.ov-tile:hover { color: var(--text); border-color: var(--primary); }
.ov-tile.ok { border-left-color: var(--success); }
.ov-tile.warn { border-left-color: var(--warning); }
.ov-tile.bad { border-left-color: var(--danger); }
.ov-tile .label { font-size: 11px; color: var(--text-light); text-transform: uppercase; letter-spacing: 1px; font-weight: 600; }
.ov-tile .value { font-size: 1.6rem; font-weight: 600; line-height: 1.2; margin: 0.25rem 0 0.15rem; }
.ov-tile .value.small-value { font-size: 0.95rem; line-height: 1.35; }
.ov-tile .detail { font-size: 12px; color: var(--text-light); }
.ov-tile.bad .value { color: var(--danger); }
.ov-tile.warn .value { color: var(--warning-dark); }
[data-theme="dark"] .ov-tile.warn .value { color: var(--warning); }
.ov-attn { list-style: none; padding: 0; margin: 0; }
.ov-attn li { display: flex; gap: 0.6rem; align-items: flex-start; padding: 0.55rem 1.25rem; border-bottom: 1px solid var(--border); font-size: 14px; }
.ov-attn li:last-child { border-bottom: none; }
.ov-attn .bi { margin-top: 0.15rem; }
.ov-attn .bi-x-octagon { color: var(--danger); }
.ov-attn .bi-exclamation-triangle { color: var(--warning); }
.ov-attn .bi-info-circle { color: var(--primary); }
.ov-attn a { margin-left: auto; white-space: nowrap; font-size: 13px; }
.ov-table td { vertical-align: top; white-space: normal; }
.ov-table .file-cell { overflow-wrap: anywhere; }
.ov-table .small { font-size: 12px; }
.ov-list-more { font-size: 12.5px; color: var(--text-light); padding: 0.5rem 1.25rem; }
</style> </style>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
{% set header_actions %} {% set header_actions %}
<a href="{{ url_for('hardware_page') }}" class="btn btn-outline-secondary btn-sm"><i class="bi bi-cpu"></i> Hardware Coverage</a> <a href="{{ url_for('hardware_page') }}" class="ui-btn">Hardware coverage</a>
<a href="{{ url_for('dell_updates.page') }}" class="btn btn-outline-secondary btn-sm"><i class="bi bi-motherboard"></i> Dell Updates</a> <a href="{{ url_for('dell_updates.page') }}" class="ui-btn">Dell updates</a>
{% endset %} {% endset %}
{% include "_image_header.html" %} {% include "_image_header.html" %}
{% set d = ov.drivers %} {% set d = ov.drivers %}
{% set lint = ov.lint %} {% set lint = ov.lint %}
{# missing-zip findings have their own tile; bad_lint is what is wrong in the map itself #} {# missing-zip findings are counted on their own; bad_lint is what is wrong in the map itself #}
{% set bad_lint = lint.map_errors if lint.available else 0 %} {% set bad_lint = lint.map_errors if lint.available else 0 %}
{% set os_row = ov.os.current %} {% set os_row = ov.os.current %}
{% set drv_url = image_tab_url(image_type, 'drivers') %}
{% set os_url = image_tab_url(image_type, 'os') %}
{# -------- tiles -------- #} {# -------- needs attention: (kind, text, link, label) -------- #}
<div class="ov-grid">
<a class="ov-tile {{ 'ok' if d.total else 'bad' }}" href="{{ image_tab_url(image_type, 'drivers') }}">
<div class="label">Driver entries</div>
<div class="value">{{ d.total }}</div>
<div class="detail">{{ d.by_os.Win11 }} Win11 | {{ d.by_os.Win10 }} Win10{% if d.by_os.Other %} | {{ d.by_os.Other }} other{% endif %}</div>
</a>
<a class="ov-tile {{ 'bad' if d.missing else 'ok' }}" href="{{ image_tab_url(image_type, 'drivers') }}">
<div class="label">Driver zips missing</div>
<div class="value">{{ d.missing | length }}</div>
<div class="detail">{% if d.missing %}{{ d.missing | selectattr('os', 'equalto', 'Win11') | list | length }} of them Win11{% else %}every entry's zip is on the share{% endif %}</div>
</a>
<a class="ov-tile {{ ('bad' if bad_lint else ('warn' if lint.total else 'ok')) if lint.available else '' }}" href="{{ image_tab_url(image_type, 'drivers') }}">
<div class="label">Driver map lint</div>
{% if lint.available %}
<div class="value">{{ lint.total }}</div>
<div class="detail">{% for lv, n in lint.by_level | dictsort %}{{ n }} {{ lv | lower }}{% if not loop.last %} | {% endif %}{% else %}no findings{% endfor %}</div>
{% else %}
<div class="value small-value text-muted">Not available</div>
<div class="detail">Shown once the Drivers tab lint is installed</div>
{% endif %}
</a>
<a class="ov-tile {{ ('ok' if os_row.wim.exists else 'bad') if os_row else 'bad' }}" href="{{ image_tab_url(image_type, 'os') }}">
<div class="label">Operating system</div>
{% if os_row %}
<div class="value small-value">{{ os_row.osv.productName or os_row.osv.marketingName }} {{ os_row.osv.versionNumber }}</div>
<div class="detail">osId {{ os_row.osv.id }} |
{% if os_row.wim.exists %}install.wim on disk ({{ (os_row.wim.size / 1073741824) | round(1) }} GB){% else %}<strong>install.wim not on disk</strong>{% endif %}</div>
{% else %}
<div class="value small-value">None</div>
<div class="detail">OperatingSystem.json is empty or missing</div>
{% endif %}
</a>
<a class="ov-tile {{ 'bad' if ov.packages.missing else 'ok' }}" href="{{ image_tab_url(image_type, 'os') }}">
<div class="label">Packages</div>
<div class="value">{{ ov.packages.enabled }}<span class="detail"> of {{ ov.packages.total }} enabled</span></div>
<div class="detail">{% if ov.packages.missing %}{{ ov.packages.missing | length }} enabled file{{ 's' if ov.packages.missing | length != 1 }} missing{% else %}all enabled files on disk{% endif %}</div>
</a>
<a class="ov-tile {{ ('bad' if ov.gaps else 'ok') if ov.gaps_checked else '' }}" href="{{ url_for('hardware_page') }}">
<div class="label">Hardware gaps</div>
{% if ov.gaps_checked %}
<div class="value">{{ ov.gaps | length }}</div>
<div class="detail">models seen by WinPE with no working driver entry here</div>
{% else %}
<div class="value small-value text-muted">Not tracked</div>
<div class="detail">Hardware Coverage checks the three GE images only</div>
{% endif %}
</a>
<a class="ov-tile {{ 'warn' if ov.dell.rows else ('ok' if ov.dell.checked else '') }}" href="{{ url_for('dell_updates.page') }}">
<div class="label">Dell updates</div>
{% if ov.dell.checked %}
<div class="value">{{ ov.dell.rows | length }}</div>
<div class="detail">newer driver pack{{ 's' if ov.dell.rows | length != 1 }} | checked {{ ov.dell.checked | local_dt }}</div>
{% else %}
<div class="value small-value text-muted">Not checked</div>
<div class="detail">Run a check on Updates, Dell Drivers and BIOS</div>
{% endif %}
</a>
</div>
{# -------- needs attention -------- #}
{% set attn = [] %} {% set attn = [] %}
{% if d.missing %}{% set _ = attn.append(('x-octagon', d.missing | length ~ ' driver entr' ~ ('ies point' if d.missing | length != 1 else 'y points') ~ ' at a zip that is not on the share. PESetup only warns, and the PC comes up without NIC or WiFi drivers.', image_tab_url(image_type, 'drivers'), 'Drivers')) %}{% endif %} {% if d.missing %}{% set _ = attn.append(('err', d.missing | length ~ ' driver entr' ~ ('ies point' if d.missing | length != 1 else 'y points') ~ ' at a zip that is not on the share. PESetup only warns, and the PC comes up without NIC or WiFi drivers.', image_tab_url(image_type, 'drivers', missing=1), 'Drivers')) %}{% endif %}
{% if bad_lint %}{% set _ = attn.append(('x-octagon', bad_lint ~ ' driver map lint error' ~ ('s' if bad_lint != 1) ~ ' in the entries themselves (a token or order PESetup will mis-match; see the findings on the Drivers tab).', image_tab_url(image_type, 'drivers'), 'Drivers')) %}{% endif %} {% if bad_lint %}{% set _ = attn.append(('err', bad_lint ~ ' driver map lint error' ~ ('s' if bad_lint != 1) ~ ' in the entries themselves (a token or order PESetup will mis-match; see the findings on the Drivers tab).', image_tab_url(image_type, 'drivers', findings=1), 'Drivers')) %}{% endif %}
{% if not os_row %}{% set _ = attn.append(('x-octagon', 'OperatingSystem.json has no entry: PESetup has no Windows image to apply.', image_tab_url(image_type, 'os'), 'OS and Packages')) %} {% if not os_row %}{% set _ = attn.append(('err', 'OperatingSystem.json has no entry: PESetup has no Windows image to apply.', os_url, 'OS and Packages')) %}
{% elif not os_row.wim.exists %}{% set _ = attn.append(('x-octagon', 'install.wim is not on disk in ' ~ (os_row.dest or 'the configured folder (DestinationDir is empty)') ~ '.', image_tab_url(image_type, 'os'), 'OS and Packages')) %}{% endif %} {% elif not os_row.wim.exists %}{% set _ = attn.append(('err', 'install.wim is not on disk in ' ~ (os_row.dest or 'the configured folder (DestinationDir is empty)') ~ '.', os_url, 'OS and Packages')) %}{% endif %}
{% if ov.packages.missing %}{% set _ = attn.append(('exclamation-triangle', ov.packages.missing | length ~ ' enabled package file' ~ ('s are' if ov.packages.missing | length != 1 else ' is') ~ ' not on disk.', image_tab_url(image_type, 'os'), 'OS and Packages')) %}{% endif %} {% if ov.packages.missing %}{% set _ = attn.append(('warn', ov.packages.missing | length ~ ' enabled package file' ~ ('s are' if ov.packages.missing | length != 1 else ' is') ~ ' not on disk.', os_url, 'OS and Packages')) %}{% endif %}
{% if ov.gaps %}{% set _ = attn.append(('exclamation-triangle', ov.gaps | length ~ ' model' ~ ('s' if ov.gaps | length != 1) ~ ' booted WinPE here with no working driver entry in this image.', url_for('hardware_page'), 'Hardware Coverage')) %}{% endif %} {% if ov.gaps %}{% set _ = attn.append(('warn', ov.gaps | length ~ ' model' ~ ('s' if ov.gaps | length != 1) ~ ' booted WinPE here with no working driver entry in this image.', url_for('hardware_page'), 'Hardware coverage')) %}{% endif %}
{% if ov.unattend_override %}{% set _ = attn.append(('exclamation-triangle', 'Deploy/Tools/GE/XML/FlatUnattendW10.xml exists. PESetup loads that file first, so the Unattend tab (which edits Deploy/FlatUnattendW10.xml) is not what imaging uses.', image_tab_url(image_type, 'unattend'), 'Unattend')) %}{% endif %} {% if ov.unattend_override %}{% set _ = attn.append(('warn', 'Deploy/Tools/GE/XML/FlatUnattendW10.xml exists. PESetup loads that file first, so the Unattend tab (which edits Deploy/FlatUnattendW10.xml) is not what imaging uses.', image_tab_url(image_type, 'unattend'), 'Unattend')) %}{% endif %}
{% if ov.hw_drivers_extra %}{% set _ = attn.append(('info-circle', 'Deploy/Control/hw_drivers.json exists. PESetup never reads it; only HardwareDriver.json decides drivers.', image_tab_url(image_type, 'drivers'), 'Drivers')) %}{% endif %} {% if ov.hw_drivers_extra %}{% set _ = attn.append(('neutral', 'Deploy/Control/hw_drivers.json exists. PESetup never reads it; only HardwareDriver.json decides drivers.', drv_url, 'Drivers')) %}{% endif %}
{% if ov.dell.rows %}{% set _ = attn.append(('info-circle', ov.dell.rows | length ~ ' driver pack' ~ ('s have' if ov.dell.rows | length != 1 else ' has') ~ ' a newer Dell release (last check ' ~ (ov.dell.checked | local_dt) ~ ').', url_for('dell_updates.page'), 'Dell Updates')) %}{% endif %} {% if ov.dell.rows %}{% set _ = attn.append(('neutral', ov.dell.rows | length ~ ' driver pack' ~ ('s have' if ov.dell.rows | length != 1 else ' has') ~ ' a newer Dell release (last check ' ~ (ov.dell.checked | local_dt) ~ ').', url_for('dell_updates.page'), 'Dell updates')) %}{% endif %}
{% if ov.models.no_entry %}{% set _ = attn.append(('info-circle', ov.models.no_entry | length ~ ' hardware model selection' ~ ('s name' if ov.models.no_entry | length != 1 else ' names') ~ ' a driver family with no entry in HardwareDriver.json (media build selection only; imaging does not use it).', image_tab_url(image_type, 'os'), 'OS and Packages')) %}{% endif %} {% if ov.models.no_entry %}{% set _ = attn.append(('neutral', ov.models.no_entry | length ~ ' hardware model selection' ~ ('s name' if ov.models.no_entry | length != 1 else ' names') ~ ' a driver family with no entry in HardwareDriver.json (media build selection only; imaging does not use it).', os_url, 'OS and Packages')) %}{% endif %}
{% set kind_label = {'err': 'Error', 'warn': 'Warning', 'neutral': 'Note'} %}
{% set n_err = attn | selectattr(0, 'equalto', 'err') | list | length %}
<div class="card"> <div class="ui-stack">
<div class="card-header">
<span class="d-flex align-items-center gap-2"><i class="bi bi-clipboard-pulse"></i> Needs attention</span> <section class="panel" aria-labelledby="ov-attn-h">
<span class="badge {{ 'badge-danger' if attn | selectattr(0, 'equalto', 'x-octagon') | list else ('badge-warning' if attn else 'badge-success') }}">{{ attn | length or 'All clear' }}</span> <div class="ov-phead">
<h2 id="ov-attn-h">Needs attention</h2>
{% if n_err %}{{ ui.chip(n_err ~ ' error' ~ ('s' if n_err != 1), 'err') }}{% endif %}
{% if attn | length > n_err %}{{ ui.chip((attn | length - n_err) ~ ' other', 'neutral') }}{% endif %}
</div> </div>
{% if attn %} {% if attn %}
<ul class="ov-attn"> <ul class="ov-attn">
{% for icon, text, link, label in attn %} {% for kind, text, link, label in attn %}
<li><i class="bi bi-{{ icon }}"></i><span>{{ text }}</span><a href="{{ link }}">{{ label }} <i class="bi bi-chevron-right"></i></a></li> <li>{{ ui.chip(kind_label[kind], kind) }}<span>{{ text }}</span><a href="{{ link }}">Open {{ label }}</a></li>
{% endfor %} {% endfor %}
</ul> </ul>
{% else %} {% else %}
<div class="card-body text-muted">Driver zips, install.wim and packages are all on disk and no seen model is missing drivers.</div> <div class="ov-clear">{{ ui.chip('All clear', 'ok') }} Driver zips, install.wim and packages are all on disk and no seen model is missing drivers.</div>
{% endif %} {% endif %}
</div> </section>
{# -------- summary counts -------- #}
<section class="panel" aria-labelledby="ov-sum-h">
<div class="ov-phead"><h2 id="ov-sum-h">At a glance</h2></div>
<div class="ov-stats-wrap"><div class="ov-stats">
{% for osn in ('Win11', 'Win10') %}
<a class="ov-stat" href="{{ image_tab_url(image_type, 'drivers', os=osn) }}">
<span class="lbl">{{ osn }} driver entries</span>
<span class="val">{{ d.by_os[osn] }}</span>
<span class="st">{{ ui.chip(osn, 'w11' if osn == 'Win11' else 'w10') }}{% if not d.total and osn == 'Win11' %} {{ ui.chip('Map empty', 'err') }}{% endif %}</span>
<span class="det">of {{ d.total }} entries{% if d.by_os.Other and osn == 'Win10' %}, {{ d.by_os.Other }} other{% endif %}</span>
</a>
{% endfor %}
<a class="ov-stat" href="{{ image_tab_url(image_type, 'drivers', missing=1) if d.missing else drv_url }}">
<span class="lbl">Zips missing</span>
<span class="val">{{ d.missing | length }}</span>
<span class="st">{{ ui.chip('Missing', 'err') if d.missing else ui.chip('OK', 'ok') }}</span>
<span class="det">{% if d.missing %}{{ d.missing | selectattr('os', 'equalto', 'Win11') | list | length }} of them Win11{% else %}every zip is on the share{% endif %}</span>
</a>
<a class="ov-stat" href="{{ image_tab_url(image_type, 'drivers', findings=1) if lint.available and lint.total else drv_url }}">
{% if lint.available %}
<span class="lbl">Map lint</span>
<span class="val">{{ lint.total }}</span>
<span class="st">{{ ui.chip('Errors', 'err') if bad_lint else (ui.chip('Findings', 'warn') if lint.total else ui.chip('Clean', 'ok')) }}</span>
<span class="det">{% for lv, n in lint.by_level | dictsort %}{{ n }} {{ lv | lower }}{% if not loop.last %}, {% endif %}{% else %}no findings{% endfor %}</span>
{% else %}
<span class="lbl">Map lint</span>
<span class="st">{{ ui.chip('Not available', 'off') }}</span>
<span class="det">Shown once the Drivers tab lint is installed</span>
{% endif %}
</a>
<a class="ov-stat" href="{{ os_url }}">
{% if os_row %}
<span class="lbl">Operating system</span>
<span class="val txt">{{ os_row.osv.productName or os_row.osv.marketingName }} {{ os_row.osv.versionNumber }}</span>
<span class="st">{{ ui.chip('On disk', 'ok') if os_row.wim.exists else ui.chip('wim missing', 'err') }}</span>
<span class="det">osId {{ os_row.osv.id }}{% if os_row.wim.exists %}, install.wim {{ (os_row.wim.size / 1073741824) | round(1) }} GB{% endif %}</span>
{% else %}
<span class="lbl">Operating system</span>
<span class="st">{{ ui.chip('None', 'err') }}</span>
<span class="det">OperatingSystem.json is empty or missing</span>
{% endif %}
</a>
<a class="ov-stat" href="{{ os_url }}">
<span class="lbl">Packages</span>
<span class="val">{{ ov.packages.enabled }} <span class="det">of {{ ov.packages.total }} enabled</span></span>
<span class="st">{{ ui.chip('File missing', 'warn') if ov.packages.missing else ui.chip('OK', 'ok') }}</span>
<span class="det">{% if ov.packages.missing %}{{ ov.packages.missing | length }} enabled file{{ 's' if ov.packages.missing | length != 1 }} missing{% else %}all enabled files on disk{% endif %}</span>
</a>
<a class="ov-stat" href="{{ url_for('hardware_page') }}">
{% if ov.gaps_checked %}
<span class="lbl">Hardware gaps</span>
<span class="val">{{ ov.gaps | length }}</span>
<span class="st">{{ ui.chip('Gaps', 'warn') if ov.gaps else ui.chip('OK', 'ok') }}</span>
<span class="det">seen models with no working driver entry</span>
{% else %}
<span class="lbl">Hardware gaps</span>
<span class="st">{{ ui.chip('Not tracked', 'off') }}</span>
<span class="det">Hardware coverage checks the three GE images only</span>
{% endif %}
</a>
<a class="ov-stat" href="{{ url_for('dell_updates.page') }}">
{% if ov.dell.checked %}
<span class="lbl">Dell updates</span>
<span class="val">{{ ov.dell.rows | length }}</span>
<span class="st">{{ ui.chip('Newer packs', 'neutral') if ov.dell.rows else ui.chip('Current', 'ok') }}</span>
<span class="det">checked {{ ov.dell.checked | local_dt }}</span>
{% else %}
<span class="lbl">Dell updates</span>
<span class="st">{{ ui.chip('Not checked', 'off') }}</span>
<span class="det">Run a check on Updates, Dell drivers and BIOS</span>
{% endif %}
</a>
</div></div>
</section>
{# -------- control files -------- #} {# -------- control files -------- #}
<div class="card"> <section class="panel ui-list" aria-labelledby="ov-cf-h">
<div class="card-header"> <div class="ov-phead"><h2 id="ov-cf-h">Control files</h2></div>
<span class="d-flex align-items-center gap-2"><i class="bi bi-folder2-open"></i> Control files</span> <div class="cf-grid cf-head" aria-hidden="true"><span>File</span><span>Last modified</span><span class="r c-size">Size</span><span class="r c-bak">Backups</span><span></span></div>
</div> {% for f in ov.files %}
<div class="table-container"> <div class="ui-row">
<table class="table mb-0 ov-table"> <details>
<thead><tr><th>File</th><th>Last modified</th><th class="text-end">Size</th><th class="text-end">Backups</th></tr></thead> <summary class="cf-grid">
<tbody> <span class="tip" data-tip="{{ f.rel }}"><span class="trunc mono">{{ f.name }}</span></span>
{% for f in ov.files %} <span class="when">{% if f.exists %}{{ f.mtime | local_dt }}{% else %}{{ ui.chip('Missing', 'err') }}{% endif %}</span>
<tr> <span class="r mono c-size">{% if f.exists %}{{ f.size | filesizeformat }}{% else %}-{% endif %}</span>
<td> <span class="r mono c-bak">{{ f.backups or '-' }}</span>
<a href="{{ image_tab_url(image_type, f.tab) }}" class="mono">{{ f.name }}</a> <span class="chev" aria-hidden="true">&#9662;</span>
<div class="small text-muted">{{ f.what }}</div> </summary>
<div class="small text-muted mono file-cell">{{ f.rel }}</div> <div class="cf-body">
</td> <span>{{ f.what }}</span>
<td class="text-nowrap"> <span class="mono">{{ f.rel }}</span>
{% if f.exists %}{{ f.mtime | local_dt }}{% else %}<span class="badge badge-danger">Missing</span>{% endif %} <span>{% if f.exists %}{{ f.size | filesizeformat }}, {% endif %}{{ f.backups }} backup{{ 's' if f.backups != 1 }} next to the file</span>
</td> </div>
<td class="text-end text-nowrap mono">{% if f.exists %}{{ f.size | filesizeformat }}{% else %}-{% endif %}</td> </details>
<td class="text-end mono">{{ f.backups or '-' }}</td> <div class="act"><a class="ui-btn sm" href="{{ image_tab_url(image_type, f.tab) }}" aria-label="Open {{ f.name }}">Open</a></div>
</tr>
{% endfor %}
</tbody>
</table>
</div> </div>
{% endfor %}
{% set saves = ov.last_saves | dictsort | selectattr(1) | list %} {% set saves = ov.last_saves | dictsort | selectattr(1) | list %}
{% if saves %} {% if saves %}
<div class="card-footer small text-muted"> <div class="hidden-bar">
Last saves from this app: <span>Last saves from this app:
{% for scope, rec in saves %}{{ {'drivers': 'Drivers', 'unattend': 'Unattend', 'os': 'OS and Packages'}[scope] }} {{ rec.ts | local_dt }} ({{ rec.files | map(attribute='name') | join(', ') }}){% if not loop.last %}; {% endif %}{% endfor %} {% for scope, rec in saves %}{{ {'drivers': 'Drivers', 'unattend': 'Unattend', 'os': 'OS and Packages'}[scope] }} {{ rec.ts | local_dt }} ({{ rec.files | map(attribute='name') | join(', ') }}){% if not loop.last %}; {% endif %}{% endfor %}</span>
</div> </div>
{% endif %} {% endif %}
</section>
<div class="ov-cols">
{# -------- coverage gaps -------- #}
<section class="panel" aria-labelledby="ov-gap-h">
<div class="ov-phead">
<h2 id="ov-gap-h">Hardware coverage gaps</h2>
{% if ov.gaps %}{{ ui.chip(ov.gaps | length, 'warn') }}{% endif %}
<a class="sp" href="{{ url_for('hardware_page') }}">Hardware coverage</a>
</div>
{% if not ov.gaps_checked %}
<div class="cl-empty">This image is not one of the images Hardware coverage checks.</div>
{% elif ov.gaps %}
<ul class="cl">
{% for g in ov.gaps[:8] %}
<li>
<a class="rowlink tip" href="{{ image_tab_url(image_type, 'drivers', q=g.model) }}" data-tip="{{ g.model }}{% if g.skus %} (SKU {{ g.skus | join(', ') }}){% endif %}{% if g.file %}: {{ g.file }}{% endif %}">
<span class="trunc mono">{{ g.model }}</span></a>
{% if g.status == 'no-entry' %}{{ ui.chip('No entry', 'err') }}{% else %}{{ ui.chip('Zip missing', 'err') }}{% endif %}
<span class="when">{{ g.last_seen | local_dt }}</span>
</li>
{% endfor %}
</ul>
{% if ov.gaps | length > 8 %}<div class="cl-more"><a href="{{ url_for('hardware_page') }}">{{ ov.gaps | length - 8 }} more on Hardware coverage</a></div>{% endif %}
{% else %}
<div class="cl-empty">Every model that has booted WinPE here matches a driver entry with its zip on disk.</div>
{% endif %}
</section>
{# -------- dell hints -------- #}
<section class="panel" aria-labelledby="ov-dell-h">
<div class="ov-phead">
<h2 id="ov-dell-h">Newer Dell driver packs</h2>
{% if ov.dell.rows %}{{ ui.chip(ov.dell.rows | length, 'neutral') }}{% endif %}
<a class="sp" href="{{ url_for('dell_updates.page') }}">Dell updates</a>
</div>
{% if ov.dell.rows %}
<ul class="cl">
{% for r in ov.dell.rows[:8] %}
<li>
<a class="rowlink tip" href="{{ image_tab_url(image_type, 'drivers', q=r.file) }}" data-tip="Entry {{ r.pos }}: {{ r.file }}{% if r.models %} ({{ r.models }}){% endif %}">
<span class="trunc"><span class="pos">{{ r.pos }}</span> <span class="mono">{{ r.file }}</span></span></a>
{{ ui.chip(r.ver or 'newer', 'neutral') }}
<span></span>
</li>
{% endfor %}
</ul>
{% if ov.dell.rows | length > 8 %}<div class="cl-more"><a href="{{ url_for('dell_updates.page') }}">{{ ov.dell.rows | length - 8 }} more on Dell updates</a></div>{% endif %}
{% elif ov.dell.checked %}
<div class="cl-empty">Every driver pack in this map is the newest Dell has (checked {{ ov.dell.checked | local_dt }}).</div>
{% else %}
<div class="cl-empty">Not checked yet. Run a check on Updates, Dell drivers and BIOS.</div>
{% endif %}
</section>
{# -------- missing zips -------- #}
{% if d.missing %}
<section class="panel" aria-labelledby="ov-miss-h">
<div class="ov-phead">
<h2 id="ov-miss-h">Zips not on the share</h2>
{{ ui.chip(d.missing | length, 'err') }}
<a class="sp" href="{{ image_tab_url(image_type, 'drivers', missing=1) }}">Drivers</a>
</div>
<ul class="cl">
{% for m in d.missing[:8] %}
<li>
<a class="rowlink tip" href="{{ image_tab_url(image_type, 'drivers', q=m.file) if m.file else image_tab_url(image_type, 'drivers', missing=1) }}" data-tip="Entry {{ m.pos }}: {{ m.file or '(no file name)' }}{% if m.models %} ({{ m.models }}){% endif %}">
<span class="trunc"><span class="pos">{{ m.pos }}</span> <span class="mono">{{ m.file or '(no file name)' }}</span></span></a>
{{ ui.chip(m.os, 'w11' if m.os == 'Win11' else 'w10') }}
<span></span>
</li>
{% endfor %}
</ul>
{% if d.missing | length > 8 %}<div class="cl-more"><a href="{{ image_tab_url(image_type, 'drivers', missing=1) }}">{{ d.missing | length - 8 }} more on the Drivers tab</a></div>{% endif %}
</section>
{% endif %}
</div> </div>
<div class="row gx-4 gy-0">
<div class="col-12 col-xl-6">
{# -------- coverage gaps -------- #}
<div class="card">
<div class="card-header">
<span class="d-flex align-items-center gap-2"><i class="bi bi-cpu"></i> Hardware coverage gaps</span>
<a href="{{ url_for('hardware_page') }}" class="small fw-normal">Hardware Coverage</a>
</div>
{% if not ov.gaps_checked %}
<div class="card-body text-muted small">This image is not one of the images Hardware Coverage checks.</div>
{% elif ov.gaps %}
<div class="table-container">
<table class="table mb-0 ov-table">
<thead><tr><th>Model (WMI)</th><th>Problem</th><th>Last seen</th></tr></thead>
<tbody>
{% for g in ov.gaps[:8] %}
<tr>
<td class="mono">{{ g.model }}{% if g.skus %}<div class="small text-muted">SKU {{ g.skus | join(', ') }}</div>{% endif %}</td>
<td>{% if g.status == 'no-entry' %}<span class="badge badge-danger">No entry</span>{% else %}<span class="badge badge-danger" title="{{ g.file }}">Zip missing</span>{% endif %}</td>
<td class="text-nowrap small">{{ g.last_seen | local_dt }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if ov.gaps | length > 8 %}<div class="ov-list-more">and {{ ov.gaps | length - 8 }} more on Hardware Coverage</div>{% endif %}
{% else %}
<div class="card-body text-muted small">Every model that has booted WinPE here matches a driver entry with its zip on disk.</div>
{% endif %}
</div>
{# -------- dell hints -------- #}
{% if ov.dell.rows %}
<div class="card">
<div class="card-header">
<span class="d-flex align-items-center gap-2"><i class="bi bi-motherboard"></i> Newer Dell driver packs</span>
<a href="{{ url_for('dell_updates.page') }}" class="small fw-normal">Dell Updates</a>
</div>
<div class="table-container">
<table class="table mb-0 ov-table">
<thead><tr><th class="text-end">#</th><th>Current file</th><th>Dell has</th></tr></thead>
<tbody>
{% for r in ov.dell.rows[:8] %}
<tr>
<td class="text-end mono text-muted">{{ r.pos }}</td>
<td class="mono file-cell">{{ r.file }}<div class="small text-muted file-cell">{{ r.models }}</div></td>
<td><span class="badge badge-info">{{ r.ver or 'newer' }}</span></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if ov.dell.rows | length > 8 %}<div class="ov-list-more">and {{ ov.dell.rows | length - 8 }} more on Dell Updates</div>{% endif %}
</div>
{% endif %}
</div>
<div class="col-12 col-xl-6">
{# -------- missing zips -------- #}
{% if d.missing %}
<div class="card">
<div class="card-header">
<span class="d-flex align-items-center gap-2"><i class="bi bi-file-earmark-x"></i> Zips not on the share</span>
<a href="{{ image_tab_url(image_type, 'drivers') }}" class="small fw-normal">Drivers</a>
</div>
<div class="table-container">
<table class="table mb-0 ov-table">
<thead><tr><th class="text-end">#</th><th>File</th><th>OS</th></tr></thead>
<tbody>
{% for m in d.missing[:8] %}
<tr>
<td class="text-end mono text-muted">{{ m.pos }}</td>
<td class="mono file-cell">{{ m.file or '(no file name)' }}<div class="small text-muted file-cell">{{ m.models }}</div></td>
<td>{{ m.os }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if d.missing | length > 8 %}<div class="ov-list-more">and {{ d.missing | length - 8 }} more on the Drivers tab</div>{% endif %}
</div>
{% endif %}
</div>
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -1,189 +1,200 @@
{% extends "base.html" %} {% extends "base.html" %}
{% import '_ui.html' as ui %}
{% block title %}{{ session.serial }} - Imaging Session{% endblock %} {% block title %}{{ session.serial }} - Imaging Session{% endblock %}
{% set st = session.status or 'in_progress' %}
{% set st_chip = {'succeeded': ('Succeeded', 'ok'), 'failed': ('Failed', 'fail')}.get(st, ('In progress', 'run')) %}
{% set st_kind = {'succeeded': 'ok', 'failed': 'fail'}.get(st, 'run') %}
{% set stage_idx = session.stage_index | int(0) %}
{% set stage_total = session.stage_total | int(0) %}
{% set history = session.stage_history or [] %}
{# Support summary: built here so the copy button carries it (static/app.js
handles .copy-btn[data-copy-text]). Same fields as the metadata list. #}
{% set summary_lines = [
'Serial: ' ~ session.serial,
'Status: ' ~ st,
'Hostname: ' ~ (session.hostname_target or '-'),
'IP: ' ~ (session.ip or '-'),
'MAC: ' ~ (session.mac or '-'),
'PC type: ' ~ (session.pctype or '-'),
'Machine #: ' ~ (session.machinenumber or '-'),
'Intune ID: ' ~ (session.intune_device_id or '-'),
'Started: ' ~ (session.started_at | local_dt(true)),
'Last update: ' ~ (session.last_updated | local_dt(true)),
'Stage: ' ~ stage_idx ~ '/' ~ (session.stage_total or '?') ~ ((' - ' ~ session.current_stage) if session.current_stage else ''),
] %}
{% if session.previous_run_at %}{% set _ = summary_lines.append('Prior run: ' ~ (session.previous_run_at | local_dt(true))) %}{% endif %}
{% if session.error %}{% set _ = summary_lines.append('Error: ' ~ session.error) %}{% endif %}
{% if history %}
{% set _ = summary_lines.append('') %}{% set _ = summary_lines.append('Stage timeline:') %}
{% for h in history %}{% set _ = summary_lines.append(' - ' ~ (h.ts | local_dt(true)) ~ ' stage ' ~ h.stage_index ~ ' ' ~ h.status ~ ' ' ~ (h.current_stage or '')) %}{% endfor %}
{% endif %}
{% block extra_head %}
<style>
.id-hero { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(170px, 100%), 1fr)); gap: 12px; }
.id-stat { background: var(--surface); border-radius: 12px; padding: 14px 16px; box-shadow: 0 1px 2px var(--shadow); min-width: 0; }
.id-stat .k { font-size: 12px; color: var(--muted); }
.id-stat .v { font-size: 18px; font-weight: 600; margin-top: 4px; overflow-wrap: anywhere; }
.id-stat .v.sm { font-size: 13px; font-weight: 400; line-height: 1.45; }
.id-cols { display: grid; grid-template-columns: minmax(0, 1.7fr) minmax(280px, 1fr); grid-template-areas: "log side"; gap: 16px; align-items: start; }
.id-cols > .id-main { grid-area: log; display: flex; flex-direction: column; gap: 16px; min-width: 0; }
.id-cols > .id-side { grid-area: side; display: flex; flex-direction: column; min-width: 0; }
@media (max-width: 1100px) { .id-cols { grid-template-columns: minmax(0, 1fr); grid-template-areas: "side" "log"; } }
.id-side section { padding: 16px 18px; }
.id-side section + section { border-top: 1px solid var(--line); }
.id-side h2, .id-log h2 { margin: 0; font-size: 15px; font-weight: 600; }
.kv { margin: 0; display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 6px 16px; font-size: 13px; }
.kv dt { color: var(--muted); font-weight: 400; } .kv dd { margin: 0; overflow-wrap: anywhere; }
/* Stage history: vertical track, one entry per transition. */
.sh-list { list-style: none; margin: 12px 0 0; padding: 0; }
.sh-item { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 2px 12px; padding: 0 0 14px 24px; font-size: 13px; }
.sh-item::before { content: ""; position: absolute; left: 5px; top: 14px; bottom: -2px; width: 2px; background: var(--line-2); }
.sh-item:last-child { padding-bottom: 0; }
.sh-item:last-child::before { display: none; }
.sh-dot { position: absolute; left: 0; top: 4px; width: 12px; height: 12px; border-radius: 6px; background: var(--track-done); box-shadow: 0 0 0 3px var(--surface); }
.sh-dot.run { background: var(--run); } .sh-dot.fail { background: var(--fail); } .sh-dot.ok { background: var(--ok); }
.sh-name { font-weight: 600; min-width: 0; overflow-wrap: anywhere; }
.sh-when { color: var(--muted); font-size: 12px; white-space: nowrap; }
.sh-meta { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 6px; align-items: center; color: var(--muted); font-size: 12px; }
/* Logs: contained scroll panels on a token background. */
.id-log { padding: 16px 18px; display: flex; flex-direction: column; gap: 10px; min-width: 0; }
.id-loghead { display: flex; flex-wrap: wrap; gap: 8px 12px; align-items: center; }
.id-loghead .grow { margin-right: auto; display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.id-pre { margin: 0; padding: 10px 12px; background: var(--surface-2); color: var(--text); border: 1px solid var(--line); border-radius: 8px;
font-family: var(--font-mono); font-size: 12px; line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere; overflow: auto; }
#full-log { max-height: 36rem; }
.id-tail { max-height: 12rem; }
</style>
{% endblock %}
{% block content %} {% block content %}
<div class="page-header"> <header class="ui-head">
<div> <div style="min-width: 0">
<h1>{{ session.serial }} <a href="{{ url_for('imaging_dashboard') }}" class="eyebrow-t" style="text-decoration: none">Imaging</a>
{% if session.hostname_target %}<span class="text-light" style="font-size:14px; font-weight:400;">{{ session.hostname_target }}</span>{% endif %} <h1 style="display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px 14px">
<span class="mono">{{ session.serial }}</span>
{{ ui.chip(st_chip[0], st_chip[1]) }}
</h1> </h1>
<div style="margin-top:0.5rem; display:flex; gap:0.4rem; flex-wrap:wrap;"> <div class="sub" style="margin-top: 4px; font-size: 13px">
{% if session.pctype %}<span class="badge badge-info">{{ session.pctype }}</span>{% endif %} Stage {{ stage_idx }} of {{ session.stage_total or '?' }}{% if session.current_stage %}: <span class="mono">{{ session.current_stage }}</span>{% endif %}
{% if session.machinenumber %}<span class="badge badge-secondary">#{{ session.machinenumber }}</span>{% endif %} <span class="muted"> | updated {{ session.last_updated | local_dt(true) }}</span>
<span class="badge badge-{{ 'success' if session.status == 'succeeded' else ('danger' if session.status == 'failed' else 'primary') }}">{{ session.status or 'in_progress' }}</span>
</div> </div>
{% if stage_total %}<div style="max-width: 360px">{{ ui.track(stage_total, stage_idx, st_kind) }}</div>{% endif %}
</div> </div>
<div class="header-actions"> <div class="actions">
<a href="{{ url_for('imaging_dashboard') }}" class="btn btn-outline-secondary btn-sm"> <a href="{{ url_for('imaging_dashboard') }}" class="ui-btn">Back to imaging</a>
<i class="bi bi-arrow-left"></i> Back to dashboard <button type="button" class="ui-btn pri copy-btn copy-summary-btn" data-copy-text="{{ summary_lines | join('\n') }}">Copy support summary</button>
</a>
<button type="button" class="btn btn-primary btn-sm copy-summary-btn">
<i class="bi bi-clipboard"></i> Copy support summary
</button>
</div> </div>
</header>
<div class="ui-stack">
{% if session.error %}
<div class="errbox" role="alert"><strong>Error.</strong> {{ session.error }}</div>
{% endif %}
<div class="id-hero">
<div class="id-stat"><div class="k">Machine number</div><div class="v mono">{{ session.machinenumber or '-' }}</div></div>
<div class="id-stat"><div class="k">Hostname</div><div class="v mono">{{ session.hostname_target or '-' }}</div></div>
<div class="id-stat"><div class="k">IP</div><div class="v mono">{{ session.ip or '-' }}</div>{% if session.mac %}<div class="sub mono">{{ session.mac }}</div>{% endif %}</div>
<div class="id-stat"><div class="k">PC type</div><div class="v" style="font-size: 15px">{{ session.pctype or '-' }}</div></div>
<div class="id-stat"><div class="k">Intune ID</div>
{% if session.intune_device_id %}<div class="v sm mono">{{ session.intune_device_id }}</div>
<button type="button" class="ui-btn sm ghost copy-btn" data-copy-text="{{ session.intune_device_id }}" style="margin-top: 4px">Copy ID</button>
{% else %}<div class="v">-</div><div class="sub">Not enrolled yet</div>{% endif %}</div>
</div> </div>
<div class="row"> <div class="id-cols">
<div class="col-lg-5"> <div class="panel id-side">
<section aria-labelledby="shHead">
<div class="section-card"> <h2 id="shHead">Stage history</h2>
<div class="section-title">Session metadata</div> {% if history %}
<dl class="row mb-0"> <ol class="sh-list">
<dt class="col-4 text-light">Serial</dt><dd class="col-8"><span class="mono">{{ session.serial }}</span></dd> {% for h in history %}
<dt class="col-4 text-light">Hostname</dt><dd class="col-8"><span class="mono">{{ session.hostname_target or '-' }}</span></dd> {% set hk = {'succeeded': 'ok', 'failed': 'fail'}.get(h.status, 'run' if loop.last else '') %}
<dt class="col-4 text-light">MAC</dt><dd class="col-8"><span class="mono">{{ session.mac or '-' }}</span></dd> <li class="sh-item">
<dt class="col-4 text-light">PC type</dt><dd class="col-8">{{ session.pctype or '-' }}</dd> <span class="sh-dot {{ hk }}" aria-hidden="true"></span>
<dt class="col-4 text-light">Machine #</dt><dd class="col-8">{{ session.machinenumber or '-' }}</dd> <span class="sh-name">{{ h.current_stage or ('Stage ' ~ h.stage_index) }}</span>
<dt class="col-4 text-light">Intune ID</dt><dd class="col-8"> <span class="sh-when mono">{{ h.ts | local_dt(true) }}</span>
{% if session.intune_device_id %} <span class="sh-meta">Stage {{ h.stage_index }}{% if stage_total %} of {{ stage_total }}{% endif %}
<span class="mono" style="word-break: break-all;">{{ session.intune_device_id }}</span> {% if h.status == 'failed' %}{{ ui.chip('Failed', 'fail') }}{% elif h.status == 'succeeded' %}{{ ui.chip('Succeeded', 'ok') }}{% endif %}</span>
{% else %}-{% endif %} </li>
</dd> {% endfor %}
<dt class="col-4 text-light">Started</dt><dd class="col-8"><span class="mono">{{ session.started_at | local_dt(true) }}</span></dd> </ol>
<dt class="col-4 text-light">Last update</dt><dd class="col-8"><span class="mono">{{ session.last_updated | local_dt(true) }}</span></dd>
<dt class="col-4 text-light">Stage</dt><dd class="col-8">
{{ session.stage_index or 0 }}/{{ session.stage_total or '?' }}
{% if session.current_stage %} - <span class="mono">{{ session.current_stage }}</span>{% endif %}
</dd>
{% if session.previous_run_at %}
<dt class="col-4 text-light">Prior run</dt><dd class="col-8"><span class="mono">{{ session.previous_run_at | local_dt(true) }}</span></dd>
{% endif %}
{% if session.error %}
<dt class="col-4 text-danger">Error</dt>
<dd class="col-8 text-danger">{{ session.error }}</dd>
{% endif %}
</dl>
</div>
<div class="section-card">
<div class="section-title">Stage timeline</div>
{% if session.stage_history %}
<div class="table-container">
<table class="data-table mb-0">
<thead>
<tr><th>Time</th><th>Stage</th><th>Status</th><th>Detail</th></tr>
</thead>
<tbody>
{% for h in session.stage_history %}
<tr>
<td><span class="mono">{{ h.ts | local_dt(true) }}</span></td>
<td><span class="badge badge-secondary">{{ h.stage_index }}</span></td>
<td>
<span class="badge badge-{{ 'success' if h.status == 'succeeded' else ('danger' if h.status == 'failed' else 'primary') }}">
{{ h.status }}
</span>
</td>
<td><span class="mono">{{ h.current_stage or '' }}</span></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %} {% else %}
<div class="status-indicator"> <p class="sub" style="margin: 8px 0 0">No stage transitions recorded yet. The client only logs a row when
<i class="bi bi-info-circle"></i> stage_index advances or status flips to succeeded or failed.</p>
<span>No stage transitions recorded yet. The client only logs a row when
stage_index advances or status flips to succeeded/failed.</span>
</div>
{% endif %} {% endif %}
</div> </section>
<section aria-labelledby="metaHead">
<h2 id="metaHead" style="margin-bottom: 10px">Session</h2>
<dl class="kv" id="sessionMeta">
<dt>Serial</dt><dd class="mono">{{ session.serial }}</dd>
<dt>MAC</dt><dd class="mono">{{ session.mac or '-' }}</dd>
<dt>Started</dt><dd class="mono">{{ session.started_at | local_dt(true) }}</dd>
<dt>Last update</dt><dd class="mono">{{ session.last_updated | local_dt(true) }}</dd>
{% if session.previous_run_at %}<dt>Prior run</dt><dd class="mono">{{ session.previous_run_at | local_dt(true) }}</dd>{% endif %}
{% if session.source %}<dt>Source</dt><dd>{{ session.source }}</dd>{% endif %}
</dl>
</section>
</div> </div>
<div class="col-lg-7"> <div class="id-main">
<section class="panel id-log" aria-labelledby="logHead">
<div class="section-card"> <div class="id-loghead">
<div class="section-title" style="display:flex; justify-content:space-between; align-items:center;"> <div class="grow"><h2 id="logHead">Full log</h2>
<span>Full log {% if full_log_truncated %}{{ ui.chip('Truncated: last 1 MB', 'warn') }}{% endif %}</div>
{% if full_log_truncated %} {% if full_log %}<button type="button" class="ui-btn sm copy-log-btn">Copy log</button>{% endif %}
<span class="badge badge-warning" style="margin-left:0.5rem;">truncated (last 1 MB)</span>
{% endif %}
</span>
<button type="button" class="btn btn-outline-secondary btn-sm copy-log-btn"><i class="bi bi-clipboard"></i> Copy</button>
</div> </div>
{% if full_log %} {% if full_log %}
<pre id="full-log" class="mono mb-0" style="max-height: 36rem; overflow: auto; white-space: pre-wrap; background: var(--bg); padding: 0.75rem; border-radius: 0.25rem;">{{ full_log }}</pre> <pre id="full-log" class="id-pre" tabindex="0" aria-label="Full log">{{ full_log }}</pre>
{% else %} {% else %}
<div class="status-indicator"> <div class="notice info">No log content. The client has not pushed any log_lines entries yet, or the sidecar file was cleared.</div>
<i class="bi bi-info-circle"></i>
<span>No log content. The client has not pushed any <code>log_lines</code>
entries yet, or the sidecar file was cleared.</span>
</div>
{% endif %} {% endif %}
</div> </section>
{% if session.log_tail %} {% if session.log_tail %}
<div class="section-card"> <section class="panel id-log" aria-labelledby="tailHead">
<div class="section-title">Recent tail ({{ session.log_tail | length }} line{{ 's' if session.log_tail | length != 1 }})</div> <div class="id-loghead"><h2 id="tailHead">Recent tail</h2><span class="sub">{{ session.log_tail | length }} line{{ 's' if session.log_tail | length != 1 }}</span></div>
<pre class="mono mb-0" style="max-height: 12rem; overflow: auto; background: var(--bg); padding: 0.75rem; border-radius: 0.25rem;">{% for line in session.log_tail %}{{ line }} <pre class="id-pre id-tail" tabindex="0" aria-label="Recent log tail">{% for line in session.log_tail %}{{ line }}
{% endfor %}</pre> {% endfor %}</pre>
</div> </section>
{% endif %} {% endif %}
</div> </div>
</div> </div>
</div>
{% endblock %} {% endblock %}
{% block extra_scripts %} {% block extra_scripts %}
<script> <script>
function copyText(text) { // Copy log: the log can be up to 1 MB, so it is read from the <pre> rather
if (navigator.clipboard && window.isSecureContext) { // than carried in a data-copy-text attribute. Same fallback as static/app.js
return navigator.clipboard.writeText(text); // (the LAN is plain http, so navigator.clipboard is often unavailable).
} (function () {
return new Promise(function(resolve, reject) { function copyText(text) {
var ta = document.createElement('textarea'); if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.writeText(text);
ta.value = text; return new Promise(function (resolve, reject) {
ta.style.position = 'fixed'; ta.style.left = '-9999px'; var ta = document.createElement('textarea');
document.body.appendChild(ta); ta.value = text; ta.setAttribute('readonly', ''); ta.style.position = 'fixed'; ta.style.opacity = '0';
ta.focus(); ta.select(); document.body.appendChild(ta); ta.select();
try { var ok = false;
var ok = document.execCommand('copy'); try { ok = document.execCommand('copy'); } catch (err) { ok = false; }
document.body.removeChild(ta); document.body.removeChild(ta);
if (ok) resolve(); else reject(new Error('execCommand returned false')); if (ok) resolve(); else reject(new Error('copy failed'));
} catch (err) {
document.body.removeChild(ta);
reject(err);
}
}); });
} }
document.addEventListener('click', function (e) {
document.addEventListener('click', function(e) { var btn = e.target.closest && e.target.closest('.copy-log-btn');
var logBtn = e.target.closest('.copy-log-btn'); if (!btn) return;
var summaryBtn = e.target.closest('.copy-summary-btn'); var pre = document.getElementById('full-log');
if (logBtn) { if (!pre) return;
var pre = document.getElementById('full-log'); var done = function (ok) {
if (pre) { btn.textContent = ok ? 'Copied' : 'Copy failed';
copyText(pre.textContent).then(function() { setTimeout(function () { btn.textContent = 'Copy log'; }, 1400);
logBtn.innerHTML = '<i class="bi bi-check2"></i> copied!'; };
setTimeout(function() { logBtn.innerHTML = '<i class="bi bi-clipboard"></i> Copy'; }, 1200); copyText(pre.textContent).then(function () { done(true); }, function () { done(false); });
}); });
} })();
} else if (summaryBtn) {
var lines = [];
document.querySelectorAll('dl dt').forEach(function(dt) {
var dd = dt.nextElementSibling;
if (dd) {
lines.push(dt.textContent.trim() + ': ' + dd.textContent.trim().replace(/\s+/g, ' '));
}
});
var rows = document.querySelectorAll('table tbody tr');
if (rows.length) {
lines.push('');
lines.push('Stage timeline:');
rows.forEach(function(tr) {
var cells = tr.querySelectorAll('td');
if (cells.length === 4) {
lines.push(' - ' + cells[0].textContent.trim() + ' stage ' + cells[1].textContent.trim()
+ ' ' + cells[2].textContent.trim() + ' ' + cells[3].textContent.trim());
}
});
}
copyText(lines.join('\n')).then(function() {
summaryBtn.innerHTML = '<i class="bi bi-check2"></i> copied!';
setTimeout(function() { summaryBtn.innerHTML = '<i class="bi bi-clipboard"></i> Copy support summary'; }, 1200);
});
}
});
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -1,164 +1,151 @@
{% extends "base.html" %} {% extends "base.html" %}
{% import '_ui.html' as ui %}
{% block title %}Image Import - PXE Server Manager{% endblock %} {% block title %}Image Import - PXE Server Manager{% endblock %}
{# Image import: two panels (network upload folder, USB drive), then the
images one line each. Submitting asks for confirmation (upload-folder
sources are MOVED, same-name files are overwritten) and then disables the
button with a spinner while the import runs. #}
{% block extra_head %}
<style>
.imp-cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(420px, 100%), 1fr)); gap: 22px; align-items: start; }
.imp-panel { padding: 20px 24px 22px; display: flex; flex-direction: column; gap: 16px; }
.imp-panel h2 { margin: 0; font-size: 16px; font-weight: 600; }
.imp-panel .lead { margin: 2px 0 0; font-size: 13px; color: var(--muted); }
.imp-panel form { display: flex; flex-direction: column; gap: 16px; margin: 0; }
.imp-field { display: flex; flex-direction: column; gap: 6px; }
.imp-field label { font-size: 13px; font-weight: 600; color: var(--text-2); }
.imp-field .field { width: 100%; }
.imp-field .sub { line-height: 1.5; }
.imp-actions { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
.imp-empty { display: flex; flex-direction: column; gap: 10px; align-items: flex-start; font-size: 14px; color: var(--text-2); line-height: 1.6; }
.imp-empty p { margin: 0; }
.ig { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.3fr) 92px 112px auto; align-items: center; gap: 0 14px;
padding: 11px 24px; font-size: 14px; border-top: 1px solid var(--line); }
.ig.ihead { font-size: 12px; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); padding-top: 6px; padding-bottom: 6px; border-top: 0; }
.ig .name { font-weight: 600; }
.ig a.ui-btn { justify-self: end; }
.imp-phead { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; padding: 16px 24px 10px; }
.imp-phead h2 { margin: 0; font-size: 16px; font-weight: 600; }
@container (max-width: 720px) {
.ig { grid-template-columns: minmax(0, 1fr) auto auto; padding: 11px 16px; gap: 6px 10px; }
.ig .c-path { grid-column: 1 / -1; grid-row: 2; }
.ig .c-un { display: none; }
.ig.ihead { display: none; }
.imp-phead { padding-left: 16px; padding-right: 16px; }
}
@media (max-width: 600px) { .imp-panel { padding: 16px; } }
</style>
{% endblock %}
{% block content %} {% block content %}
<div class="page-header"> {{ ui.head('Image import', 'Images', "Copy WinPE deployment content into an image's Deploy folder, from the network upload share or a USB drive.") }}
<h1><i class="bi bi-box-arrow-in-down"></i> Image Import</h1>
<div class="ui-stack">
<div class="imp-cols">
<!-- Network upload import -->
<section class="panel imp-panel" aria-labelledby="imp-up-h">
<div>
<h2 id="imp-up-h">From the network upload share</h2>
<p class="lead">Files copied over SMB to <span class="mono">\\172.16.9.1\image-upload</span>. They are moved, not copied.</p>
</div>
{% if upload_sources %}
<form method="POST" id="uploadImportForm">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="imp-field">
<label for="uploadSource">Source</label>
<select class="field" name="source" id="uploadSource" required>
<option value="">Select an upload folder</option>
{% for src in upload_sources %}
<option value="{{ src }}">{{ src }}</option>
{% endfor %}
</select>
</div>
<div class="imp-field">
<label for="uploadTarget">Target image</label>
<select class="field" name="target" id="uploadTarget" required>
<option value="">Select the image to import into</option>
{% for it in image_types %}
<option value="{{ it }}">{{ friendly_names[it] }} ({{ it }})</option>
{% endfor %}
</select>
<span class="sub">Content goes into the image's Deploy folder. Out-of-box Drivers are pooled once in a shared folder and linked from each image to save disk space.</span>
</div>
<div class="notice"><span><strong>The files are moved out of the upload folder</strong>, so the source is used up.
Files with the same name in the image are overwritten. Large images take several minutes.</span></div>
<div class="imp-actions">
<button type="submit" class="ui-btn pri" id="uploadImportBtn">Start import</button>
</div>
</form>
{% else %}
<div class="imp-empty">
{{ ui.chip('Nothing uploaded', 'off') }}
<p>Map <span class="mono">\\172.16.9.1\image-upload</span> on your Windows PC and copy the Deploy folder contents there, then refresh.</p>
<button type="button" class="ui-btn" onclick="location.reload()">Refresh</button>
</div>
{% endif %}
</section>
<!-- USB import -->
<section class="panel imp-panel" aria-labelledby="imp-usb-h">
<div>
<h2 id="imp-usb-h">From a USB drive</h2>
<p class="lead">A drive mounted under <span class="mono">/mnt/</span> or <span class="mono">/media/</span>. Files are copied.</p>
</div>
{% if usb_mounts %}
<form method="POST" id="importForm">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="imp-field">
<label for="source">Source (USB mount point)</label>
<select class="field" name="source" id="source" required>
<option value="">Select a mounted USB drive</option>
{% for mount in usb_mounts %}
<option value="{{ mount }}">{{ mount }}</option>
{% endfor %}
</select>
<span class="sub">The drive holding the WinPE deployment content.</span>
</div>
<div class="imp-field">
<label for="target">Target image</label>
<select class="field" name="target" id="target" required>
<option value="">Select the image to import into</option>
{% for it in image_types %}
<option value="{{ it }}">{{ friendly_names[it] }} ({{ it }})</option>
{% endfor %}
</select>
<span class="sub">Content goes into the image's Deploy folder.</span>
</div>
<div class="notice"><span>Files with the same name in the image are overwritten. Large images take several minutes.</span></div>
<div class="imp-actions">
<button type="submit" class="ui-btn pri" id="importBtn">Start import</button>
</div>
</form>
{% else %}
<div class="imp-empty">
{{ ui.chip('No USB drive', 'off') }}
<p>No mounted USB drive was found under <span class="mono">/mnt/</span> or <span class="mono">/media/</span>. Mount one and refresh.</p>
<button type="button" class="ui-btn" onclick="location.reload()">Refresh</button>
</div>
{% endif %}
</section>
</div> </div>
<div class="row g-4"> <section class="panel ui-list" aria-labelledby="imp-st-h">
<div class="col-lg-8"> <div class="imp-phead"><h2 id="imp-st-h">Images</h2>{{ ui.chip(images | length, 'neutral') }}</div>
<div class="ig ihead" aria-hidden="true"><span>Image</span><span class="c-path">Deploy folder</span><span>Content</span><span class="c-un">Unattend</span><span></span></div>
<!-- Network Upload Import --> {% for img in images %}
<div class="section-card"> <div class="ig">
<div class="section-title"><i class="bi bi-hdd-network"></i> Import from Network Upload</div> <span class="tip" data-tip="{{ img.friendly_name }}"><span class="trunc name">{{ img.friendly_name }}</span></span>
{% if upload_sources %} <span class="tip c-path" data-tip="{{ img.deploy_path }}"><span class="trunc mono sub">{{ img.image_type }}/Deploy</span></span>
<form method="POST" id="uploadImportForm"> <span>{% if img.has_content %}{{ ui.chip('Present', 'ok') }}{% else %}{{ ui.chip('Empty', 'off') }}{% endif %}</span>
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}"> <span class="c-un">{% if img.has_unattend %}{{ ui.chip('Unattend', 'neutral') }}{% else %}<span class="sub">No unattend</span>{% endif %}</span>
<div class="form-field"> {% if img.image_type in image_types %}<a class="ui-btn sm" href="{{ image_tab_url(img.image_type, 'overview') }}" aria-label="Open {{ img.friendly_name }}">Open</a>{% else %}<span></span>{% endif %}
<label for="uploadSource" class="form-label">Source</label>
<select class="form-select" name="source" id="uploadSource" required>
<option value="">-- Select upload source --</option>
{% for src in upload_sources %}
<option value="{{ src }}">{{ src }}</option>
{% endfor %}
</select>
<div class="form-text">
Files uploaded via SMB to <code class="mono">\\172.16.9.1\image-upload</code>
</div>
</div>
<div class="form-field">
<label for="uploadTarget" class="form-label">Target Image Type</label>
<select class="form-select" name="target" id="uploadTarget" required>
<option value="">-- Select target image --</option>
{% for it in image_types %}
<option value="{{ it }}">{{ friendly_names[it] }} ({{ it }})</option>
{% endfor %}
</select>
<div class="form-text">
Content will be copied into the Deploy directory. Shared resources
(Out-of-box Drivers) are stored once and linked across all image types.
</div>
</div>
<div class="alert alert-info" role="alert">
<strong>Shared Drivers:</strong> Out-of-box Drivers are automatically pooled
into a shared directory and symlinked for each image type to save disk space.
</div>
<div class="alert alert-warning" role="alert">
<strong>Warning:</strong> Existing files in the target Deploy directory with the
same names will be overwritten. This operation may take several minutes for large
images.
</div>
<button type="submit" class="pxe-btn pxe-btn-primary" id="uploadImportBtn">
<i class="bi bi-play-fill"></i> Start Import
</button>
</form>
{% else %}
<div class="text-center py-4">
<i class="bi bi-cloud-slash" style="font-size:2rem;color:var(--text-light);"></i>
<h5 class="mt-3" style="color:var(--text-light);">No Upload Content Found</h5>
<p class="mb-0" style="color:var(--text-light);">
Map <code class="mono">\\172.16.9.1\image-upload</code> on your Windows PC and copy
the Deploy directory contents there.
</p>
<button class="pxe-btn pxe-btn-ghost pxe-btn-sm mt-3" onclick="location.reload()">
<i class="bi bi-arrow-clockwise"></i> Refresh
</button>
</div>
{% endif %}
</div>
<!-- USB Import -->
<div class="section-card">
<div class="section-title"><i class="bi bi-usb-drive"></i> Import from USB Drive</div>
{% if usb_mounts %}
<form method="POST" id="importForm">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="form-field">
<label for="source" class="form-label">Source (USB Mount Point)</label>
<select class="form-select" name="source" id="source" required>
<option value="">-- Select a mounted USB drive --</option>
{% for mount in usb_mounts %}
<option value="{{ mount }}">{{ mount }}</option>
{% endfor %}
</select>
<div class="form-text">
Select the mounted USB drive containing the WinPE deployment content.
</div>
</div>
<div class="form-field">
<label for="target" class="form-label">Target Image Type</label>
<select class="form-select" name="target" id="target" required>
<option value="">-- Select target image --</option>
{% for it in image_types %}
<option value="{{ it }}">{{ friendly_names[it] }} ({{ it }})</option>
{% endfor %}
</select>
<div class="form-text">
Content will be copied into the Deploy directory for this image type.
</div>
</div>
<div class="alert alert-warning" role="alert">
<strong>Warning:</strong> Existing files in the target Deploy directory with the
same names will be overwritten. This operation may take several minutes for large
images.
</div>
<button type="submit" class="pxe-btn pxe-btn-primary" id="importBtn">
<i class="bi bi-play-fill"></i> Start Import
</button>
</form>
{% else %}
<div class="text-center py-4">
<i class="bi bi-usb-drive" style="font-size:2rem;color:var(--text-light);"></i>
<h5 class="mt-3" style="color:var(--text-light);">No USB Drives Detected</h5>
<p class="mb-0" style="color:var(--text-light);">
No mounted USB drives were found under <code class="mono">/mnt/</code> or <code class="mono">/media/</code>.<br>
Mount a USB drive and refresh this page.
</p>
<button class="pxe-btn pxe-btn-ghost pxe-btn-sm mt-3" onclick="location.reload()">
<i class="bi bi-arrow-clockwise"></i> Refresh
</button>
</div>
{% endif %}
</div>
</div>
<div class="col-lg-4">
<div class="section-card">
<div class="section-title"><i class="bi bi-list-check"></i> Current Image Status</div>
<div class="table-container">
<table class="data-table mb-0">
<thead>
<tr>
<th>Image</th>
<th>Content</th>
</tr>
</thead>
<tbody>
{% for img in images %}
<tr>
<td>{{ img.friendly_name }}</td>
<td>
{% if img.has_content %}
<span class="badge badge-success">Present</span>
{% else %}
<span class="badge badge-secondary">Empty</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div> </div>
{% endfor %}
</section>
</div> </div>
{% endblock %} {% endblock %}
@@ -181,7 +168,7 @@ document.addEventListener('DOMContentLoaded', function() {
if (!confirm(msg)) { e.preventDefault(); return; } if (!confirm(msg)) { e.preventDefault(); return; }
var btn = form.querySelector('button[type="submit"]'); var btn = form.querySelector('button[type="submit"]');
btn.disabled = true; btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span> Importing...'; btn.innerHTML = '<span class="spinner-border spinner-border-sm" aria-hidden="true"></span> Importing...';
}); });
} }
}); });

File diff suppressed because it is too large Load Diff

View File

@@ -1,190 +1,176 @@
{% extends "base.html" %} {% extends "base.html" %}
{% import '_ui.html' as ui %}
{% block title %}{{ friendly_name }} - Unattend{% endblock %} {% block title %}{{ friendly_name }} - Unattend{% endblock %}
{# {#
Unattend tab. Rows carry data-orig = their index in the file as parsed, so a Unattend tab. Rows carry data-orig = their index in the file as parsed, so a
save can tell an edited or moved item from a new one (services/unattend.py, save can tell an edited or moved item from a new one (services/unattend.py,
"Form edits"). All behaviour is in static/unattend-editor.js; class names "Form edits"). All behaviour is in static/unattend-editor.js; class names
are una-* so the old handlers in static/app.js never fire here. are una-* so the old handlers in static/app.js never fire here. Command
rows keep Move up / Move down / Remove (.una-up .una-down .una-remove) in
their "..." menu; the save bar (Review changes, #unaReview) shows once
something is edited.
#} #}
{% block extra_head %} {% block extra_head %}
<style> <style>
.una-bar { .una-bar { display: flex; flex-wrap: wrap; gap: 10px 16px; align-items: center; justify-content: space-between; }
display: flex; flex-wrap: wrap; gap: 0.5rem 1rem; align-items: center; /* Form / Raw XML switch: Bootstrap pills underneath, drawn as a segmented control */
justify-content: space-between; margin-bottom: 1rem; .una-seg { display: flex; margin: 0; padding: 0; list-style: none; border: 1px solid var(--control); border-radius: 8px; overflow: hidden; flex: none; }
} .una-seg .nav-link { display: flex; align-items: center; height: 34px; padding: 0 14px; border: 0; border-radius: 0; background: var(--surface);
.una-bar .nav-pills .nav-link { font-size: 13px; color: var(--text); text-decoration: none; }
font-size: 12px; padding: 0.4rem 0.9rem; display: flex; align-items: center; gap: 0.4rem; .una-seg .nav-item + .nav-item .nav-link { border-left: 1px solid var(--control); }
color: var(--text-light); border: 1px solid var(--border); background: transparent; .una-seg .nav-link.active { background: var(--text); color: var(--surface); font-weight: 600; }
} .una-meta { font-size: 13px; color: var(--muted); display: flex; flex-wrap: wrap; gap: 6px 14px; align-items: center; }
.una-bar .nav-pills .nav-item + .nav-item .nav-link { margin-left: 0.4rem; } .una-meta a.chip { text-decoration: none; }
.una-bar .nav-pills .nav-link.active { background: var(--primary); border-color: var(--primary); color: #fff; } .una-lint-list { font-size: 13px; margin: 8px 0 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 6px; }
.una-meta { font-size: 12.5px; color: var(--text-light); display: flex; flex-wrap: wrap; gap: 0.25rem 0.75rem; align-items: center; } .una-lint-list .chip { font-size: 11px; padding: 1px 6px; margin-right: 6px; }
.una-meta .badge { font-size: 10.5px; } .una-lintbox { flex-direction: column; gap: 0; }
.una-lint-list { font-size: 13px; margin: 0.5rem 0 0; padding-left: 1.1rem; }
.una-lint-list li { margin-bottom: 0.25rem; }
/* Form view: section index beside the sections (wide), or a sticky row of /* Form view: section index beside the sections (wide), or a sticky row of
links above them (narrow). */ links above them (narrow). */
.una-layout { display: block; } .una-layout { display: block; }
.una-index { .una-index { position: sticky; top: 0; z-index: 20; background: var(--bg); margin: 0 0 14px; padding: 8px 0; }
position: sticky; top: 0; z-index: 20; background: var(--bg); .una-index ul { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: nowrap; gap: 6px; overflow-x: auto; scrollbar-width: none; }
margin: 0 -10px 1rem; padding: 0.5rem 10px; border-bottom: 1px solid var(--border);
}
.una-index ul { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 0.3rem; }
.una-index .ix-item:not(.ix-keep), .una-index .ix-wide, .una-index .ix-long { display: none; } .una-index .ix-item:not(.ix-keep), .una-index .ix-wide, .una-index .ix-long { display: none; }
.una-index a { .una-index a { display: flex; align-items: center; gap: 6px; text-decoration: none; font-size: 13px; color: var(--text-2);
display: flex; align-items: center; gap: 0.4rem; text-decoration: none; padding: 0 12px; min-height: 30px; border: 1px solid var(--line-2); border-radius: 15px; white-space: nowrap; background: var(--surface-3); }
font-size: 12.5px; color: var(--text-light); padding: 0.25rem 0.6rem; .una-index a:hover { color: var(--text); }
border: 1px solid var(--border); border-radius: 999px; white-space: nowrap; .una-index a.active { background: var(--text); color: var(--surface); border-color: var(--text); }
} .una-index .count { font-family: var(--font-mono); font-size: 11px; color: inherit; opacity: .8; }
.una-index a:hover { color: var(--text); border-color: var(--primary); } .una-index a.has-edits::after { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--act); flex: none; }
.una-index a.active { color: var(--primary); border-color: var(--primary); }
.una-index .count { font-size: 10px; font-weight: 600; color: var(--text-light); }
.una-index a.has-edits::after {
content: ""; width: 6px; height: 6px; border-radius: 50%; background: var(--warning);
}
@media (min-width: 1280px) { @media (min-width: 1280px) {
.una-layout { display: grid; grid-template-columns: 200px minmax(0, 1fr); gap: 1.5rem; align-items: start; } .una-layout { display: grid; grid-template-columns: 210px minmax(0, 1fr); gap: 24px; align-items: start; }
.una-index { .una-index { top: 16px; margin: 0; padding: 0; background: transparent; max-height: calc(100vh - 32px); overflow-y: auto; }
top: 1rem; margin: 0; padding: 0; border-bottom: 0; background: transparent;
max-height: calc(100vh - 2rem); overflow-y: auto;
}
.una-index ul { display: block; } .una-index ul { display: block; }
.una-index .ix-item:not(.ix-keep), .una-index .ix-wide { display: flex; } .una-index .ix-item:not(.ix-keep), .una-index .ix-wide { display: flex; }
.una-index .ix-long { display: inline; } .una-index .ix-long { display: inline; }
.una-index .ix-short { display: none; } .una-index .ix-short { display: none; }
.una-index a.ix-group { .una-index a { border: 0; border-radius: 7px; background: transparent; min-height: 32px; padding: 0 10px; justify-content: flex-start; }
font-size: 11px; font-weight: 600; letter-spacing: 1px; text-transform: uppercase; .una-index a.ix-group { font-size: 12px; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); margin-top: 14px; }
margin-top: 0.9rem; border-left-color: transparent !important; background: transparent !important;
}
.una-index li:first-child a.ix-group { margin-top: 0; } .una-index li:first-child a.ix-group { margin-top: 0; }
.una-index a { .una-index a.active { background: var(--surface); color: var(--text); font-weight: 600; box-shadow: inset 3px 0 0 var(--accent), 0 1px 2px var(--shadow); }
border: 0; border-left: 2px solid transparent; border-radius: 0; .una-index a.ix-group.active { background: transparent; box-shadow: none; color: var(--text); }
padding: 0.3rem 0.6rem; justify-content: flex-start;
}
.una-index a.active { border-left-color: var(--primary); background: rgba(65, 129, 255, 0.08); }
.una-index a.has-edits::after { margin-left: 0.4rem; flex: none; }
.una-index a .count { margin-left: auto; } .una-index a .count { margin-left: auto; }
} }
.una-main { min-width: 0; } .una-main { min-width: 0; display: flex; flex-direction: column; gap: 18px; }
.una-section { scroll-margin-top: 4.5rem; } .una-section { scroll-margin-top: 4.5rem; }
@media (min-width: 1280px) { .una-section { scroll-margin-top: 1rem; } } @media (min-width: 1280px) { .una-section { scroll-margin-top: 1rem; } }
.group-heading { .group-heading { margin: 14px 0 0; font-size: 18px; font-weight: 600; }
font-size: 12px; text-transform: uppercase; letter-spacing: 1.5px; font-weight: 600;
color: var(--text-light); margin: 1.75rem 0 0.9rem; padding-bottom: 0.4rem;
border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 0.5rem;
}
.una-main > .group-heading:first-child { margin-top: 0; } .una-main > .group-heading:first-child { margin-top: 0; }
.group-heading .bi { color: var(--primary); } .una-phead { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; padding: 16px 24px 12px; }
.card-header .title-group { display: flex; align-items: center; gap: 0.5rem; font-weight: 600; } .una-phead h3 { margin: 0; font-size: 16px; font-weight: 600; }
.card-header .title-group .bi { color: var(--primary); } .una-phead .sp { margin-left: auto; }
.count-pill { .una-pbody { padding: 0 24px 20px; }
font-size: 11px; font-weight: 600; background: var(--bg); color: var(--text-light); .una-pbody .form-label { font-size: 13px; color: var(--text-2); margin-bottom: 4px; }
border: 1px solid var(--border); border-radius: 999px; padding: 1px 9px; .una-pbody .form-text { font-size: 12px; color: var(--muted); }
} .una-pbody .form-control, .una-pbody .form-select { min-height: 38px; height: 38px; padding-top: 6px; padding-bottom: 6px; font-size: 14px; }
.una-hint { font-size: 12.5px; color: var(--text-light); } .una-hint { font-size: 13px; color: var(--muted); }
.una-empty { text-align: center; color: var(--text-light); padding: 1rem; font-size: 13.5px; } .una-intro { padding: 0 24px 12px; font-size: 13px; color: var(--text-2); }
.una-empty { text-align: center; color: var(--muted); padding: 16px; font-size: 14px; border-top: 1px solid var(--line); }
.una-foot-note { padding: 10px 24px; font-size: 13px; color: var(--text-2); border-top: 1px solid var(--line); background: var(--surface-2); border-radius: 0 0 14px 14px; }
/* Command filter: stays in view while the lists scroll (wide layout). */ /* Command filter: stays in view while the lists scroll (wide layout). */
.una-filter { .una-filter { display: flex; flex-wrap: wrap; gap: 8px 14px; align-items: center; background: var(--bg); padding: 8px 0; }
display: flex; flex-wrap: wrap; gap: 0.5rem 0.75rem; align-items: center;
background: var(--bg); padding: 0.5rem 0; margin-bottom: 0.75rem;
}
@media (min-width: 1280px) { .una-filter { position: sticky; top: 0; z-index: 15; } } @media (min-width: 1280px) { .una-filter { position: sticky; top: 0; z-index: 15; } }
.una-filter .input-wrap { position: relative; flex: 1 1 18rem; max-width: 34rem; } .una-filter .input-wrap { flex: 1 1 18rem; max-width: 34rem; display: flex; }
.una-filter .input-wrap .bi { position: absolute; left: 0.7rem; top: 50%; transform: translateY(-50%); color: var(--text-light); } .una-filter .field { flex: 1; min-width: 0; }
.una-filter input { padding-left: 2rem; }
.una-filter .una-hint strong { color: var(--text); } .una-filter .una-hint strong { color: var(--text); }
/* One command: number + grip | description, full command, counters | actions */ /* One command: number + grip | description, full command, counters | menu */
.una-list { border-top: 1px solid var(--border); } .una-list { border-top: 1px solid var(--line); }
.una-list:empty { border-top: 0; } .una-list:empty { border-top: 0; }
.una-row { .una-row { display: grid; grid-template-columns: 44px minmax(0, 1fr) auto; gap: 10px; padding: 10px 16px 10px 8px;
display: grid; grid-template-columns: 2.4rem minmax(0, 1fr) auto; gap: 0.6rem; border-bottom: 1px solid var(--line); box-shadow: inset 3px 0 0 transparent; transition: background-color .15s; }
padding: 0.6rem 0.9rem 0.6rem 0.4rem; border-bottom: 1px solid var(--border);
border-left: 3px solid transparent; transition: background-color 0.15s;
}
.una-row:last-child { border-bottom: 0; } .una-row:last-child { border-bottom: 0; }
.una-row.is-edited { border-left-color: var(--warning); } .una-row.is-edited, .una-simple.is-edited { background: var(--edit-soft); box-shadow: inset 3px 0 0 var(--accent); }
.una-row.is-new { border-left-color: var(--success); } .una-row.is-new, .una-simple.is-new { background: var(--edit-soft); box-shadow: inset 3px 0 0 var(--ok); }
.una-row.dragging { opacity: 0.45; } .una-row.dragging { opacity: .45; }
.una-row.drop-before { box-shadow: inset 0 2px 0 var(--primary); } .una-row.drop-before { box-shadow: inset 0 2px 0 var(--accent); }
.una-row.drop-after { box-shadow: inset 0 -2px 0 var(--primary); } .una-row.drop-after { box-shadow: inset 0 -2px 0 var(--accent); }
.una-side { display: flex; flex-direction: column; align-items: center; gap: 0.35rem; padding-top: 0.2rem; } .una-side { display: flex; flex-direction: column; align-items: center; gap: 6px; padding-top: 6px; }
.una-pos { font-weight: 600; color: var(--text-light); font-size: 13px; line-height: 1; } .una-pos { font-size: 13px; color: var(--muted); line-height: 1; }
.una-grip { color: var(--text-light); cursor: grab; padding: 0.1rem 0.3rem; border-radius: 4px; } .una-grip { color: var(--muted); cursor: grab; padding: 2px 5px; border-radius: 4px; }
.una-grip:hover { color: var(--text); background: var(--bg); } .una-grip:hover { color: var(--text); background: var(--surface-3); }
.una-body { min-width: 0; display: flex; flex-direction: column; gap: 0.4rem; } .una-body { min-width: 0; display: flex; flex-direction: column; gap: 6px; }
.una-note { .una-note { font-size: 13px; color: var(--muted); font-style: italic; display: flex; gap: 6px; align-items: baseline; }
font-size: 12.5px; color: var(--text-light); font-style: italic;
display: flex; gap: 0.4rem; align-items: baseline;
}
.una-note .bi { font-style: normal; flex: none; } .una-note .bi { font-style: normal; flex: none; }
.una-head { display: flex; gap: 0.5rem; align-items: center; } .una-head { display: flex; gap: 10px; align-items: center; }
.una-head .una-desc { flex: 1 1 auto; font-weight: 500; } .una-head .una-desc { flex: 1 1 auto; font-weight: 600; }
.una-tag { font-size: 11px; color: var(--text-light); white-space: nowrap; display: flex; flex-direction: column; align-items: flex-end; line-height: 1.35; min-width: 5.5rem; } .una-tag { font-size: 12px; color: var(--muted); white-space: nowrap; display: flex; flex-direction: column; align-items: flex-end; line-height: 1.35; min-width: 5.5rem; }
textarea.una-cmd { .una-tag .chip { font-size: 11px; padding: 1px 6px; }
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace; font-size: 12.5px; line-height: 1.5; textarea.una-cmd { font-family: var(--font-mono); font-size: 13px; line-height: 1.5; resize: none; overflow: hidden; white-space: pre-wrap;
resize: none; overflow: hidden; white-space: pre-wrap; overflow-wrap: anywhere; word-break: normal; overflow-wrap: anywhere; word-break: normal; min-height: 2.1rem; }
min-height: 2.1rem; .una-foot { display: flex; flex-wrap: wrap; gap: 4px 16px; font-size: 12px; color: var(--muted); }
}
.una-foot { display: flex; flex-wrap: wrap; gap: 0.25rem 1rem; font-size: 11.5px; color: var(--text-light); }
.una-foot[hidden] { display: none; } .una-foot[hidden] { display: none; }
.una-inner { font-style: italic; } .una-inner { font-style: italic; }
.una-count.near { color: var(--warning-dark); } .una-count.near { color: var(--warn-text); }
[data-theme="dark"] .una-count.near { color: var(--warning); } .una-count.over { color: var(--fail-text); font-weight: 600; }
.una-count.over { color: var(--danger); font-weight: 600; } .una-actions { display: flex; align-items: flex-start; padding-top: 2px; }
.una-actions { display: flex; flex-direction: column; gap: 0.3rem; } .filtering .una-grip, .filtering .una-up, .filtering .una-down { opacity: .3; pointer-events: none; }
.una-actions .btn { padding: 6px 9px; }
.filtering .una-grip, .filtering .una-up, .filtering .una-down { opacity: 0.3; pointer-events: none; }
.una-row[hidden] { display: none; } .una-row[hidden] { display: none; }
/* Short rows (driver paths, accounts) */ /* Short rows (driver paths, accounts) */
.una-simple { display: grid; grid-template-columns: 2.2rem minmax(0, 1fr) auto; gap: 0.6rem; align-items: center; padding: 0.55rem 1rem 0.55rem 0.5rem; border-bottom: 1px solid var(--border); border-left: 3px solid transparent; } .una-simple { display: grid; grid-template-columns: 36px minmax(0, 1fr) auto; gap: 10px; align-items: center; padding: 8px 16px 8px 8px; border-bottom: 1px solid var(--line); }
.una-simple:last-child { border-bottom: 0; } .una-simple:last-child { border-bottom: 0; }
.una-simple.is-edited { border-left-color: var(--warning); }
.una-simple.is-new { border-left-color: var(--success); }
.una-simple .una-pos { text-align: center; } .una-simple .una-pos { text-align: center; }
.una-acct-fields { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 0.5rem; } .una-acct-fields { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; }
@media (max-width: 1100px) { .una-acct-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); } } @media (max-width: 1100px) { .una-acct-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
.una-acct-fields label { font-size: 11px; color: var(--text-light); margin-bottom: 0.15rem; display: block; } .una-acct-fields label { font-size: 12px; color: var(--muted); margin-bottom: 2px; display: block; }
.una-field-changed { border-color: var(--warning) !important; } .una-field-changed { border-color: var(--accent) !important; box-shadow: inset 0 0 0 1px var(--accent); }
.raw-xml-editor { .raw-xml-editor { font-family: var(--font-mono); font-size: 13px; min-height: 600px; height: 70vh; tab-size: 4; white-space: pre; resize: vertical; }
font-family: 'SF Mono', 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 0.85rem; min-height: 600px; height: 70vh; tab-size: 4; white-space: pre; resize: vertical;
}
/* Review modal additions (the modal itself is in _image_header.html) */ /* Review modal additions (the modal itself is in _image_header.html) */
.una-review-lint { margin-top: 1rem; } .una-review-lint { margin-top: 1rem; }
#imageReviewBody > .notice { margin-bottom: 12px; }
.una-review-lint .alert { font-size: 13.5px; } .una-review-lint .alert { font-size: 13.5px; }
.una-lint-ok { font-size: 13px; color: var(--text-light); display: flex; gap: 0.4rem; align-items: center; } .una-lint-ok { font-size: 13px; color: var(--muted); display: flex; gap: 6px; align-items: center; }
.una-lint-ok .bi { color: var(--success-dark); } .una-lint-ok .bi { color: var(--ok); }
[data-theme="dark"] .una-lint-ok .bi { color: var(--success); } .una-review-xml summary { cursor: pointer; font-size: 13px; color: var(--accent); font-weight: 600; margin-top: 1rem; }
.una-review-xml summary { cursor: pointer; font-size: 13px; color: var(--text-light); margin-top: 1rem; } .una-review-xml pre { max-height: 50vh; overflow: auto; font-size: 12px; padding: 12px; margin: 8px 0 0; background: var(--surface-2);
.una-review-xml pre { border: 1px solid var(--line-2); border-radius: 8px; white-space: pre; }
max-height: 50vh; overflow: auto; font-size: 12px; padding: 0.75rem; margin: 0.5rem 0 0;
background: var(--bg-card-solid); border: 1px solid var(--border); border-radius: 0.25rem; white-space: pre; .savebar { position: sticky; bottom: 12px; z-index: 50; display: none; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px;
padding: 12px 12px 12px 20px; border-radius: 12px; background: var(--savebar-bg); border: 1px solid var(--savebar-edge);
color: var(--savebar-text); box-shadow: 0 8px 24px rgba(0,0,0,.25); }
.dirty .savebar { display: flex; }
.savebar .ui-btn { background: transparent; color: var(--savebar-text); border-color: #8E9AAB; }
.savebar .ui-btn.pri { background: var(--savebar-pri); border-color: var(--savebar-pri); color: var(--on-savebar-pri); }
.savebar .dirty-flag { display: inline-flex; align-items: center; gap: 8px; font-size: 14px; }
.savebar .dirty-flag .dot { background: var(--act); }
.savebar .sub { color: var(--savebar-sub); }
@media (max-width: 600px) {
.una-phead, .una-intro, .una-foot-note { padding-left: 16px; padding-right: 16px; }
.una-pbody { padding: 0 16px 16px; }
.una-row { grid-template-columns: 30px minmax(0, 1fr) auto; padding-right: 8px; gap: 8px; }
.una-head { flex-wrap: wrap; }
.una-tag { flex-direction: row; gap: 8px; min-width: 0; }
} }
.dirty-flag { font-size: 12px; color: var(--warning-dark); display: none; align-items: center; gap: 0.35rem; }
[data-theme="dark"] .dirty-flag { color: var(--warning); }
.dirty .dirty-flag { display: inline-flex; }
</style> </style>
{% endblock %} {% endblock %}
{% macro note(item) -%} {% macro note(item) -%}
{% if item.note %}<div class="una-note"><i class="bi bi-chat-left-text"></i><span>{{ item.note | join(' | ') }}</span></div>{% endif %} {% if item.note %}<div class="una-note"><i class="bi bi-chat-left-text" aria-hidden="true"></i><span>{{ item.note | join(' | ') }}</span></div>{% endif %}
{%- endmacro %}
{% macro row_menu(label) -%}
{% call ui.more(label) %}
<button type="button" class="una-up">Move up</button>
<button type="button" class="una-down">Move down</button>
<div class="sep"></div>
<button type="button" class="una-remove del">Remove</button>
{% endcall %}
{%- endmacro %} {%- endmacro %}
{% macro cmd_row(cmd, idx, field, limit) -%} {% macro cmd_row(cmd, idx, field, limit) -%}
<div class="una-row" data-orig="{{ idx }}"> <div class="una-row" data-orig="{{ idx }}">
<div class="una-side"> <div class="una-side">
<span class="una-pos">{{ idx + 1 }}</span> <span class="una-pos">{{ idx + 1 }}</span>
<span class="una-grip" title="Drag to reorder"><i class="bi bi-grip-vertical"></i></span> <span class="una-grip" title="Drag to reorder" aria-hidden="true"><i class="bi bi-grip-vertical"></i></span>
</div> </div>
<div class="una-body"> <div class="una-body">
{{ note(cmd) }} {{ note(cmd) }}
@@ -197,63 +183,51 @@
data-limit="{{ limit }}">{{ cmd[field] }}</textarea> data-limit="{{ limit }}">{{ cmd[field] }}</textarea>
<div class="una-foot"> <div class="una-foot">
<span class="una-count" data-for="desc"></span> <span class="una-count" data-for="desc"></span>
{% if cmd.inner_note %}<span class="una-inner" title="A comment inside this command; it is kept"><i class="bi bi-chat-left-text"></i> {{ cmd.inner_note | join(' | ') | truncate(160) }}</span>{% endif %} {% if cmd.inner_note %}<span class="una-inner" title="A comment inside this command; it is kept"><i class="bi bi-chat-left-text" aria-hidden="true"></i> {{ cmd.inner_note | join(' | ') | truncate(160) }}</span>{% endif %}
</div> </div>
</div> </div>
<div class="una-actions"> <div class="una-actions">{{ row_menu('command ' ~ (idx + 1)) }}</div>
<button type="button" class="btn btn-outline-secondary btn-sm una-up" title="Move up"><i class="bi bi-arrow-up"></i></button>
<button type="button" class="btn btn-outline-secondary btn-sm una-down" title="Move down"><i class="bi bi-arrow-down"></i></button>
<button type="button" class="btn btn-outline-danger btn-sm una-remove" title="Remove"><i class="bi bi-trash"></i></button>
</div>
</div> </div>
{%- endmacro %} {%- endmacro %}
{% block content %} {% block content %}
{% set header_subtitle = 'Deploy/FlatUnattendW10.xml' %} {% set header_subtitle = 'Deploy/FlatUnattendW10.xml' %}
{% set header_actions %} {% set header_actions %}
<span class="dirty-flag" id="unaDirty"><i class="bi bi-circle-fill" style="font-size:8px;"></i> Unsaved changes</span>
{% if last_save %} {% if last_save %}
<form method="POST" action="{{ url_for('images_unattend.undo', image_type=image_type) }}" class="d-inline" <form method="POST" action="{{ url_for('images_unattend.undo', image_type=image_type) }}" class="d-inline"
onsubmit="return confirm('Put FlatUnattendW10.xml back as it was before the save of {{ last_save.ts | local_dt }}?');"> onsubmit="return confirm('Put FlatUnattendW10.xml back as it was before the save of {{ last_save.ts | local_dt }}?');">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-outline-secondary btn-sm" title="Last save: {{ last_save.ts | local_dt }} ({{ last_save.action }})"> <button type="submit" class="ui-btn" title="Last save: {{ last_save.ts | local_dt }} ({{ last_save.action }})">Undo last save</button>
<i class="bi bi-arrow-counterclockwise"></i> Undo last save
</button>
</form> </form>
{% else %}
<span class="sub">Undo last save: nothing saved from this page yet</span>
{% endif %} {% endif %}
<button type="button" class="btn btn-success btn-sm" id="unaReview" disabled title="Ctrl+S">
<i class="bi bi-file-earmark-diff"></i> Review changes
</button>
{% endset %} {% endset %}
{% include "_image_header.html" %} {% include "_image_header.html" %}
<div class="ui-stack" id="unaPage">
{% if unattend_override %} {% if unattend_override %}
<div class="alert alert-warning"> <div class="notice"><i class="bi bi-exclamation-triangle" aria-hidden="true"></i><span>
<i class="bi bi-exclamation-triangle"></i>
<span class="mono">{{ unattend_override }}</span> exists in this image. PESetup loads that file first, <span class="mono">{{ unattend_override }}</span> exists in this image. PESetup loads that file first,
so edits to <span class="mono">Deploy/FlatUnattendW10.xml</span> here do not reach the PCs until it is removed. so edits to <span class="mono">Deploy/FlatUnattendW10.xml</span> here do not reach the PCs until it is removed.</span>
</div> </div>
{% endif %} {% endif %}
{% if not file_exists %} {% if not file_exists %}
<div class="alert alert-info"> <div class="notice info"><i class="bi bi-info-circle" aria-hidden="true"></i><span>
<i class="bi bi-info-circle"></i>
This image has no <span class="mono">Deploy/FlatUnattendW10.xml</span> yet. The form starts from an empty This image has no <span class="mono">Deploy/FlatUnattendW10.xml</span> yet. The form starts from an empty
answer file; saving creates it. answer file; saving creates it.</span>
</div> </div>
{% endif %} {% endif %}
{% set lint_errors = lint | selectattr('level', 'equalto', 'ERROR') | list %} {% set lint_errors = lint | selectattr('level', 'equalto', 'ERROR') | list %}
<div>
<div class="una-bar"> <div class="una-bar">
<ul class="nav nav-pills" role="tablist"> <ul class="nav nav-pills una-seg" role="tablist" aria-label="Editor view">
<li class="nav-item"> <li class="nav-item" role="presentation">
<a class="nav-link active" id="unaFormTab" data-bs-toggle="pill" href="#unaFormView" role="tab"> <a class="nav-link active" id="unaFormTab" data-bs-toggle="pill" href="#unaFormView" role="tab" aria-controls="unaFormView" aria-selected="true">Form</a>
<i class="bi bi-ui-checks-grid"></i> Form
</a>
</li> </li>
<li class="nav-item"> <li class="nav-item" role="presentation">
<a class="nav-link" id="unaRawTab" data-bs-toggle="pill" href="#unaRawView" role="tab"> <a class="nav-link" id="unaRawTab" data-bs-toggle="pill" href="#unaRawView" role="tab" aria-controls="unaRawView" aria-selected="false">Raw XML</a>
<i class="bi bi-code-slash"></i> Raw XML
</a>
</li> </li>
</ul> </ul>
<div class="una-meta"> <div class="una-meta">
@@ -263,25 +237,26 @@
<span title="FlatUnattendW10.xml.bak-* next to the file">{{ backup_count }} backup{{ '' if backup_count == 1 else 's' }}</span> <span title="FlatUnattendW10.xml.bak-* next to the file">{{ backup_count }} backup{{ '' if backup_count == 1 else 's' }}</span>
{% endif %} {% endif %}
{% if not lint %} {% if not lint %}
<span class="badge badge-success" title="The checks of scripts/lint-unattend.py">Lint clean</span> <span class="chip c-ok" title="The checks of scripts/lint-unattend.py">Lint clean</span>
{% elif lint_errors %} {% elif lint_errors %}
<a href="#unaLint" data-bs-toggle="collapse" class="badge badge-danger text-decoration-none">{{ lint_errors | length }} lint error{{ '' if lint_errors | length == 1 else 's' }}</a> <a href="#unaLint" data-bs-toggle="collapse" class="chip c-err" aria-expanded="true">{{ lint_errors | length }} lint error{{ '' if lint_errors | length == 1 else 's' }}</a>
{% else %} {% else %}
<a href="#unaLint" data-bs-toggle="collapse" class="badge badge-warning text-decoration-none">{{ lint | length }} lint warning{{ '' if lint | length == 1 else 's' }}</a> <a href="#unaLint" data-bs-toggle="collapse" class="chip c-warn" aria-expanded="false">{{ lint | length }} lint warning{{ '' if lint | length == 1 else 's' }}</a>
{% endif %} {% endif %}
</div> </div>
</div> </div>
{% if lint %} {% if lint %}
<div class="collapse {% if lint_errors %}show{% endif %}" id="unaLint"> <div class="collapse {% if lint_errors %}show{% endif %}" id="unaLint" style="margin-top: 12px">
<div class="alert {% if lint_errors %}alert-danger{% else %}alert-warning{% endif %}"> <div class="notice una-lintbox {{ 'bad' if lint_errors }}">
<strong>The file as it is now:</strong> checks from <span class="mono">scripts/lint-unattend.py</span>. <div><strong>The file as it is now:</strong> checks from <span class="mono">scripts/lint-unattend.py</span>.
{% if lint_errors %}One ERROR invalidates the whole answer file for its pass; fix it before imaging.{% endif %} {% if lint_errors %}One ERROR invalidates the whole answer file for its pass; fix it before imaging.{% endif %}</div>
<ul class="una-lint-list"> <ul class="una-lint-list">
{% for f in lint %}<li><span class="badge {{ 'badge-danger' if f.level == 'ERROR' else 'badge-warning' }}">{{ f.level }}</span> {{ f.message }}</li>{% endfor %} {% for f in lint %}<li>{{ ui.chip(f.level, 'err' if f.level == 'ERROR' else 'warn') }}{{ f.message }}</li>{% endfor %}
</ul> </ul>
</div> </div>
</div> </div>
{% endif %} {% endif %}
</div>
<div class="tab-content"> <div class="tab-content">
@@ -312,14 +287,14 @@
<div class="una-main" id="unaMain"> <div class="una-main" id="unaMain">
<!-- ============= SYSTEM AND REGIONAL ============= --> <!-- ============= SYSTEM AND REGIONAL ============= -->
<div class="group-heading una-section" id="grp-system" data-group="system"><i class="bi bi-pc-display-horizontal"></i> System and regional</div> <h2 class="group-heading una-section" id="grp-system" data-group="system">System and regional</h2>
<section class="card una-section" id="sec-machine" data-section="machine" data-group="system"> <section class="panel una-section" id="sec-machine" data-section="machine" data-group="system" aria-labelledby="h-machine">
<div class="card-header"> <div class="una-phead">
<span class="title-group"><i class="bi bi-hdd-network"></i> Machine settings</span> <h3 id="h-machine">Machine settings</h3>
<span class="una-hint">specialize pass, Microsoft-Windows-Shell-Setup</span> <span class="una-hint">specialize pass, Microsoft-Windows-Shell-Setup</span>
</div> </div>
<div class="card-body"> <div class="una-pbody">
<div class="row g-3"> <div class="row g-3">
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label" for="unaComputerName">Computer name</label> <label class="form-label" for="unaComputerName">Computer name</label>
@@ -346,12 +321,12 @@
</div> </div>
</section> </section>
<section class="card una-section" id="sec-intl" data-section="intl" data-group="system"> <section class="panel una-section" id="sec-intl" data-section="intl" data-group="system" aria-labelledby="h-intl">
<div class="card-header"> <div class="una-phead">
<span class="title-group"><i class="bi bi-globe2"></i> International</span> <h3 id="h-intl">International</h3>
<span class="una-hint">oobeSystem pass, Microsoft-Windows-International-Core</span> <span class="una-hint">oobeSystem pass, Microsoft-Windows-International-Core</span>
</div> </div>
<div class="card-body"> <div class="una-pbody">
<div class="row g-3"> <div class="row g-3">
{% for key, label in [("input_locale", "Input locale"), ("system_locale", "System locale"), ("ui_language", "UI language"), ("user_locale", "User locale")] %} {% for key, label in [("input_locale", "Input locale"), ("system_locale", "System locale"), ("ui_language", "UI language"), ("user_locale", "User locale")] %}
<div class="col-md-3 col-6"> <div class="col-md-3 col-6">
@@ -364,17 +339,13 @@
</div> </div>
</section> </section>
<section class="card una-section" id="sec-driverpaths" data-section="driverpaths" data-group="system"> <section class="panel una-section" id="sec-driverpaths" data-section="driverpaths" data-group="system" aria-labelledby="h-dp">
<div class="card-header"> <div class="una-phead">
<span class="title-group"> <h3 id="h-dp">Driver paths</h3>
<i class="bi bi-usb-drive"></i> Driver paths <span class="chip c-neutral" data-count="driverpaths">{{ data.driver_paths | length }}</span>
<span class="count-pill" data-count="driverpaths">{{ data.driver_paths | length }}</span> <button type="button" class="ui-btn sp una-add" data-list="driverpaths">Add path</button>
</span>
<button type="button" class="btn btn-sm btn-outline-primary una-add" data-list="driverpaths">
<i class="bi bi-plus-lg"></i> Add
</button>
</div> </div>
<div class="una-hint px-3 pt-2 pb-2"> <div class="una-intro">
Folders DISM searches for drivers while servicing the applied image (offlineServicing pass). Folders DISM searches for drivers while servicing the applied image (offlineServicing pass).
<span class="mono">W:\Drivers</span> is where PESetup unpacks this model's driver pack. <span class="mono">W:\Drivers</span> is where PESetup unpacks this model's driver pack.
</div> </div>
@@ -382,8 +353,8 @@
{% for dp in data.driver_paths %} {% for dp in data.driver_paths %}
<div class="una-simple" data-orig="{{ loop.index0 }}"> <div class="una-simple" data-orig="{{ loop.index0 }}">
<span class="una-pos">{{ loop.index }}</span> <span class="una-pos">{{ loop.index }}</span>
<input type="text" class="form-control form-control-sm mono una-dp" value="{{ dp }}" aria-label="Driver path"> <input type="text" class="form-control form-control-sm mono una-dp" value="{{ dp }}" aria-label="Driver path {{ loop.index }}">
<button type="button" class="btn btn-outline-danger btn-sm una-remove" title="Remove"><i class="bi bi-trash"></i></button> <button type="button" class="ui-btn ghost sm una-remove" aria-label="Remove driver path {{ loop.index }}">Remove</button>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
@@ -391,44 +362,38 @@
</section> </section>
<!-- ============= ACCOUNTS AND SIGN-IN ============= --> <!-- ============= ACCOUNTS AND SIGN-IN ============= -->
<div class="group-heading una-section" id="grp-accounts" data-group="accounts"><i class="bi bi-people"></i> Accounts and sign-in</div> <h2 class="group-heading una-section" id="grp-accounts" data-group="accounts">Accounts and sign-in</h2>
<section class="card una-section" id="sec-accounts" data-section="accounts" data-group="accounts"> <section class="panel una-section" id="sec-accounts" data-section="accounts" data-group="accounts" aria-labelledby="h-acct">
<div class="card-header"> <div class="una-phead">
<span class="title-group"> <h3 id="h-acct">User accounts (local)</h3>
<i class="bi bi-person-badge"></i> User accounts (local) <span class="chip c-neutral" data-count="accounts">{{ data.user_accounts | length }}</span>
<span class="count-pill" data-count="accounts">{{ data.user_accounts | length }}</span> <button type="button" class="ui-btn sp una-add" data-list="accounts">Add account</button>
</span>
<button type="button" class="btn btn-sm btn-outline-primary una-add" data-list="accounts">
<i class="bi bi-plus-lg"></i> Add
</button>
</div> </div>
<div class="una-list" id="unaAccounts" data-list="accounts"> <div class="una-list" id="unaAccounts" data-list="accounts">
{% for acct in data.user_accounts %} {% for acct in data.user_accounts %}
<div class="una-simple" data-orig="{{ loop.index0 }}" data-plain="{{ acct.plain_text }}"> <div class="una-simple" data-orig="{{ loop.index0 }}" data-plain="{{ acct.plain_text }}">
<span class="una-pos">{{ loop.index }}</span> <span class="una-pos">{{ loop.index }}</span>
<div class="una-acct-fields"> <div class="una-acct-fields">
<div><label>Name</label><input type="text" class="form-control form-control-sm" data-f="name" value="{{ acct.name }}"></div> <div><label for="unaAcct{{ loop.index }}n">Name</label><input type="text" class="form-control form-control-sm" id="unaAcct{{ loop.index }}n" data-f="name" value="{{ acct.name }}"></div>
<div><label>Password{% if acct.plain_text == 'false' %} <span title="PlainText is false in the file: this is the encoded value. A password typed here is stored as plain text.">(encoded)</span>{% endif %}</label><input type="text" class="form-control form-control-sm mono" data-f="password" value="{{ acct.password }}" autocomplete="off"></div> <div><label for="unaAcct{{ loop.index }}p">Password{% if acct.plain_text == 'false' %} <span title="PlainText is false in the file: this is the encoded value. A password typed here is stored as plain text.">(encoded)</span>{% endif %}</label><input type="text" class="form-control form-control-sm mono" id="unaAcct{{ loop.index }}p" data-f="password" value="{{ acct.password }}" autocomplete="off"></div>
<div><label>Group</label><input type="text" class="form-control form-control-sm" data-f="group" value="{{ acct.group }}"></div> <div><label for="unaAcct{{ loop.index }}g">Group</label><input type="text" class="form-control form-control-sm" id="unaAcct{{ loop.index }}g" data-f="group" value="{{ acct.group }}"></div>
<div><label>Display name</label><input type="text" class="form-control form-control-sm" data-f="display_name" value="{{ acct.display_name }}"></div> <div><label for="unaAcct{{ loop.index }}d">Display name</label><input type="text" class="form-control form-control-sm" id="unaAcct{{ loop.index }}d" data-f="display_name" value="{{ acct.display_name }}"></div>
</div> </div>
<button type="button" class="btn btn-outline-danger btn-sm una-remove" title="Remove"><i class="bi bi-trash"></i></button> <button type="button" class="ui-btn ghost sm una-remove" aria-label="Remove account {{ acct.name }}">Remove</button>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
<div class="una-empty" data-empty="accounts" {% if data.user_accounts %}hidden{% endif %}>No local accounts.</div> <div class="una-empty" data-empty="accounts" {% if data.user_accounts %}hidden{% endif %}>No local accounts.</div>
<div class="card-footer una-hint"> <div class="una-foot-note">Passwords sit in the answer file as plain text. Treat this file as sensitive.</div>
<i class="bi bi-shield-lock"></i> Passwords sit in the answer file as plain text. Treat this file as sensitive.
</div>
</section> </section>
<section class="card una-section" id="sec-autologon" data-section="autologon" data-group="accounts"> <section class="panel una-section" id="sec-autologon" data-section="autologon" data-group="accounts" aria-labelledby="h-al">
<div class="card-header"> <div class="una-phead">
<span class="title-group"><i class="bi bi-box-arrow-in-right"></i> AutoLogon</span> <h3 id="h-al">AutoLogon</h3>
<span class="una-hint">clear the user name to remove AutoLogon</span> <span class="una-hint">clear the user name to remove AutoLogon</span>
</div> </div>
<div class="card-body"> <div class="una-pbody">
<div class="row g-3 align-items-end"> <div class="row g-3 align-items-end">
<div class="col-md-3 col-6"> <div class="col-md-3 col-6">
<label class="form-label" for="unaAlEnabled">Enabled</label> <label class="form-label" for="unaAlEnabled">Enabled</label>
@@ -460,14 +425,14 @@
</section> </section>
<!-- ============= OUT-OF-BOX EXPERIENCE ============= --> <!-- ============= OUT-OF-BOX EXPERIENCE ============= -->
<div class="group-heading una-section" id="grp-oobe" data-group="oobe"><i class="bi bi-window-desktop"></i> Out-of-box experience (OOBE)</div> <h2 class="group-heading una-section" id="grp-oobe" data-group="oobe">Out-of-box experience (OOBE)</h2>
<section class="card una-section" id="sec-oobe" data-section="oobe" data-group="oobe"> <section class="panel una-section" id="sec-oobe" data-section="oobe" data-group="oobe" aria-labelledby="h-oobe">
<div class="card-header"> <div class="una-phead">
<span class="title-group"><i class="bi bi-toggles"></i> OOBE settings</span> <h3 id="h-oobe">OOBE settings</h3>
<span class="una-hint">"Not set" = not in the file (Windows default)</span> <span class="una-hint">"Not set" = not in the file (Windows default)</span>
</div> </div>
<div class="card-body"> <div class="una-pbody">
<div class="row g-3"> <div class="row g-3">
{% set oobe_fields = [ {% set oobe_fields = [
("HideEULAPage", "Hide EULA page", ["true", "false"]), ("HideEULAPage", "Hide EULA page", ["true", "false"]),
@@ -495,12 +460,12 @@
</div> </div>
</section> </section>
<section class="card una-section" id="sec-oobetz" data-section="oobetz" data-group="oobe"> <section class="panel una-section" id="sec-oobetz" data-section="oobetz" data-group="oobe" aria-labelledby="h-oobetz">
<div class="card-header"> <div class="una-phead">
<span class="title-group"><i class="bi bi-clock-history"></i> OOBE time zone</span> <h3 id="h-oobetz">OOBE time zone</h3>
<span class="una-hint">oobeSystem pass; separate from the specialize time zone above</span> <span class="una-hint">oobeSystem pass; separate from the specialize time zone above</span>
</div> </div>
<div class="card-body"> <div class="una-pbody">
<div class="row g-3"> <div class="row g-3">
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label" for="unaOobeTz">Time zone</label> <label class="form-label" for="unaOobeTz">Time zone</label>
@@ -512,37 +477,38 @@
</section> </section>
<!-- ============= SETUP COMMANDS ============= --> <!-- ============= SETUP COMMANDS ============= -->
<div class="group-heading una-section" id="grp-commands" data-group="commands"><i class="bi bi-terminal"></i> Setup commands</div> <h2 class="group-heading una-section" id="grp-commands" data-group="commands">Setup commands</h2>
<details class="panel" style="border-radius: 10px">
<summary class="disc" style="padding: 0 16px; min-height: 44px; color: var(--text); font-weight: 400">
<i class="bi bi-info-circle" aria-hidden="true" style="color: var(--accent)"></i>
<span class="trunc" style="flex: 1 1 auto"><strong>Order matters.</strong> <span class="muted">Each list runs top to bottom as a tested sequence.</span></span>
<span class="chev" aria-hidden="true">&#9662;</span>
</summary>
<p style="margin: 0; padding: 0 16px 14px 42px; font-size: 14px; line-height: 1.6; color: var(--text-2)">
Reorder with Move up and Move down in a command's menu, or by dragging its <i class="bi bi-grip-vertical" aria-label="grip"></i> handle.
Comments shown in italics are XML comments from the file; they move with the command below them.
Limits (one over invalidates the whole pass): specialize command {{ limits.path }} characters,
first logon command {{ limits.commandline }}, description {{ limits.description }}.
</p>
</details>
<div class="una-filter" id="unaFilterBar"> <div class="una-filter" id="unaFilterBar">
<div class="input-wrap"> <div class="input-wrap" role="search">
<i class="bi bi-search"></i> <label for="unaFilter" class="vh">Filter commands</label>
<input type="search" class="form-control form-control-sm" id="unaFilter" <input type="search" class="field" id="unaFilter"
placeholder="Filter commands (text, description or position)" aria-label="Filter commands"> placeholder="Filter commands (text, description or position)">
</div> </div>
<span class="una-hint" id="unaFilterCount"></span> <span class="una-hint" id="unaFilterCount"></span>
</div> </div>
<div class="alert alert-warning"> <section class="panel una-section" id="sec-spec" data-section="spec" data-group="commands" aria-labelledby="h-spec">
<i class="bi bi-exclamation-triangle"></i> <div class="una-phead">
Each list runs top to bottom as a tested sequence; <strong>order matters</strong>. Reorder with the arrows <h3 id="h-spec">Specialize commands</h3>
or by dragging the <i class="bi bi-grip-vertical"></i> handle. Comments shown in italics are XML comments <span class="chip c-neutral" data-count="spec">{{ data.specialize_commands | length }}</span>
from the file; they move with the command below them. <button type="button" class="ui-btn sp una-add" data-list="spec">Add command</button>
Limits (one over invalidates the whole pass): specialize command {{ limits.path }} characters,
first logon command {{ limits.commandline }}, description {{ limits.description }}.
</div>
<section class="card una-section" id="sec-spec" data-section="spec" data-group="commands">
<div class="card-header">
<span class="title-group">
<i class="bi bi-gear-wide-connected"></i> Specialize commands
<span class="count-pill" data-count="spec">{{ data.specialize_commands | length }}</span>
</span>
<button type="button" class="btn btn-sm btn-outline-primary una-add" data-list="spec">
<i class="bi bi-plus-lg"></i> Add
</button>
</div> </div>
<div class="una-hint px-3 pt-2 pb-2">RunSynchronous: run as SYSTEM during the specialize pass, before anyone signs in.</div> <div class="una-intro">RunSynchronous: run as SYSTEM during the specialize pass, before anyone signs in.</div>
<div class="una-list una-cmds" id="unaSpec" data-list="spec" data-limit="{{ limits.path }}"> <div class="una-list una-cmds" id="unaSpec" data-list="spec" data-limit="{{ limits.path }}">
{% for cmd in data.specialize_commands %}{{ cmd_row(cmd, loop.index0, 'path', limits.path) }}{% endfor %} {% for cmd in data.specialize_commands %}{{ cmd_row(cmd, loop.index0, 'path', limits.path) }}{% endfor %}
</div> </div>
@@ -550,17 +516,13 @@
<div class="una-empty" data-nomatch="spec" hidden>No specialize command matches the filter.</div> <div class="una-empty" data-nomatch="spec" hidden>No specialize command matches the filter.</div>
</section> </section>
<section class="card una-section" id="sec-fl" data-section="fl" data-group="commands"> <section class="panel una-section" id="sec-fl" data-section="fl" data-group="commands" aria-labelledby="h-fl">
<div class="card-header"> <div class="una-phead">
<span class="title-group"> <h3 id="h-fl">First logon commands</h3>
<i class="bi bi-play-circle"></i> First logon commands <span class="chip c-neutral" data-count="fl">{{ data.firstlogon_commands | length }}</span>
<span class="count-pill" data-count="fl">{{ data.firstlogon_commands | length }}</span> <button type="button" class="ui-btn sp una-add" data-list="fl">Add command</button>
</span>
<button type="button" class="btn btn-sm btn-outline-primary una-add" data-list="fl">
<i class="bi bi-plus-lg"></i> Add
</button>
</div> </div>
<div class="una-hint px-3 pt-2 pb-2">FirstLogonCommands: run in the first user session (the AutoLogon account), oobeSystem pass.</div> <div class="una-intro">FirstLogonCommands: run in the first user session (the AutoLogon account), oobeSystem pass.</div>
<div class="una-list una-cmds" id="unaFl" data-list="fl" data-limit="{{ limits.commandline }}"> <div class="una-list una-cmds" id="unaFl" data-list="fl" data-limit="{{ limits.commandline }}">
{% for cmd in data.firstlogon_commands %}{{ cmd_row(cmd, loop.index0, 'commandline', limits.commandline) }}{% endfor %} {% for cmd in data.firstlogon_commands %}{{ cmd_row(cmd, loop.index0, 'commandline', limits.commandline) }}{% endfor %}
</div> </div>
@@ -574,63 +536,65 @@
<!-- ==================== RAW XML VIEW ==================== --> <!-- ==================== RAW XML VIEW ==================== -->
<div class="tab-pane fade" id="unaRawView" role="tabpanel" aria-labelledby="unaRawTab"> <div class="tab-pane fade" id="unaRawView" role="tabpanel" aria-labelledby="unaRawTab">
<div class="card"> <section class="panel" aria-labelledby="h-raw">
<div class="card-header"> <div class="una-phead">
<span class="title-group"><i class="bi bi-filetype-xml"></i> Raw XML</span> <h3 id="h-raw">Raw XML</h3>
<span class="una-hint">Review changes shows the diff of this text against the file</span> <span class="una-hint">Review changes shows the diff of this text against the file</span>
</div> </div>
<div class="card-body"> <div class="una-pbody">
<p class="una-hint mb-2"> <p class="una-hint" style="margin: 0 0 10px">
For what the form does not cover. This text is written exactly as it stands; edits made on the For what the form does not cover. This text is written exactly as it stands; edits made on the
Form view are not part of a Raw XML save (and the other way round). Form view are not part of a Raw XML save (and the other way round).
</p> </p>
<textarea class="form-control raw-xml-editor" id="unaRawXml" spellcheck="false" <textarea class="form-control raw-xml-editor" id="unaRawXml" spellcheck="false"
aria-label="Raw XML">{{ data.raw_xml }}</textarea> aria-label="Raw XML">{{ data.raw_xml }}</textarea>
</div> </div>
</div> </section>
</div> </div>
</div><!-- tab-content --> </div><!-- tab-content -->
<div class="savebar" role="region" aria-label="Unsaved changes">
<span class="dirty-flag" id="unaDirty" role="status"><span class="dot" aria-hidden="true"></span> Unsaved changes <span class="sub">Ctrl+S reviews</span></span>
<button type="button" class="ui-btn pri" id="unaReview" disabled title="Ctrl+S">Review changes</button>
</div>
</div><!-- unaPage -->
{# Row templates for Add #} {# Row templates for Add #}
<template id="unaTplCmd"> <template id="unaTplCmd">
<div class="una-row is-new"> <div class="una-row is-new">
<div class="una-side"> <div class="una-side">
<span class="una-pos"></span> <span class="una-pos"></span>
<span class="una-grip" title="Drag to reorder"><i class="bi bi-grip-vertical"></i></span> <span class="una-grip" title="Drag to reorder" aria-hidden="true"><i class="bi bi-grip-vertical"></i></span>
</div> </div>
<div class="una-body"> <div class="una-body">
<div class="una-head"> <div class="una-head">
<input type="text" class="form-control form-control-sm una-desc" placeholder="Description" aria-label="Description" data-limit="{{ limits.description }}"> <input type="text" class="form-control form-control-sm una-desc" placeholder="Description" aria-label="Description" data-limit="{{ limits.description }}">
<span class="una-tag"><span class="badge badge-success">new</span><span class="una-count" data-for="cmd"></span></span> <span class="una-tag"><span class="chip c-edit">New</span><span class="una-count" data-for="cmd"></span></span>
</div> </div>
<textarea class="form-control una-cmd" rows="1" spellcheck="false" aria-label="Command" placeholder="Command"></textarea> <textarea class="form-control una-cmd" rows="1" spellcheck="false" aria-label="Command" placeholder="Command"></textarea>
<div class="una-foot"><span class="una-count" data-for="desc"></span></div> <div class="una-foot"><span class="una-count" data-for="desc"></span></div>
</div> </div>
<div class="una-actions"> <div class="una-actions">{{ row_menu('new command') }}</div>
<button type="button" class="btn btn-outline-secondary btn-sm una-up" title="Move up"><i class="bi bi-arrow-up"></i></button>
<button type="button" class="btn btn-outline-secondary btn-sm una-down" title="Move down"><i class="bi bi-arrow-down"></i></button>
<button type="button" class="btn btn-outline-danger btn-sm una-remove" title="Remove"><i class="bi bi-trash"></i></button>
</div>
</div> </div>
</template> </template>
<template id="unaTplDp"> <template id="unaTplDp">
<div class="una-simple is-new"> <div class="una-simple is-new">
<span class="una-pos"></span> <span class="una-pos"></span>
<input type="text" class="form-control form-control-sm mono una-dp" placeholder="e.g. W:\Drivers" aria-label="Driver path"> <input type="text" class="form-control form-control-sm mono una-dp" placeholder="e.g. W:\Drivers" aria-label="Driver path">
<button type="button" class="btn btn-outline-danger btn-sm una-remove" title="Remove"><i class="bi bi-trash"></i></button> <button type="button" class="ui-btn ghost sm una-remove" aria-label="Remove this driver path">Remove</button>
</div> </div>
</template> </template>
<template id="unaTplAcct"> <template id="unaTplAcct">
<div class="una-simple is-new" data-plain="true"> <div class="una-simple is-new" data-plain="true">
<span class="una-pos"></span> <span class="una-pos"></span>
<div class="una-acct-fields"> <div class="una-acct-fields">
<div><label>Name</label><input type="text" class="form-control form-control-sm" data-f="name"></div> <div><label>Name <input type="text" class="form-control form-control-sm" data-f="name"></label></div>
<div><label>Password</label><input type="text" class="form-control form-control-sm mono" data-f="password" autocomplete="off"></div> <div><label>Password <input type="text" class="form-control form-control-sm mono" data-f="password" autocomplete="off"></label></div>
<div><label>Group</label><input type="text" class="form-control form-control-sm" data-f="group" value="Administrators"></div> <div><label>Group <input type="text" class="form-control form-control-sm" data-f="group" value="Administrators"></label></div>
<div><label>Display name</label><input type="text" class="form-control form-control-sm" data-f="display_name"></div> <div><label>Display name <input type="text" class="form-control form-control-sm" data-f="display_name"></label></div>
</div> </div>
<button type="button" class="btn btn-outline-danger btn-sm una-remove" title="Remove"><i class="bi bi-trash"></i></button> <button type="button" class="ui-btn ghost sm una-remove" aria-label="Remove this account">Remove</button>
</div> </div>
</template> </template>
{% endblock %} {% endblock %}

View File

@@ -1,337 +1,413 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Blancco Updates - PXE Server Manager{% endblock %} {% import '_ui.html' as ui %}
{% block title %}Blancco releases - PXE Manager{% endblock %}
{% macro mb(n) -%}{{ "%.1f"|format((n or 0) / 1048576) }} MB{%- endmacro %} {% macro mb(n) -%}{{ "%.1f"|format((n or 0) / 1048576) }} MB{%- endmacro %}
{% macro release_cells(r) -%} {% macro prefs(r) -%}{{ r.prefs_version or '-' }}{% if r.vendor_prefs_version and r.vendor_prefs_version != r.prefs_version %} (vendor {{ r.vendor_prefs_version }}){% endif %}{%- endmacro %}
<td class="mono">{{ r.kernel or '-' }}</td>
<td class="mono">{{ r.prefs_version or '-' }}{% if r.vendor_prefs_version and r.vendor_prefs_version != r.prefs_version %}<div class="small text-muted">vendor {{ r.vendor_prefs_version }}</div>{% endif %}</td> {% block extra_head %}
<td class="text-nowrap">{{ mb(r.size) }}</td> <style>
{%- endmacro %} .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; align-items: center; }
.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; } }
.bar { flex: 1 1 160px; height: 6px; border-radius: 3px; background: var(--track-empty); overflow: hidden; min-width: 60px; max-width: 360px; }
.bar > div { height: 100%; width: 0; background: var(--run); }
.bar.bad > div, .bar > div.bg-danger { background: var(--fail); }
.cur { padding: 20px 24px; display: flex; flex-direction: column; gap: 14px; }
.cur-top { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; }
.cur-ver { font-size: 24px; font-weight: 600; }
.facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(170px, 100%), 1fr)); gap: 12px 20px; margin: 0; }
.facts dt { font-size: 12px; color: var(--muted); font-weight: 400; }
.facts dd { margin: 2px 0 0; overflow-wrap: anywhere; }
.sgrid { display: grid; grid-template-columns: 190px minmax(0, 1fr) 190px 14px; align-items: center; gap: 0 16px;
padding: 12px 240px 12px 24px; font-size: 14px; min-height: 24px; }
.fgrid { display: grid; grid-template-columns: minmax(0, 1fr) 110px 80px 170px; align-items: center; gap: 0 16px;
padding: 12px 170px 12px 24px; font-size: 14px; min-height: 36px; }
.bgrid { display: grid; grid-template-columns: minmax(0, 1fr) 90px 170px 14px; align-items: center; gap: 0 16px;
padding: 12px 170px 12px 24px; font-size: 14px; min-height: 24px; }
.rbody { padding: 2px 24px 16px; font-size: 13px; display: flex; flex-direction: column; gap: 8px; line-height: 1.55; }
.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; }
.vname { display: flex; align-items: center; gap: 8px; min-width: 0; white-space: nowrap; }
.upl { display: flex; flex-wrap: wrap; gap: 10px 14px; align-items: flex-end; padding: 0 24px 16px; }
.upl .fld { display: flex; flex-direction: column; gap: 4px; flex: 1 1 280px; min-width: 0; }
.upl label { font-size: 13px; font-weight: 600; }
input[type=file].field { padding: 5px 8px; height: auto; min-height: 36px; width: 100%; }
.prog { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; padding: 0 24px 16px; font-size: 13px; }
.steps { margin: 0; padding-left: 20px; display: flex; flex-direction: column; gap: 4px; }
.joblog { margin: 6px 0 0; max-height: 16rem; overflow: auto; padding: 8px 10px; background: var(--surface-2); border: 1px solid var(--line); border-radius: 6px; font-size: 12px; white-space: pre-wrap; color: var(--text); }
@container (max-width: 900px) {
.sgrid { grid-template-columns: minmax(0, 1fr) 180px 14px; }
.sgrid .c-meta { display: none; }
.fgrid { grid-template-columns: minmax(0, 1fr) 80px; }
.fgrid .c-folder, .fgrid .c-mod { display: none; }
.bgrid { grid-template-columns: minmax(0, 1fr) 80px 14px; }
.bgrid .c-saved { display: none; }
}
@container (max-width: 560px) {
.sgrid .c-meta, .bgrid .c-saved { display: block; }
.sgrid, .bgrid { grid-template-columns: minmax(0, 1fr) auto 14px; padding: 12px 16px; gap: 4px 10px; }
.sgrid .c-meta, .bgrid .c-saved { grid-column: 1 / -1; grid-row: 2; }
.bgrid .c-ver { grid-column: 2; grid-row: 1; }
.fgrid { grid-template-columns: minmax(0, 1fr) auto; padding: 12px 16px; gap: 2px 10px; }
.fgrid .c-folder, .fgrid .c-mod { display: none; }
.ui-row > .act { position: static; padding: 0 16px 10px; display: flex; justify-content: flex-end; }
.rbody { padding: 2px 16px 14px; }
.kv { grid-template-columns: minmax(0, 1fr); }
.kv dd { margin-bottom: 6px; }
.sec-h, .upl, .prog { padding-left: 16px; padding-right: 16px; }
.cur { padding: 18px 16px; }
}
</style>
{% endblock %}
{% block content %} {% block content %}
<div class="page-header"> <header class="ui-head">
<h1><i class="bi bi-eraser"></i> Blancco Updates</h1> <div>
<div class="header-actions"> <div class="eyebrow-t">Updates</div>
<span class="badge badge-info badge-lg">Live: {{ current.version }}{% if current.inferred %} (installed by hand){% endif %}</span> <h1>Blancco releases</h1>
{% if test %}<span class="badge badge-warning badge-lg">Test entry: {{ test.version or '?' }}</span>{% endif %} <div class="state-line">
{{ ui.chip('Live: ' ~ current.version ~ (' (installed by hand)' if current.inferred else ''), 'fail' if current.missing else 'ok') }}
{% if test %}{{ ui.chip('Test entry: ' ~ (test.version or '?'), 'warn') }}{% endif %}
{% set n_ready = staged | selectattr('status', 'equalto', 'ready') | list | length %}
{% if staged %}<span class="sub">{{ staged | length }} staged, {{ n_ready }} ready</span>{% endif %}
</div>
</div> </div>
</div> <div class="actions"><a class="ui-btn pri" href="#upload">Upload release</a></div>
</header>
<p class="text-muted"> <div class="ui-stack">
Installs a new Blancco Drive Eraser release from the vendor ISO (or the vendor's USB image, <span class="mono">.img</span>
or <span class="mono">.img.gz</span>). Staging extracts the release next to the live one, applies the same changes as
<span class="mono">scripts/prepare-boot-tools.sh</span> (our preferences written into <span class="mono">config.img</span>,
the initramfs network patch), and a test entry in the PXE boot menu boots the staged copy. Only <strong>Activate</strong>
changes what the normal Blancco entry boots, and the files it replaces are kept as a backup you can roll back to.
The Ubuntu kernel, its initrd and the kmod tarball the UEFI path uses are not part of a release and are never touched.
</p>
{# ---- Live release -------------------------------------------------------- #}
<div class="section-card">
<div class="section-title"><i class="bi bi-hdd-network"></i> Live release</div>
<p class="small text-muted mb-2">Served from <span class="mono">{{ current.path }}</span>.</p>
<div class="table-container">
<table class="data-table">
<thead><tr><th>Version</th><th>Blancco kernel</th><th>Preferences</th><th>Size</th><th>Rootfs date</th><th>Installed</th></tr></thead>
<tbody>
<tr>
<td class="mono">{{ current.version }}
{% if current.inferred %}<div class="small text-muted">from the preferences in config.img</div>{% endif %}
{% if current.changed_by_hand %}<div class="small text-warning">files changed outside this page since the last activate or roll back</div>{% endif %}</td>
{{ release_cells(current) }}
<td class="mono text-nowrap">{{ current.rootfs_mtime | timestamp_fmt if current.rootfs_mtime else '-' }}</td>
<td class="small">
{% if current.activated %}{{ current.activated | local_dt }}
{% if current.restored_from %}<div class="text-muted">rolled back to {{ current.restored_from }}</div>
{% elif current.source %}<div class="text-muted">from {{ current.source }}</div>{% endif %}
{% else %}<span class="text-muted">by hand (before this page)</span>{% endif %}
</td>
</tr>
</tbody>
</table>
</div>
{% if current.missing %}
<div class="alert alert-warning mt-2 mb-0">Missing from the live folder: <span class="mono">{{ current.missing | join(', ') }}</span></div>
{% endif %}
</div>
{# ---- Job ----------------------------------------------------------------- #} {# ---- Job ----------------------------------------------------------------- #}
{% if job.state %} {% if job.state %}
<div class="section-card" id="blanccoJob" data-state="{{ job.state }}"> <div id="blanccoJob" data-state="{{ job.state }}" class="ui-stack" style="gap: 8px">
<div class="section-title"><i class="bi bi-gear"></i> Staging {{ job.source or '' }}</div>
{% if job_running %} {% if job_running %}
<div class="d-flex align-items-center gap-2 mb-1"> <div class="panel strip" role="status">
<span class="spinner-border spinner-border-sm" role="status"></span> <span class="pulse" aria-hidden="true"></span>
<span id="blanccoJobStep">{{ job.step or 'Starting' }}</span> <span>Staging {{ job.source or '' }}: <span id="blanccoJobStep">{{ job.step or 'Starting' }}</span></span>
<span id="blanccoJobMsg" class="small text-muted">{{ job.message or '' }}</span> <span id="blanccoJobMsg" class="sub">{{ job.message or '' }}</span>
<div class="bar" aria-hidden="true"><div id="blanccoJobBar" style="width: {{ job.percent or 0 }}%"></div></div>
<form method="post" action="{{ url_for('blancco_update.cancel') }}"
onsubmit="return confirm('Stop staging and remove the partial files?');">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="ui-btn sm">Cancel</button>
</form>
</div> </div>
<div class="progress mb-2" style="height: 0.5rem;">
<div id="blanccoJobBar" class="progress-bar" style="width: {{ job.percent or 0 }}%;"></div>
</div>
<form method="post" action="{{ url_for('blancco_update.cancel') }}" class="mb-2"
onsubmit="return confirm('Stop staging and remove the partial files?');">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="pxe-btn pxe-btn-ghost pxe-btn-sm"><i class="bi bi-x-circle"></i> Cancel</button>
</form>
{% elif job.state == 'done' %} {% elif job.state == 'done' %}
<div class="alert alert-success mb-2">{{ job.message }} <span class="small text-muted">({{ job.finished | local_dt }})</span></div> <div class="notice info">Staging {{ job.source or '' }}: {{ job.message }} <span class="sub">({{ job.finished | local_dt }})</span></div>
{% elif job.state == 'blocked' %} {% elif job.state == 'blocked' %}
<div class="alert alert-warning mb-2"><strong>Staging stopped: preferences need merging.</strong> {{ job.message }}</div> <div class="notice"><span><strong>Staging {{ job.source or '' }} stopped: preferences need merging.</strong> {{ job.message }} See the staged release below.</span></div>
{% elif job.state == 'cancelled' %} {% elif job.state == 'cancelled' %}
<div class="alert alert-warning mb-2">Staging was cancelled.</div> <div class="notice">Staging {{ job.source or '' }} was cancelled.</div>
{% else %} {% else %}
<div class="alert alert-danger mb-2">{{ job.message or 'Staging failed.' }}</div> <div class="errbox">Staging {{ job.source or '' }}: {{ job.message or 'Staging failed.' }}</div>
{% endif %} {% endif %}
{% if job.log %} {% if job.log %}
<details {% if job_running or job.state in ('failed', 'blocked') %}open{% endif %}> <details {% if job_running or job.state in ('failed', 'blocked') %}open{% endif %}>
<summary class="small text-muted" style="cursor:pointer;">Job log</summary> <summary class="disc"><span class="chev" aria-hidden="true">&#9662;</span>Job log</summary>
<pre id="blanccoJobLog" class="mono small mb-0" style="white-space: pre-wrap; max-height: 16rem; overflow:auto;">{{ job.log | join('\n') }}</pre> <pre id="blanccoJobLog" class="mono joblog">{{ job.log | join('\n') }}</pre>
</details> </details>
{% endif %} {% endif %}
</div> </div>
{% endif %} {% endif %}
{# ---- Get a release ------------------------------------------------------- #} {# ---- Live release -------------------------------------------------------- #}
<div class="section-card"> <section class="panel cur" aria-labelledby="live-h">
<div class="section-title"><i class="bi bi-upload"></i> Release files</div> <div class="cur-top">
<form id="blanccoUpload" method="post" action="{{ url_for('blancco_update.upload') }}" enctype="multipart/form-data" <div>
class="d-flex flex-wrap gap-2 align-items-end mb-2"> <h2 id="live-h" class="sec" style="font-size: 15px; color: var(--muted)">Live release</h2>
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}"> <div class="cur-ver mono">{{ current.version }}</div>
<div class="form-field mb-0" style="min-width: min(22rem, 100%);">
<label for="blanccoFile" class="form-label">Vendor ISO or USB image</label>
<input type="file" class="form-control" id="blanccoFile" name="release_file" accept=".iso,.img,.gz" required>
<div class="form-text">About 1 GB; streamed to <span class="mono">{{ upload_dir }}</span>.</div>
</div> </div>
<button type="submit" class="pxe-btn pxe-btn-primary"><i class="bi bi-upload"></i> Upload</button> <span style="margin-left: auto">{% if current.missing %}{{ ui.chip('Files missing', 'fail') }}{% elif current.changed_by_hand %}{{ ui.chip('Changed by hand', 'warn') }}{% else %}{{ ui.chip('Serving', 'ok') }}{% endif %}</span>
</form>
<div id="blanccoUploadProgress" class="mb-3" style="display:none;">
<div class="small mb-1" id="blanccoUploadText">Uploading...</div>
<div class="progress" style="height: 0.5rem;"><div id="blanccoUploadBar" class="progress-bar" style="width: 0%;"></div></div>
</div> </div>
<p class="small text-muted"> {% if current.missing %}<div class="notice bad">Missing from the live folder: <span class="mono">{{ current.missing | join(', ') }}</span></div>{% endif %}
Files copied in by hand are listed too, from {% if current.changed_by_hand %}<div class="notice">Files changed outside this page since the last activate or roll back.</div>{% endif %}
{% for key, folder in source_dirs %}<span class="mono">{{ folder }}</span>{{ ', ' if not loop.last }}{% endfor %} <dl class="facts">
(<span class="mono">.iso</span>, <span class="mono">.img</span>, <span class="mono">.img.gz</span>, <span class="mono">.iso.gz</span>). <div><dt>Blancco kernel</dt><dd class="mono">{{ current.kernel or '-' }}</dd></div>
</p> <div><dt>Preferences</dt><dd class="mono">{{ prefs(current) }}</dd></div>
{% if sources %} <div><dt>Size</dt><dd>{{ mb(current.size) }}</dd></div>
<div class="table-container"> <div><dt>Rootfs date</dt><dd class="mono">{{ current.rootfs_mtime | timestamp_fmt if current.rootfs_mtime else '-' }}</dd></div>
<table class="data-table"> <div><dt>Installed</dt><dd>{% if current.activated %}{{ current.activated | local_dt }}
<thead><tr><th>File</th><th>Folder</th><th>Size</th><th>Modified</th><th class="text-end"></th></tr></thead> {% if current.restored_from %}<div class="sub">rolled back to {{ current.restored_from }}</div>
<tbody> {% elif current.source %}<div class="sub">from {{ current.source }}</div>{% endif %}
{% for s in sources %} {% else %}<span class="muted">by hand (before this page)</span>{% endif %}
<tr> {% if current.inferred %}<div class="sub">version read from the preferences in config.img</div>{% endif %}</dd></div>
<td class="mono">{{ s.name }}</td> </dl>
<td class="small text-muted">{{ s.key }}</td> <div class="sub" style="overflow-wrap: anywhere">Served from <span class="mono">{{ current.path }}</span>.</div>
<td class="text-nowrap">{{ mb(s.size) }}</td> </section>
<td class="mono text-nowrap">{{ s.modified | timestamp_fmt }}</td>
<td class="text-end text-nowrap">
<form method="post" action="{{ url_for('blancco_update.stage') }}" style="display:inline;">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="source" value="{{ s.key }}:{{ s.name }}">
<button type="submit" class="pxe-btn pxe-btn-primary btn-row-action" {% if job_running %}disabled{% endif %}>
<i class="bi bi-box-seam"></i> Stage</button>
</form>
{% if s.deletable %}
<form method="post" action="{{ url_for('blancco_update.delete_upload', name=s.name) }}" style="display:inline;"
onsubmit="return confirm({{ ('Delete ' ~ s.name ~ '?') | tojson | forceescape }});">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="pxe-btn pxe-btn-ghost btn-row-action"><i class="bi bi-trash"></i></button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted mb-0">No release files yet. Upload the vendor ISO or USB image above.</p>
{% endif %}
</div>
{# ---- Staged -------------------------------------------------------------- #} {# ---- Staged -------------------------------------------------------------- #}
<div class="section-card"> <section class="panel ui-list" aria-labelledby="stg-h">
<div class="section-title"><i class="bi bi-box-seam"></i> Staged releases</div> <div class="sec-h"><div><h2 id="stg-h" class="sec">Staged releases</h2>
{% if staged %} <div class="sub">Next step for each: add a test entry, test-boot it from the PXE menu, then activate.</div></div></div>
<div class="table-container"> {% for s in staged %}
<table class="data-table"> {% set q_test = ('Add a test entry for ' ~ s.name ~ ' to the PXE boot menu' ~ (' (replacing the one for ' ~ (test.version or '?') ~ ')' if test else '') ~ '? The menu is backed up first.') %}
<thead><tr><th>Version</th><th>Blancco kernel</th><th>Preferences</th><th>Size</th><th>Status</th><th>Staged</th><th class="text-end"></th></tr></thead> {% set q_act = ('Make Blancco ' ~ s.name ~ ' the live release? Every Blancco boot from now on gets it. The current files (' ~ current.version ~ ') are backed up first and can be rolled back.') %}
<tbody> <div class="ui-row">
{% for s in staged %} <details {% if s.status in ('blocked', 'failed') %}open{% endif %}>
<tr> <summary class="sgrid">
<td class="mono">{{ s.name }}{% if s.in_menu %} <span class="badge badge-warning">in boot menu</span>{% endif %}</td> <span class="vname"><span class="mono" style="font-weight: 600">{{ s.name }}</span>{% if s.in_menu %}{{ ui.chip('in boot menu', 'warn') }}{% endif %}</span>
{{ release_cells(s) }} <span class="tip c-meta" data-tip="kernel {{ s.kernel or '-' }}, preferences {{ prefs(s) }}, {{ mb(s.size) }}"><span class="trunc sub">Staged {{ s.staged | local_dt }}{% if s.source %} from {{ s.source }}{% endif %}</span></span>
<td> <span>
{% if s.status == 'ready' %}<span class="badge badge-success">Ready</span> {% if s.status == 'ready' and s.in_menu %}{{ ui.chip('Ready: testing', 'ok') }}
{% if s.merged_prefs %}<div class="small text-muted">merged preferences</div>{% endif %} {% elif s.status == 'ready' %}{{ ui.chip('Ready to test', 'ok') }}
{% elif s.status == 'blocked' %}<span class="badge badge-danger">Needs preferences merge</span> {% elif s.status == 'blocked' %}{{ ui.chip('Needs preferences merge', 'act') }}
{% elif s.status == 'failed' %}<span class="badge badge-danger">Failed</span> {% elif s.status == 'failed' %}{{ ui.chip('Failed', 'err') }}
{% if s.error %}<div class="small text-danger wrap">{{ s.error }}</div>{% endif %} {% else %}{{ ui.chip(s.status, 'neutral') }}{% endif %}
{% else %}<span class="badge badge-secondary">{{ s.status }}</span>{% endif %} </span>
</td> <span class="chev" aria-hidden="true">&#9662;</span>
<td class="small text-nowrap">{{ s.staged | local_dt }}<div class="text-muted">{{ s.source or '' }}</div></td> </summary>
<td class="text-end text-nowrap"> <div class="rbody">
{% if s.status == 'ready' %} {% if s.status == 'failed' and s.error %}<div class="errbox">{{ s.error }}</div>{% endif %}
{% if not s.in_menu %} <dl class="kv">
<form method="post" action="{{ url_for('blancco_update.test_add', version=s.name) }}" style="display:inline;" <dt>Blancco kernel</dt><dd class="mono">{{ s.kernel or '-' }}</dd>
onsubmit="return confirm({{ ('Add a test entry for ' ~ s.name ~ ' to the PXE boot menu' ~ (' (replacing the one for ' ~ (test.version or '?') ~ ')' if test else '') ~ '? The menu is backed up first.') | tojson | forceescape }});"> <dt>Preferences</dt><dd class="mono">{{ prefs(s) }}{% if s.merged_prefs %} <span class="sub">(merged preferences)</span>{% endif %}</dd>
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}"> <dt>Size</dt><dd>{{ mb(s.size) }}</dd>
<button type="submit" class="pxe-btn pxe-btn-secondary btn-row-action"><i class="bi bi-list-check"></i> Add test entry</button> <dt>Staged</dt><dd>{{ s.staged | local_dt }}{% if s.source %} from <span class="mono">{{ s.source }}</span>{% endif %}</dd>
</form> </dl>
{% endif %} {% if s.status == 'ready' %}
<form method="post" action="{{ url_for('blancco_update.activate', version=s.name) }}" style="display:inline;" <div class="sub">{% if s.in_menu %}The test entry boots this release. When a test boot works, activate it.{% else %}Add a test entry, test-boot it, then activate.{% endif %}</div>
onsubmit="return confirm({{ ('Make Blancco ' ~ s.name ~ ' the live release? Every Blancco boot from now on gets it. The current files (' ~ current.version ~ ') are backed up first and can be rolled back.') | tojson | forceescape }});">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="pxe-btn pxe-btn-primary btn-row-action"><i class="bi bi-check2-circle"></i> Activate</button>
</form>
{% endif %}
<form method="post" action="{{ url_for('blancco_update.delete_staged', version=s.name) }}" style="display:inline;"
onsubmit="return confirm({{ ('Delete staged ' ~ s.name ~ '?') | tojson | forceescape }});">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="pxe-btn pxe-btn-ghost btn-row-action" title="Delete"><i class="bi bi-trash"></i></button>
</form>
</td>
</tr>
{% if s.status == 'blocked' %}
<tr>
<td colspan="7" class="wrap">
<div class="alert alert-warning mb-2">{{ s.blocked }}</div>
<ol class="small mb-2">
<li><a href="{{ url_for('blancco_update.vendor_prefs', version=s.name) }}">Download the vendor preferences.xml of {{ s.name }}</a>.</li>
<li>Carry our settings across from <span class="mono">playbook/blancco-preferences.xml</span> (report share
<span class="mono">{{ server }}</span>/<span class="mono">blancco-reports</span>, <span class="mono">auto_backup</span>, WiFi,
screensaver, business location and the rest), keeping the new file's <span class="mono">&lt;version&gt;</span> block and any new settings.</li>
<li>Upload the merged file here; staging then finishes. Commit it to the repo as <span class="mono">playbook/blancco-preferences.xml</span>.</li>
</ol>
<form method="post" action="{{ url_for('blancco_update.merged_prefs', version=s.name) }}" enctype="multipart/form-data"
class="d-flex flex-wrap gap-2 align-items-end">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="form-field mb-0" style="min-width: min(20rem, 100%);">
<label class="form-label" for="prefs-{{ loop.index }}">Merged preferences.xml</label>
<input type="file" class="form-control" id="prefs-{{ loop.index }}" name="prefs_file" accept=".xml" required>
</div>
<button type="submit" class="pxe-btn pxe-btn-primary"><i class="bi bi-upload"></i> Upload and finish staging</button>
</form>
</td>
</tr>
{% endif %} {% endif %}
{% endfor %} {% if s.status == 'blocked' %}
</tbody> <div class="notice">{{ s.blocked }}</div>
</table> <ol class="steps">
<li><a href="{{ url_for('blancco_update.vendor_prefs', version=s.name) }}">Download the vendor preferences.xml of {{ s.name }}</a>.</li>
<li>Carry our settings across from <span class="mono">playbook/blancco-preferences.xml</span> (report share
<span class="mono">{{ server }}</span>/<span class="mono">blancco-reports</span>, <span class="mono">auto_backup</span>, WiFi,
screensaver, business location and the rest), keeping the new file's <span class="mono">&lt;version&gt;</span> block and any new settings.</li>
<li>Upload the merged file here; staging then finishes. Commit it to the repo as <span class="mono">playbook/blancco-preferences.xml</span>.</li>
</ol>
<form method="post" action="{{ url_for('blancco_update.merged_prefs', version=s.name) }}" enctype="multipart/form-data"
style="display: flex; flex-wrap: wrap; gap: 10px; align-items: flex-end; margin: 0">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div style="display: flex; flex-direction: column; gap: 4px; flex: 1 1 260px; min-width: 0">
<label for="prefs-{{ loop.index }}" style="font-weight: 600">Merged preferences.xml</label>
<input type="file" class="field" id="prefs-{{ loop.index }}" name="prefs_file" accept=".xml" required>
</div>
<button type="submit" class="ui-btn pri">Upload and finish staging</button>
</form>
{% endif %}
</div>
</details>
<div class="act">
{% if s.status == 'ready' and not s.in_menu %}
<form method="post" action="{{ url_for('blancco_update.test_add', version=s.name) }}" style="margin: 0"
onsubmit="return confirm({{ q_test | tojson | forceescape }});">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="ui-btn pri">Add test entry</button>
</form>
{% elif s.status == 'ready' %}
<form method="post" action="{{ url_for('blancco_update.activate', version=s.name) }}" style="margin: 0"
onsubmit="return confirm({{ q_act | tojson | forceescape }});">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="ui-btn pri">Activate</button>
</form>
{% endif %}
{% call ui.more('staged release ' ~ s.name) %}
{% if s.status == 'ready' and not s.in_menu %}
<form method="post" action="{{ url_for('blancco_update.activate', version=s.name) }}"
onsubmit="return confirm({{ q_act | tojson | forceescape }});">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit">Activate without a test boot</button>
</form>
<div class="sep"></div>
{% endif %}
<form method="post" action="{{ url_for('blancco_update.delete_staged', version=s.name) }}"
onsubmit="return confirm({{ ('Delete staged ' ~ s.name ~ '?') | tojson | forceescape }});">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="del">Delete staged release</button>
</form>
{% endcall %}
</div>
</div> </div>
{% else %} {% else %}
<p class="text-muted mb-0">Nothing staged.</p> <div class="hidden-bar">Nothing staged. Upload a release file below, then Stage it.</div>
{% endif %} {% endfor %}
</div> </section>
{# ---- Test entry ---------------------------------------------------------- #} {# ---- Test entry ---------------------------------------------------------- #}
<div class="section-card"> <section class="panel" aria-labelledby="test-h" style="padding: 18px 24px; display: flex; flex-direction: column; gap: 10px">
<div class="section-title"><i class="bi bi-list-check"></i> Test entry in the PXE boot menu</div> <div style="display: flex; flex-wrap: wrap; align-items: center; gap: 10px 16px">
{% if test %} <div style="flex: 1 1 300px; min-width: 0">
<p class="mb-2"> <h2 id="test-h" class="sec" style="font-size: 15px">Test entry in the PXE boot menu</h2>
The menu has <span class="mono">{{ test.item or 'a blancco_staged entry' }}</span>, booting the staged {% if test %}
<strong class="mono">{{ test.version or '?' }}</strong> from <span class="mono">/blancco/staged/{{ test.version }}/</span>. <div class="sub" style="overflow-wrap: anywhere">The menu has <span class="mono">{{ test.item or 'a blancco_staged entry' }}</span>, booting the staged
</p> <strong class="mono">{{ test.version or '?' }}</strong> from <span class="mono">/blancco/staged/{{ test.version }}/</span>.</div>
{% if test.efi is false %} {% else %}
<div class="alert alert-warning">The entry's UEFI branch is off: when it was added, the UEFI initrd did not read <div class="sub">No test entry. Add test entry on a staged release puts one under the normal Blancco entry.</div>
{% endif %}
</div>
{% if test %}
<form method="post" action="{{ url_for('blancco_update.test_remove') }}" style="margin: 0"
onsubmit="return confirm('Remove the test entry from the PXE boot menu? The menu is backed up first.');">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="ui-btn">Remove test entry</button>
</form>
{% endif %}
</div>
{% if test and test.efi is false %}
<div class="notice">The entry's UEFI branch is off: when it was added, the UEFI initrd did not read
<span class="mono">blancco.base=</span>, so a UEFI test boot would have run the LIVE release. A UEFI boot of the entry <span class="mono">blancco.base=</span>, so a UEFI test boot would have run the LIVE release. A UEFI boot of the entry
says so and returns to the menu. After rebuilding the initrd, add the test entry again.</div> says so and returns to the menu. After rebuilding the initrd, add the test entry again.</div>
{% endif %} {% endif %}
<form method="post" action="{{ url_for('blancco_update.test_remove') }}" {% if initrd.state == 'no' %}
onsubmit="return confirm('Remove the test entry from the PXE boot menu? The menu is backed up first.');"> <div class="notice bad"><span>The UEFI initrd <span class="mono">{{ initrd.file or 'kexec-initrd' }}</span> predates <span class="mono">blancco.base=</span>: booted, it would run the LIVE release, so a test
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}"> entry added now has its UEFI branch off (legacy BIOS still works). Rebuild the initrd with the current
<button type="submit" class="pxe-btn pxe-btn-secondary"><i class="bi bi-x-circle"></i> Remove test entry</button> <span class="mono">playbook/blancco-init.sh</span>, then add the entry again (the boot screen shows "release under test" when it works).</span></div>
</form>
{% else %}
<p class="text-muted">No test entry. <strong>Add test entry</strong> on a staged release puts one under the normal Blancco entry.</p>
{% endif %} {% endif %}
<ul class="small mt-3 mb-0"> <details>
<li><strong>Legacy BIOS:</strong> the entry boots Blancco's own kernel from the staged folder <summary class="disc"><span class="chev" aria-hidden="true">&#9662;</span>How the test entry boots
(<span class="mono">archiso_http_srv</span> pointed at it). Works with any initrd.</li> {% if initrd.state == 'yes' %}{{ ui.chip('UEFI supported', 'ok') }}{% elif initrd.state == 'no' %}{{ ui.chip('UEFI not supported', 'err') }}{% else %}{{ ui.chip('UEFI unknown', 'off') }}{% endif %}</summary>
<li><strong>UEFI:</strong> the entry copies the live <span class="mono">:blancco_efi</span> kernel line (Ubuntu kernel, <ul class="sub" style="margin: 4px 0 0; padding-left: 18px; line-height: 1.6; display: flex; flex-direction: column; gap: 4px">
Secure Boot as the normal entry) and adds <span class="mono">blancco.base=/blancco/staged/&lt;version&gt;</span>, which <li><strong>Legacy BIOS:</strong> the entry boots Blancco's own kernel from the staged folder
the init in <span class="mono">{{ initrd.file or 'kexec-initrd' }}</span> must understand: (<span class="mono">archiso_http_srv</span> pointed at it). Works with any initrd.</li>
{% if initrd.state == 'yes' %}<span class="badge badge-success">supported</span> <li><strong>UEFI:</strong> the entry copies the live <span class="mono">:blancco_efi</span> kernel line (Ubuntu kernel,
{% elif initrd.state == 'no' %}<span class="badge badge-danger">not supported</span> Secure Boot as the normal entry) and adds <span class="mono">blancco.base=/blancco/staged/&lt;version&gt;</span>, which
<div class="text-danger">This initrd predates <span class="mono">blancco.base=</span>: booted, it would run the LIVE release, so a test the init in <span class="mono">{{ initrd.file or 'kexec-initrd' }}</span> must understand:
entry added now has its UEFI branch off (legacy BIOS still works). Rebuild the initrd with the current {% if initrd.state == 'yes' %}supported.{% elif initrd.state == 'no' %}not supported (see above).{% else %}unknown, {{ initrd.detail }}.{% endif %}</li>
<span class="mono">playbook/blancco-init.sh</span>, then add the entry again (the boot screen shows "release under test" <li>Add, replace and remove edit only the test entry's lines; each edit saves
when it works).</div> <span class="mono">GetPxeScript.aspx.bak-&lt;time&gt;-pre-blancco-staged*</span> next to the menu first. Activating the
{% else %}<span class="badge badge-secondary">unknown</span> <span class="text-muted">{{ initrd.detail }}</span>{% endif %}</li> version under test removes its entry.</li>
<li>Add, replace and remove edit only the test entry's lines; each edit saves </ul>
<span class="mono">GetPxeScript.aspx.bak-&lt;time&gt;-pre-blancco-staged*</span> next to the menu first. Activating the </details>
version under test removes its entry.</li> </section>
</ul>
</div>
{# ---- Backups ------------------------------------------------------------- #} {# ---- Release files and upload -------------------------------------------- #}
<div class="section-card"> <section class="panel ui-list" id="upload" aria-labelledby="up-h">
<div class="section-title"><i class="bi bi-clock-history"></i> Backups</div> <div class="sec-h"><div><h2 id="up-h" class="sec">Release files</h2>
<p class="small text-muted mb-2">Folders <span class="mono">{{ current.path }}/backup-*</span>. A rollback first saves what is live, so it can be undone the same way; <div class="sub">Upload the vendor ISO or USB image (about 1 GB), then Stage it.</div></div></div>
the restored backup's folder is then released, because its files are the live files.</p> <form id="blanccoUpload" method="post" action="{{ url_for('blancco_update.upload') }}" enctype="multipart/form-data" class="upl">
{% if backups %} <input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="table-container"> <div class="fld">
<table class="data-table"> <label for="blanccoFile">Vendor ISO or USB image</label>
<thead><tr><th>Backup</th><th>Version</th><th>Blancco kernel</th><th>Preferences</th><th>Size</th><th>Saved</th><th class="text-end"></th></tr></thead> <input type="file" class="field" id="blanccoFile" name="release_file" accept=".iso,.img,.gz" required aria-describedby="blanccoFileHelp">
<tbody> </div>
{% for b in backups %} <button type="submit" class="ui-btn pri">Upload</button>
<tr> <span id="blanccoFileHelp" class="sub" style="flex-basis: 100%; overflow-wrap: anywhere">Streamed to <span class="mono">{{ upload_dir }}</span>.</span>
<td class="mono">{{ b.name }}{% if b.reason %}<div class="small text-muted">{{ b.reason }}</div>{% endif %}</td> </form>
<td class="mono">{{ b.version }}</td> <div id="blanccoUploadProgress" class="prog" style="display: none;" role="status">
{{ release_cells(b) }} <span id="blanccoUploadText">Uploading...</span>
<td class="mono text-nowrap">{{ b.created | local_dt if b.created is string else b.created | timestamp_fmt }}</td> <div class="bar"><div id="blanccoUploadBar" style="width: 0%;"></div></div>
<td class="text-end text-nowrap"> </div>
{% if b.complete %} {% for s in sources %}
<form method="post" action="{{ url_for('blancco_update.rollback', name=b.name) }}" style="display:inline;" <div class="ui-row">
onsubmit="return confirm({{ ('Put ' ~ b.name ~ ' back live? What is live now (' ~ current.version ~ ') is backed up first.') | tojson | forceescape }});"> <div class="fgrid">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}"> <span class="tip" data-tip="{{ s.name }}"><span class="trunc mono">{{ s.name }}</span></span>
<button type="submit" class="pxe-btn pxe-btn-secondary btn-row-action"><i class="bi bi-arrow-counterclockwise"></i> Roll back</button> <span class="sub c-folder">{{ s.key }}</span>
</form> <span class="sub">{{ mb(s.size) }}</span>
{% else %} <span class="sub mono c-mod">{{ s.modified | timestamp_fmt }}</span>
<span class="badge badge-secondary" title="Missing {{ b.missing | join(', ') }}">Incomplete</span> </div>
{% endif %} <div class="act" style="top: 6px">
<form method="post" action="{{ url_for('blancco_update.delete_backup', name=b.name) }}" style="display:inline;" <form method="post" action="{{ url_for('blancco_update.stage') }}" style="margin: 0">
onsubmit="return confirm({{ ('Delete ' ~ b.name ~ '? It cannot be rolled back to afterwards.') | tojson | forceescape }});"> <input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="source" value="{{ s.key }}:{{ s.name }}">
<button type="submit" class="pxe-btn pxe-btn-ghost btn-row-action" title="Delete"><i class="bi bi-trash"></i></button> <button type="submit" class="ui-btn" {% if job_running %}disabled{% endif %}>Stage</button>
</form> </form>
</td> {% if s.deletable %}
</tr> {% call ui.more(s.name) %}
{% endfor %} <form method="post" action="{{ url_for('blancco_update.delete_upload', name=s.name) }}"
</tbody> onsubmit="return confirm({{ ('Delete ' ~ s.name ~ '?') | tojson | forceescape }});">
</table> <input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="del">Delete file</button>
</form>
{% endcall %}
{% endif %}
</div>
</div> </div>
<div class="form-text">Each backup holds one release, about 1 GB. Taking one is instant (hard links of the live files before they are replaced).</div>
{% else %} {% else %}
<p class="text-muted mb-0">No backups yet. Activating a release saves the one it replaces here.</p> <div class="hidden-bar">No release files yet. Upload the vendor ISO or USB image above.</div>
{% endif %} {% endfor %}
</div> <div class="hidden-bar" style="overflow-wrap: anywhere"><span>Files copied in by hand are listed too, from
{% for key, folder in source_dirs %}<span class="mono">{{ folder }}</span>{{ ', ' if not loop.last }}{% endfor %}
(<span class="mono">.iso</span>, <span class="mono">.img</span>, <span class="mono">.img.gz</span>, <span class="mono">.iso.gz</span>).</span></div>
</section>
{# ---- Backups (folded) ----------------------------------------------------- #}
<section class="panel ui-list" aria-labelledby="bk-h">
<details>
<summary class="sec-h">
<div><h2 id="bk-h" class="sec" style="font-size: 15px">Backups</h2>
<div class="sub">Activating a release saves the one it replaces here. A rollback first saves what is live.</div></div>
<span style="display: flex; align-items: center; gap: 10px">{{ ui.chip(backups | length ~ ' backup' ~ ('s' if backups | length != 1), 'neutral') }}<span class="chev" aria-hidden="true">&#9662;</span></span>
</summary>
{% for b in backups %}
<div class="ui-row">
<details>
<summary class="bgrid">
<span class="tip" data-tip="{{ b.name }}"><span class="trunc mono">{{ b.name }}</span></span>
<span class="mono c-ver">{{ b.version }}</span>
<span class="sub mono c-saved">{{ b.created | local_dt if b.created is string else b.created | timestamp_fmt }}</span>
<span class="chev" aria-hidden="true">&#9662;</span>
</summary>
<div class="rbody">
{% if b.reason %}<div>{{ b.reason }}</div>{% endif %}
{% if not b.complete %}<div class="notice bad">Incomplete, cannot be rolled back to: missing <span class="mono">{{ b.missing | join(', ') }}</span></div>{% endif %}
<dl class="kv">
<dt>Blancco kernel</dt><dd class="mono">{{ b.kernel or '-' }}</dd>
<dt>Preferences</dt><dd class="mono">{{ prefs(b) }}</dd>
<dt>Size</dt><dd>{{ mb(b.size) }}</dd>
</dl>
</div>
</details>
<div class="act">
{% if b.complete %}
<form method="post" action="{{ url_for('blancco_update.rollback', name=b.name) }}" style="margin: 0"
onsubmit="return confirm({{ ('Put ' ~ b.name ~ ' back live? What is live now (' ~ current.version ~ ') is backed up first.') | tojson | forceescape }});">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="ui-btn">Roll back</button>
</form>
{% else %}{{ ui.chip('Incomplete', 'off') }}{% endif %}
{% call ui.more(b.name) %}
<form method="post" action="{{ url_for('blancco_update.delete_backup', name=b.name) }}"
onsubmit="return confirm({{ ('Delete ' ~ b.name ~ '? It cannot be rolled back to afterwards.') | tojson | forceescape }});">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="del">Delete backup</button>
</form>
{% endcall %}
</div>
</div>
{% else %}
<div class="hidden-bar">No backups yet. Activating a release saves the one it replaces here.</div>
{% endfor %}
<div class="hidden-bar" style="overflow-wrap: anywhere"><span>Folders <span class="mono">{{ current.path }}/backup-*</span>, about 1 GB each. Taking one is instant
(hard links of the live files before they are replaced); the restored backup's folder is released after a rollback, because its files are the live files.</span></div>
</details>
</section>
{# ---- How it works -------------------------------------------------------- #} {# ---- How it works -------------------------------------------------------- #}
<div class="pxe-card"> <details>
<h3 class="pxe-card-title"><i class="bi bi-info-circle"></i> What a release changes</h3> <summary class="disc"><span class="chev" aria-hidden="true">&#9662;</span>What a release changes</summary>
<p class="small mb-2"> <div class="sub" style="max-width: 900px; line-height: 1.6; display: flex; flex-direction: column; gap: 8px; margin-top: 4px; overflow-wrap: anywhere">
Replaced in <span class="mono">{{ current.path }}</span>: <span class="mono">arch/x86_64/airootfs.sfs</span>, <p style="margin: 0">Installs a new Blancco Drive Eraser release from the vendor ISO (or the vendor's USB image, <span class="mono">.img</span>
<span class="mono">vmlinuz-bde-linux</span>, <span class="mono">initramfs-bde-linux.img</span> (copytoram network patch), or <span class="mono">.img.gz</span>). Staging extracts the release next to the live one, applies the same changes as
<span class="mono">intel-ucode.img</span>, <span class="mono">amd-ucode.img</span>, <span class="mono">config.img</span> <span class="mono">scripts/prepare-boot-tools.sh</span> (our preferences written into <span class="mono">config.img</span>,
(vendor cpio with our preferences written in at the same size), <span class="mono">preferences.xml</span>, the initramfs network patch), and a test entry in the PXE boot menu boots the staged copy. Only Activate
<span class="mono">config-clean.xml</span> (the licence container from <span class="mono">config.img</span>) and, when the changes what the normal Blancco entry boots, and the files it replaces are kept as a backup you can roll back to.</p>
ISO has one, <span class="mono">blancco-shim.efi</span>. Never touched: <span class="mono">vmlinuz-ubuntu*</span>, <p style="margin: 0">Replaced in <span class="mono">{{ current.path }}</span>: <span class="mono">arch/x86_64/airootfs.sfs</span>,
<span class="mono">kexec-initrd*</span>, <span class="mono">kmod*.tar.gz</span>, <span class="mono">grubx64.efi</span>, <span class="mono">vmlinuz-bde-linux</span>, <span class="mono">initramfs-bde-linux.img</span> (copytoram network patch),
<span class="mono">shimx64.efi</span>, <span class="mono">blancco-progress.py</span>. <span class="mono">intel-ucode.img</span>, <span class="mono">amd-ucode.img</span>, <span class="mono">config.img</span>
</p> (vendor cpio with our preferences written in at the same size), <span class="mono">preferences.xml</span>,
<p class="small mb-0"> <span class="mono">config-clean.xml</span> (the licence container from <span class="mono">config.img</span>) and, when the
Our preferences come from the live <span class="mono">preferences.xml</span> (the playbook deploys ISO has one, <span class="mono">blancco-shim.efi</span>. Never touched: <span class="mono">vmlinuz-ubuntu*</span>,
<span class="mono">playbook/blancco-preferences.xml</span> there). They are a merge onto one vendor release's file, <span class="mono">kexec-initrd*</span>, <span class="mono">kmod*.tar.gz</span>, <span class="mono">grubx64.efi</span>,
so when a release's <span class="mono">&lt;version&gt;</span> differs, staging stops until a merged file is uploaded. <span class="mono">shimx64.efi</span>, <span class="mono">blancco-progress.py</span>.</p>
After activating such a release, commit the merged file to the repo, or the next playbook run puts the old one back. <p style="margin: 0">Our preferences come from the live <span class="mono">preferences.xml</span> (the playbook deploys
</p> <span class="mono">playbook/blancco-preferences.xml</span> there). They are a merge onto one vendor release's file,
<p class="small mb-0 mt-2"> so when a release's <span class="mono">&lt;version&gt;</span> differs, staging stops until a merged file is uploaded.
A playbook run also copies <span class="mono">boot-tools/blancco/*</span> from the USB over the live files. After activating After activating such a release, commit the merged file to the repo, or the next playbook run puts the old one back.</p>
a release here, refresh that copy (<span class="mono">scripts/prepare-boot-tools.sh</span> with the new ISO), or a playbook run <p style="margin: 0">A playbook run also copies <span class="mono">boot-tools/blancco/*</span> from the USB over the live files. After activating
puts the old release back. a release here, refresh that copy (<span class="mono">scripts/prepare-boot-tools.sh</span> with the new ISO), or a playbook run
</p> puts the old release back.</p>
</div>
</details>
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -1,5 +1,6 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}BPRT Sync - PXE Server Manager{% endblock %} {% import '_ui.html' as ui %}
{% block title %}BPRT sync - PXE Manager{% endblock %}
{% macro size(n) -%} {% macro size(n) -%}
{%- if n is none -%}-{%- elif n >= 1000000000 -%}{{ '%.2f' | format(n / 1000000000) }} GB{%- elif n >= 1000000 -%}{{ '%.0f' | format(n / 1000000) }} MB{%- elif n >= 1000 -%}{{ '%.0f' | format(n / 1000) }} KB{%- else -%}{{ n }} B{%- endif -%} {%- if n is none -%}-{%- elif n >= 1000000000 -%}{{ '%.2f' | format(n / 1000000000) }} GB{%- elif n >= 1000000 -%}{{ '%.0f' | format(n / 1000000) }} MB{%- elif n >= 1000 -%}{{ '%.0f' | format(n / 1000) }} KB{%- else -%}{{ n }} B{%- endif -%}
@@ -10,484 +11,593 @@
{%- for part in n.split('_') %}{{ part }}{% if not loop.last %}_<wbr>{% endif %}{% endfor -%} {%- for part in n.split('_') %}{{ part }}{% if not loop.last %}_<wbr>{% endif %}{% endfor -%}
{%- endmacro %} {%- endmacro %}
{% macro val_badge(v, lg=false) -%} {% macro val_chip(v) -%}
{% set cls = 'badge badge-lg' if lg else 'badge' %} {% if not v %}{{ ui.chip('Not validated', 'off') }}
{% if not v %}<span class="{{ cls }} badge-secondary">Not validated</span> {% elif v.stale %}{{ ui.chip('Changed since validation', 'warn') }}
{% elif v.stale %}<span class="{{ cls }} badge-warning" title="{{ v.stale }}">Changed since validation</span> {% elif v.state == 'valid' %}{{ ui.chip('Valid', 'ok') }}
{% elif v.state == 'valid' %}<span class="{{ cls }} badge-success">Valid</span> {% else %}{{ ui.chip(v.errors | length ~ ' error' ~ ('s' if v.errors | length != 1), 'err') }}{% endif %}
{% else %}<span class="{{ cls }} badge-danger">{{ v.errors | length }} error{{ 's' if v.errors | length != 1 }}</span>{% endif %}
{%- endmacro %} {%- endmacro %}
{% macro job_panel(job) -%} {% macro job_panel(job) -%}
{% if job.running %} {% if job.running %}
<div class="section-card" id="bprtJob" data-running="1"> <div class="panel strip" id="bprtJob" data-running="1" role="status">
<div class="section-title"><span class="spinner-border spinner-border-sm"></span> <span class="pulse" aria-hidden="true"></span>
{{ {'manifest': 'Hashing the live BPRT', 'validate': 'Validating', 'activate': 'Activating', 'unzip': 'Unpacking the zip'}.get(job.kind, job.kind) }} <span><strong>{{ {'manifest': 'Hashing the live BPRT', 'validate': 'Validating', 'activate': 'Activating', 'unzip': 'Unpacking the zip'}.get(job.kind, job.kind) }}</strong>
{% if job.arg %}<span class="mono small">{{ job.arg }}</span>{% endif %}</div> {% if job.arg %}<span class="mono">{{ job.arg }}</span>{% endif %}</span>
<div class="small mb-1"><span id="bprtJobStep">{{ job.step or 'Starting' }}</span> <span id="bprtJobCurrent" class="mono text-muted">{{ job.current or '' }}</span></div> <span id="bprtJobStep">{{ job.step or 'Starting' }}</span>
<div class="progress" style="height: 0.5rem;"><div id="bprtJobBar" class="progress-bar" style="width: {{ job.percent or 0 }}%;"></div></div> <span id="bprtJobCurrent" class="mono sub" style="min-width: 0; overflow-wrap: anywhere">{{ job.current or '' }}</span>
<div class="small text-muted mt-1" id="bprtJobPct">{% if job.percent is not none %}{{ job.percent }}%{% endif %}</div> <div class="bar" aria-hidden="true"><div id="bprtJobBar" style="width: {{ job.percent or 0 }}%"></div></div>
<span class="sub" id="bprtJobPct">{% if job.percent is not none %}{{ job.percent }}%{% endif %}</span>
</div> </div>
{% elif job.state in ('error', 'interrupted') %} {% elif job.state in ('error', 'interrupted') %}
<div class="alert alert-danger"> <div class="errbox">
<strong>The last BPRT {{ job.kind }} job {{ 'failed' if job.state == 'error' else 'stopped before it finished' }}</strong> <strong>The last BPRT {{ job.kind }} job {{ 'failed' if job.state == 'error' else 'stopped before it finished' }}</strong>
{% if job.arg %}(<span class="mono">{{ job.arg }}</span>){% endif %}{% if job.finished %}, {{ job.finished | local_dt }}{% endif %}{% if job.error %}: {{ job.error }}{% endif %} {% if job.arg %}(<span class="mono">{{ job.arg }}</span>){% endif %}{% if job.finished %}, {{ job.finished | local_dt }}{% endif %}{% if job.error %}: {{ job.error }}{% endif %}
{% if job.kind == 'activate' %}<div class="small mt-1">The live BPRT is only replaced by the last step (two renames), so it is either the old folder or the new one, never a half-built one.</div>{% endif %} {% if job.kind == 'activate' %}<div style="margin-top: 4px">The live BPRT is only replaced by the last step (two renames), so it is either the old folder or the new one, never a half-built one.</div>{% endif %}
</div> </div>
{% elif job.state == 'done' and job.result %} {% elif job.state == 'done' and job.result %}
<div class="small text-muted mb-3"><i class="bi bi-check2-circle"></i> Last job: {{ job.kind }}{% if job.arg %} <span class="mono">{{ job.arg }}</span>{% endif %}, {{ job.result }}{% if job.finished %} ({{ job.finished | local_dt }}){% endif %}.</div> <div class="sub">Last job: {{ job.kind }}{% if job.arg %} <span class="mono">{{ job.arg }}</span>{% endif %}, {{ job.result }}{% if job.finished %} ({{ job.finished | local_dt }}){% endif %}.</div>
{% endif %} {% endif %}
{%- endmacro %} {%- endmacro %}
{% macro imaging_alert(imaging) -%} {% macro imaging_alert(imaging) -%}
{% if imaging %} {% if imaging %}
<div class="alert alert-warning"> <div class="notice"><span>
<strong><i class="bi bi-exclamation-triangle"></i> {{ imaging | length }} PC{{ 's are' if imaging | length != 1 else ' is' }} imaging now.</strong> <strong>{{ imaging | length }} PC{{ 's are' if imaging | length != 1 else ' is' }} imaging now.</strong>
PESetup copies BPRT to each PC while it images; a PC copying during the swap can end up with a mix of old and new files. PESetup copies BPRT to each PC while it images; a PC copying during the swap can end up with a mix of old and new files.
Activate or roll back when these have finished: Activate or roll back when these have finished:
{% for s in imaging[:8] %}<span class="mono">{{ s.hostname or s.id }}</span>{% if s.stage %} ({{ s.stage }}){% endif %}{% if not loop.last %}, {% endif %}{% endfor %}{% if imaging | length > 8 %} and {{ imaging | length - 8 }} more{% endif %}. {% for s in imaging[:8] %}<span class="mono">{{ s.hostname or s.id }}</span>{% if s.stage %} ({{ s.stage }}){% endif %}{% if not loop.last %}, {% endif %}{% endfor %}{% if imaging | length > 8 %} and {{ imaging | length - 8 }} more{% endif %}.
<a href="{{ url_for('imaging_dashboard') }}">Imaging progress</a> <a href="{{ url_for('imaging_dashboard') }}">Imaging progress</a></span>
</div> </div>
{% endif %} {% endif %}
{%- endmacro %} {%- endmacro %}
{% block extra_head %} {% block extra_head %}
<style> <style>
.bprt td.wrap { white-space: normal; word-break: break-word; min-width: 8rem; } .sec-h { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 8px 16px; padding: 18px 24px 12px; }
.bprt .file-list { columns: 2 22rem; column-gap: 2rem; margin: 0.25rem 0 0; padding-left: 1.1rem; } .sec-h .sub { margin-top: 2px; }
.bprt .file-list li { break-inside: avoid; } summary.sec-h { flex-wrap: nowrap; align-items: center; }
.bprt details > summary { cursor: pointer; } .state-line { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; margin-top: 6px; }
.bprt .fname { word-break: normal; overflow-wrap: normal; } .strip { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; padding: 12px 20px; font-size: 13px; }
.bprt .msg-list { margin: 0; padding-left: 1.1rem; } .pulse { width: 10px; height: 10px; border-radius: 5px; background: var(--run); flex: none; animation: pulse 1.4s ease-in-out infinite; }
.bprt .msg-list li { margin-bottom: 0.25rem; } @keyframes pulse { 50% { opacity: .35; } }
.bprt pre.cmd { white-space: pre-wrap; word-break: break-all; margin: 0.25rem 0 0.75rem; } @media (prefers-reduced-motion: reduce) { .pulse { animation: none; } }
.bar { flex: 1 1 160px; height: 6px; border-radius: 3px; background: var(--track-empty); overflow: hidden; min-width: 60px; max-width: 360px; }
.bar > div { height: 100%; width: 0; background: var(--run); }
.bar > div.bg-danger { background: var(--fail); }
.pad { padding: 0 24px 16px; display: flex; flex-direction: column; gap: 10px; font-size: 13px; }
.facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(150px, 100%), 1fr)); gap: 12px 20px; margin: 0; }
.facts dt { font-size: 12px; color: var(--muted); font-weight: 400; }
.facts dd { margin: 2px 0 0; overflow-wrap: anywhere; font-size: 14px; }
.sub-h { padding: 12px 24px 6px; border-top: 1px solid var(--line); font-size: 13px; font-weight: 600; color: var(--text-2); }
.pgrid { display: grid; grid-template-columns: 170px minmax(0, 1fr) 90px 70px; align-items: center; gap: 0 16px; padding: 9px 24px; border-top: 1px solid var(--line); font-size: 13px; }
.ogrid { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 0 16px; padding: 9px 24px; border-top: 1px solid var(--line); font-size: 13px; }
.ogrid .chips { display: flex; gap: 6px; flex-wrap: wrap; justify-content: flex-end; }
.bgrid { display: grid; grid-template-columns: minmax(0, 1fr) 150px 130px 170px 14px; align-items: center; gap: 0 16px;
padding: 12px 190px 12px 24px; font-size: 14px; min-height: 24px; }
.xgrid { display: grid; grid-template-columns: 200px minmax(0, 1fr) 180px 14px; align-items: center; gap: 0 16px;
padding: 12px 190px 12px 24px; font-size: 14px; min-height: 24px; }
.hgrid { display: grid; grid-template-columns: 170px minmax(0, 1fr) 14px; align-items: center; gap: 0 16px; padding: 12px 24px; font-size: 14px; }
.zgrid { display: grid; grid-template-columns: minmax(0, 1fr) 90px; align-items: center; gap: 0 16px; padding: 12px 190px 12px 24px; font-size: 14px; min-height: 36px; }
.rbody { padding: 2px 24px 16px; font-size: 13px; display: flex; flex-direction: column; gap: 8px; line-height: 1.55; }
.rbody .mono { overflow-wrap: anywhere; }
.msg-list { margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 4px; }
.file-list { columns: 2 22rem; column-gap: 2rem; margin: 4px 0 0; padding-left: 18px; font-size: 12px; }
.file-list li { break-inside: avoid; overflow-wrap: anywhere; }
.fname { word-break: normal; overflow-wrap: anywhere; }
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(150px, 100%), 1fr)); gap: 12px; }
.stat { padding: 14px 18px; display: flex; flex-direction: column; gap: 2px; }
.stat .n { font-size: 24px; font-weight: 600; }
.ppk { display: grid; grid-template-columns: 170px minmax(0, 1fr) auto minmax(0, 1fr); gap: 4px 12px; align-items: baseline; padding: 9px 24px; border-top: 1px solid var(--line); font-size: 13px; }
.ppk .old { color: var(--del-text); } .ppk .new { color: var(--add-text); }
pre.cmd { white-space: pre-wrap; word-break: break-all; margin: 8px 0 4px; padding: 10px 12px; border-radius: 8px; background: var(--surface-2); border: 1px solid var(--line); font-size: 12px; color: var(--text); }
.steps { margin: 0; padding-left: 20px; display: flex; flex-direction: column; gap: 10px; font-size: 13px; line-height: 1.6; }
.upl { display: flex; flex-wrap: wrap; gap: 10px 14px; align-items: flex-end; padding: 0 24px 16px; }
.upl .fld { display: flex; flex-direction: column; gap: 4px; flex: 1 1 280px; min-width: 0; }
.upl label { font-size: 13px; font-weight: 600; }
input[type=file].field { padding: 5px 8px; height: auto; min-height: 36px; width: 100%; }
.prog { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; padding: 0 24px 16px; font-size: 13px; }
.foot { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px; }
.foot form { margin: 0; }
@container (max-width: 900px) {
.bgrid { grid-template-columns: minmax(0, 1fr) 150px 14px; }
.bgrid .c-from, .bgrid .c-size { display: none; }
.xgrid { grid-template-columns: 170px minmax(0, 1fr) 160px 14px; }
}
@container (max-width: 560px) {
.bgrid .c-from, .bgrid .c-size { display: block; }
.bgrid, .xgrid { grid-template-columns: minmax(0, 1fr) auto 14px; padding: 12px 16px; gap: 4px 10px; }
.bgrid .c-from, .bgrid .c-size, .xgrid .c-pk { grid-column: 1 / -1; }
.bgrid .c-st, .xgrid .c-st { grid-column: 2; grid-row: 1; }
.bgrid .chev, .xgrid .chev { grid-column: 3; grid-row: 1; }
.hgrid { grid-template-columns: minmax(0, 1fr) 14px; padding: 12px 16px; gap: 2px 10px; }
.hgrid .when { grid-column: 1; }
.zgrid { padding: 12px 16px; }
.pgrid { grid-template-columns: minmax(0, 1fr) auto; padding: 9px 16px; gap: 2px 10px; }
.pgrid .c-file { grid-column: 1 / -1; grid-row: 2; }
.pgrid .c-size { display: none; }
.ppk { grid-template-columns: minmax(0, 1fr); padding: 9px 16px; }
.ppk .to { display: none; }
.ogrid { padding: 9px 16px; }
.ui-row > .act { position: static; padding: 0 16px 10px; display: flex; justify-content: flex-end; }
.rbody { padding: 2px 16px 14px; }
.sec-h, .pad, .upl, .prog, .sub-h { padding-left: 16px; padding-right: 16px; }
}
</style> </style>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="bprt">
{% if detail %} {% if detail %}
{# ======================= one incoming batch ============================== #} {# ======================= one incoming batch ============================== #}
{% set b = detail %} {% set b = detail %}
<div class="page-header"> <header class="ui-head">
<h1><i class="bi bi-inboxes"></i> Incoming batch <span class="mono">{{ b.name }}</span></h1> <div>
<div class="header-actions"> <div class="eyebrow-t"><a href="{{ url_for('bprt_sync.page') }}">BPRT sync</a></div>
{{ val_badge(res, lg=true) }} <h1>Incoming batch <span class="mono" style="font-size: 22px; overflow-wrap: anywhere">{{ b.name }}</span></h1>
<a href="{{ url_for('bprt_sync.page') }}" class="pxe-btn pxe-btn-ghost"><i class="bi bi-arrow-left"></i> BPRT Sync</a> <div class="state-line">{{ val_chip(res) }}
{% if res %}<span class="sub">Validated {{ res.validated | local_dt }}</span>{% endif %}</div>
</div> </div>
</div> <div class="actions"><a href="{{ url_for('bprt_sync.page') }}" class="ui-btn">Back to BPRT sync</a></div>
</header>
<p class="text-muted"> <div class="ui-stack">
{% if b.sync %}From <strong>{{ b.sync.computer or 'unknown PC' }}</strong>{% if b.sync.user %} ({{ b.sync.user }}){% endif %}{% if b.sync.created %}, {{ b.sync.created | local_dt }}{% endif %}. <section class="panel" aria-labelledby="src-h" style="padding: 16px 24px; display: flex; flex-direction: column; gap: 10px">
{% if b.sync.source %}Source <span class="mono">{{ b.sync.source }}</span>.{% endif %} <h2 id="src-h" class="vh">Where this batch came from</h2>
{{ b.sync.listed }} vendor files listed, {{ b.files }} uploaded ({{ size(b.bytes) }}).{% endif %} <dl class="facts">
Folder <span class="mono">{{ incoming }}/{{ b.name }}</span>. {% if b.sync %}
</p> <div><dt>From</dt><dd>{{ b.sync.computer or 'unknown PC' }}{% if b.sync.user %} ({{ b.sync.user }}){% endif %}</dd></div>
{% if b.sync_error %}<div class="alert alert-danger">{{ b.sync_error }}</div>{% endif %} {% if b.sync.created %}<div><dt>Made</dt><dd>{{ b.sync.created | local_dt }}</dd></div>{% endif %}
<div><dt>Files</dt><dd>{{ b.sync.listed }} listed, {{ b.files }} uploaded ({{ size(b.bytes) }})</dd></div>
{% endif %}
</dl>
<div class="sub" style="overflow-wrap: anywhere">Folder <span class="mono">{{ incoming }}/{{ b.name }}</span>{% if b.sync and b.sync.source %}. Source on the PC <span class="mono">{{ b.sync.source }}</span>{% endif %}.</div>
{% if b.sync_error %}<div class="errbox">{{ b.sync_error }}</div>{% endif %}
</section>
{{ job_panel(job) }} {{ job_panel(job) }}
{% if not res %} {% if not res %}
<div class="section-card"> <section class="panel" aria-labelledby="val-h" style="padding: 18px 24px; display: flex; flex-direction: column; gap: 12px">
<div class="section-title"><i class="bi bi-clipboard-check"></i> Validate</div> <h2 id="val-h" class="sec">Validate</h2>
<p>Validation hashes every uploaded file against <span class="mono">sync.json</span>, checks that every other listed file is <p class="sub" style="margin: 0; max-width: 820px">Validation hashes every uploaded file against <span class="mono">sync.json</span>, checks that every other listed file is
unchanged in the live BPRT, applies the vendor's package rules and works out what activating the batch would change. unchanged in the live BPRT, applies the vendor's package rules and works out what activating the batch would change.
Nothing on the share changes.</p> Nothing on the share changes.</p>
<form method="post" action="{{ url_for('bprt_sync.validate', name=b.name) }}"> <div class="foot"><form method="post" action="{{ url_for('bprt_sync.validate', name=b.name) }}" style="margin: 0">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="pxe-btn pxe-btn-primary" {% if job.running %}disabled{% endif %}><i class="bi bi-clipboard-check"></i> Validate</button> <button type="submit" class="ui-btn pri" {% if job.running %}disabled{% endif %}>Validate</button>
</form> </form>
</div> <form method="post" action="{{ url_for('bprt_sync.delete', name=b.name) }}" style="margin: 0"
onsubmit="return confirm('Delete incoming batch {{ b.name }} and its uploaded files? The live BPRT is not touched.');">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="ui-btn danger" {% if job.running and job.arg == b.name %}disabled{% endif %}>Delete this batch</button>
</form></div>
</section>
{% else %} {% else %}
{% if res.errors %} {% if res.errors %}
<div class="section-card"> <section class="panel" aria-labelledby="err-h" style="padding: 18px 24px; display: flex; flex-direction: column; gap: 10px">
<div class="section-title text-danger"><i class="bi bi-x-octagon"></i> Errors <span class="badge badge-danger">{{ res.errors | length }}</span></div> <h2 id="err-h" class="sec" style="color: var(--fail-text)">{{ res.errors | length }} error{{ 's' if res.errors | length != 1 }}</h2>
<p class="small text-muted">This batch cannot be activated. Fix the cause on the work PC and run Sync-BPRT.ps1 again (it makes a new batch), then delete this one.</p> <p class="sub" style="margin: 0">This batch cannot be activated. Fix the cause on the work PC and run Sync-BPRT.ps1 again (it makes a new batch), then delete this one.</p>
<ul class="msg-list">{% for e in res.errors %}<li>{{ e }}</li>{% endfor %}</ul> <div class="errbox"><ul class="msg-list">{% for e in res.errors %}<li>{{ e }}</li>{% endfor %}</ul></div>
</div> </section>
{% endif %} {% endif %}
{% if res.stale %}<div class="alert alert-warning">{{ res.stale }}: validate again before activating.</div>{% endif %} {% if res.stale %}<div class="notice">{{ res.stale }}: validate again before activating.</div>{% endif %}
{% if res.warnings %} {% if res.warnings %}
<div class="section-card"> <div class="notice"><div><strong>{{ res.warnings | length }} warning{{ 's' if res.warnings | length != 1 }}</strong>
<div class="section-title text-warning"><i class="bi bi-exclamation-triangle"></i> Warnings <span class="badge badge-warning">{{ res.warnings | length }}</span></div> <ul class="msg-list" style="margin-top: 4px">{% for w in res.warnings %}<li>{{ w }}</li>{% endfor %}</ul></div></div>
<ul class="msg-list">{% for w in res.warnings %}<li>{{ w }}</li>{% endfor %}</ul>
</div>
{% endif %} {% endif %}
{% if res.counts %} {% if res.counts %}
<div class="dashboard-grid mb-4"> <div class="stats">
<div class="stat-card {{ 'info' if res.counts.new else '' }}"><div class="label"><i class="bi bi-plus-circle"></i> New files</div><div class="value">{{ res.counts.new }}</div></div> <div class="panel stat"><span class="sub">New files</span><span class="n">{{ res.counts.new }}</span></div>
<div class="stat-card {{ 'warning' if res.counts.changed else '' }}"><div class="label"><i class="bi bi-arrow-repeat"></i> Changed</div><div class="value">{{ res.counts.changed }}</div></div> <div class="panel stat"><span class="sub">Changed</span><span class="n">{{ res.counts.changed }}</span></div>
<div class="stat-card {{ 'danger' if res.counts.removed else '' }}"><div class="label"><i class="bi bi-dash-circle"></i> Removed</div><div class="value">{{ res.counts.removed }}</div></div> <div class="panel stat"><span class="sub">Removed</span><span class="n" {% if res.counts.removed %}style="color: var(--fail-text)"{% endif %}>{{ res.counts.removed }}</span></div>
<div class="stat-card success"><div class="label"><i class="bi bi-check2"></i> Unchanged</div><div class="value">{{ res.counts.unchanged }}</div></div> <div class="panel stat"><span class="sub">Unchanged</span><span class="n">{{ res.counts.unchanged }}</span></div>
</div> </div>
<div class="section-card"> <section class="panel ui-list" aria-labelledby="pk-h">
<div class="section-title"><i class="bi bi-box-seam"></i> MCL packages</div> <div class="sec-h"><div><h2 id="pk-h" class="sec">MCL packages</h2><div class="sub">Live now, then after activation.</div></div></div>
{% if res.ppkg %} {% for p in res.ppkg %}
<div class="table-container"> <div class="ppk">
<table class="data-table"> <span class="mono">{{ p.prefix }}</span>
<thead><tr><th>Tenant / purpose</th><th>Live now</th><th></th><th>After activation</th></tr></thead> <span>{% for o in p.old %}<div>{{ ui.chip(o.version, 'neutral') }} <span class="mono sub fname old">{{ fname(o.name) }}</span></div>{% else %}<span class="muted">none</span>{% endfor %}</span>
<tbody> <span class="sub to">to</span>
{% for p in res.ppkg %} <span>{% for n in p.new %}<div>{{ ui.chip(n.version, 'w11') }} <span class="mono sub fname new">{{ fname(n.name) }}</span></div>{% else %}<span style="color: var(--fail-text)">none (removed)</span>{% endfor %}</span>
<tr>
<td class="mono">{{ p.prefix }}</td>
<td class="wrap">{% for o in p.old %}<div><span class="badge badge-secondary">{{ o.version }}</span> <span class="mono small fname">{{ fname(o.name) }}</span></div>{% else %}<span class="text-muted">none</span>{% endfor %}</td>
<td class="text-muted">to</td>
<td class="wrap">{% for n in p.new %}<div><span class="badge badge-info">{{ n.version }}</span> <span class="mono small fname">{{ fname(n.name) }}</span></div>{% else %}<span class="text-danger">none (removed)</span>{% endfor %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div> </div>
{% else %}<p class="text-muted mb-0">No package changes.</p>{% endif %} {% else %}
</div> <div class="hidden-bar">No package changes.</div>
{% endfor %}
</section>
{% if res.zips or res.bom_fix %} {% if res.zips or res.bom_fix %}
<div class="section-card"> <section class="panel ui-list" aria-labelledby="zp-h">
<div class="section-title"><i class="bi bi-file-earmark-zip"></i> Large files and fixes</div> <div class="sec-h"><div><h2 id="zp-h" class="sec">Large files and fixes</h2></div></div>
{% if res.zips %} {% for z in res.zips %}
<div class="table-container mb-2"> <div class="pgrid" style="grid-template-columns: minmax(0, 1fr) 90px 90px">
<table class="data-table"> <span class="mono fname">{{ z.path }}</span><span class="sub">{{ ('live ' ~ size(z.old_size)) if z.old_size is not none else 'new file' }}</span><span>now {{ size(z.new_size) }}</span>
<thead><tr><th>Zip</th><th>Live size</th><th>New size</th></tr></thead> </div>
<tbody>{% for z in res.zips %}<tr><td class="mono wrap">{{ z.path }}</td><td>{{ size(z.old_size) if z.old_size is not none else 'new' }}</td><td>{{ size(z.new_size) }}</td></tr>{% endfor %}</tbody> {% endfor %}
</table> {% if res.bom_fix %}
<div class="pad" style="padding-top: 12px; border-top: 1px solid var(--line)">
<span>Activation adds a UTF-8 byte order mark to {{ res.bom_fix | length }} script{{ 's' if res.bom_fix | length != 1 }}
that hold{{ 's' if res.bom_fix | length == 1 }} non-ASCII text without one (Windows PowerShell 5.1 would read them as ANSI):</span>
<ul class="file-list mono">{% for f in res.bom_fix %}<li>{{ f }}</li>{% endfor %}</ul>
</div> </div>
{% endif %} {% endif %}
{% if res.bom_fix %} </section>
<p class="small mb-1">Activation adds a UTF-8 byte order mark to {{ res.bom_fix | length }} script{{ 's' if res.bom_fix | length != 1 }}
that hold{{ 's' if res.bom_fix | length == 1 }} non-ASCII text without one (Windows PowerShell 5.1 would read them as ANSI):</p>
<ul class="file-list small mono">{% for f in res.bom_fix %}<li>{{ f }}</li>{% endfor %}</ul>
{% endif %}
</div>
{% endif %} {% endif %}
<div class="section-card"> <section class="panel ui-list" aria-labelledby="fl-h">
<div class="section-title"><i class="bi bi-list-ul"></i> Files</div> <div class="sec-h"><div><h2 id="fl-h" class="sec">Files</h2></div></div>
{% for key, label in [('new', 'New'), ('changed', 'Changed'), ('removed', 'Removed (not in the vendor folder any more; kept in the archive)'), ('unchanged', 'Unchanged')] %} {% for key, label in [('new', 'New'), ('changed', 'Changed'), ('removed', 'Removed (not in the vendor folder any more; kept in the archive)'), ('unchanged', 'Unchanged')] %}
{% set rows = res.diff[key] %} {% set rows = res.diff[key] %}
{% if rows %} {% if rows %}
<details class="mb-2" {% if key != 'unchanged' and rows | length <= 40 %}open{% endif %}> <details style="border-top: 1px solid var(--line)" {% if key != 'unchanged' and rows | length <= 40 %}open{% endif %}>
<summary><strong>{{ label }}</strong> <span class="badge badge-secondary">{{ rows | length }}</span></summary> <summary class="disc" style="padding: 0 24px; min-height: 44px; color: var(--text)"><span class="chev" aria-hidden="true">&#9662;</span>{{ label }} {{ ui.chip(rows | length, 'neutral') }}</summary>
<ul class="file-list small mono">{% for f in rows %}<li>{{ f }}{% if f in renamed %} <span class="text-muted">(kept as {{ renamed[f] }})</span>{% endif %}</li>{% endfor %}</ul> <ul class="file-list mono" style="margin: 0 24px 14px">{% for f in rows %}<li>{{ f }}{% if f in renamed %} <span class="muted">(kept as {{ renamed[f] }})</span>{% endif %}</li>{% endfor %}</ul>
</details> </details>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
<p class="small text-muted mb-0">Site files carried over from the live BPRT as they are: <div class="hidden-bar"><span>Site files carried over from the live BPRT as they are:
{% for o in overlay %}<span class="mono">{{ o }}</span>{% if not loop.last %}, {% endif %}{% endfor %}.</p> {% for o in overlay %}<span class="mono">{{ o }}</span>{% if not loop.last %}, {% endif %}{% endfor %}.</span></div>
</div> </section>
{% endif %} {% endif %}
<div class="section-card"> <section class="panel" aria-labelledby="act-h" style="padding: 18px 24px; display: flex; flex-direction: column; gap: 12px">
<div class="section-title"><i class="bi bi-rocket-takeoff"></i> Activate</div> <h2 id="act-h" class="sec">Activate</h2>
{{ imaging_alert(imaging) }} {{ imaging_alert(imaging) }}
<p class="small text-muted"> <details>
Builds <span class="mono">BPRT.new</span> beside the live folder (hard links: unchanged files take no space), puts the site <summary class="disc"><span class="chev" aria-hidden="true">&#9662;</span>What activating does</summary>
files back on top, checks it once more, then swaps it in with two renames: the live folder becomes <p class="sub" style="margin: 4px 0 0; max-width: 820px; line-height: 1.6">
<span class="mono">BPRT-archive/full-&lt;time&gt;</span> (roll back to it at any time) and <span class="mono">BPRT.new</span> becomes Builds <span class="mono">BPRT.new</span> beside the live folder (hard links: unchanged files take no space), puts the site
<span class="mono">BPRT</span>. Every image links to that path, so they all switch at once. files back on top, checks it once more, then swaps it in with two renames: the live folder becomes
</p> <span class="mono">BPRT-archive/full-&lt;time&gt;</span> (roll back to it at any time) and <span class="mono">BPRT.new</span> becomes
<div class="d-flex flex-wrap gap-2"> <span class="mono">BPRT</span>. Every image links to that path, so they all switch at once.</p>
</details>
<div class="foot">
<form method="post" action="{{ url_for('bprt_sync.activate', name=b.name) }}" <form method="post" action="{{ url_for('bprt_sync.activate', name=b.name) }}"
onsubmit="return confirm('Replace the live BPRT with batch {{ b.name }}?{% if imaging %} {{ imaging | length }} PC(s) are imaging right now.{% endif %} The current folder is archived and can be rolled back.');"> onsubmit="return window.confirm('Replace the live BPRT with batch {{ b.name }}?{% if imaging %} {{ imaging | length }} PC(s) are imaging right now.{% endif %} The current folder is archived and can be rolled back.');">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="confirm" value="{{ b.name }}"> <input type="hidden" name="confirm" value="{{ b.name }}">
<button type="submit" class="pxe-btn pxe-btn-success" {% if res.state != 'valid' or res.stale or job.running %}disabled{% endif %}><i class="bi bi-check2-circle"></i> Activate</button> <button type="submit" class="ui-btn pri" {% if res.state != 'valid' or res.stale or job.running %}disabled{% endif %}>Activate</button>
</form> </form>
<form method="post" action="{{ url_for('bprt_sync.validate', name=b.name) }}"> <form method="post" action="{{ url_for('bprt_sync.validate', name=b.name) }}">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="pxe-btn pxe-btn-ghost" {% if job.running %}disabled{% endif %}><i class="bi bi-clipboard-check"></i> Validate again</button> <button type="submit" class="ui-btn" {% if job.running %}disabled{% endif %}>Validate again</button>
</form>
{% if res.state != 'valid' %}<span class="sub">Activate needs a valid batch.</span>{% elif res.stale %}<span class="sub">Validate again first: something changed.</span>{% endif %}
<form method="post" action="{{ url_for('bprt_sync.delete', name=b.name) }}" style="margin-left: auto"
onsubmit="return confirm('Delete incoming batch {{ b.name }} and its uploaded files? The live BPRT is not touched.');">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="ui-btn danger" {% if job.running and job.arg == b.name %}disabled{% endif %}>Delete this batch</button>
</form> </form>
<span class="small text-muted align-self-center">Validated {{ res.validated | local_dt }}.</span>
</div> </div>
</div> </section>
{% endif %} {% endif %}
</div>
<form method="post" action="{{ url_for('bprt_sync.delete', name=b.name) }}" class="mb-4"
onsubmit="return confirm('Delete incoming batch {{ b.name }} and its uploaded files? The live BPRT is not touched.');">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="pxe-btn pxe-btn-ghost" {% if job.running and job.arg == b.name %}disabled{% endif %}><i class="bi bi-trash"></i> Delete this batch</button>
</form>
{% else %} {% else %}
{# ======================= overview ======================================= #} {# ======================= overview ======================================= #}
<div class="page-header"> {% set n_pk = packages | selectattr('prefix') | list | length %}
<h1><i class="bi bi-arrow-left-right"></i> BPRT Sync</h1> <header class="ui-head">
<div class="header-actions"> <div>
{% if live.missing_required %}<span class="badge badge-danger badge-lg">{{ live.missing_required | length }} required file{{ 's' if live.missing_required | length != 1 }} missing</span> <div class="eyebrow-t">Updates</div>
{% elif live.exists %}<span class="badge badge-success badge-lg">{{ packages | selectattr('prefix') | list | length }} MCL packages live</span>{% endif %} <h1>BPRT sync</h1>
<form method="post" action="{{ url_for('bprt_sync.rebuild') }}" style="display:inline;"> <div class="state-line">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}"> {% if not live.exists %}{{ ui.chip('No live BPRT folder', 'fail') }}
<button type="submit" class="pxe-btn pxe-btn-ghost" {% if job.running %}disabled{% endif %} {% elif live.missing_required %}{{ ui.chip(live.missing_required | length ~ ' required file' ~ ('s' if live.missing_required | length != 1) ~ ' missing', 'fail') }}
title="Hash any live file the cache does not know; Sync-BPRT.ps1 starts this itself when it needs it"><i class="bi bi-fingerprint"></i> Rehash live folder</button> {% else %}{{ ui.chip(n_pk ~ ' MCL packages live', 'ok') }}{% endif %}
</form> {% set waiting = batches | rejectattr('uploading') | list | length %}
<span class="sub">{% if waiting %}{{ waiting }} incoming batch{{ 'es' if waiting != 1 }} waiting{% else %}No batch waiting{% endif %}</span>
</div>
</div> </div>
</div> <div class="actions">
<form method="post" action="{{ url_for('bprt_sync.rebuild') }}" style="margin: 0">
<p class="text-muted"> <input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<span class="mono">_shared/Applications/BPRT</span> is the vendor's bulk-enroll folder (MCL packages, Office zips, orchestrator <button type="submit" class="ui-btn" {% if job.running %}disabled{% endif %}
scripts) that every image links to and PESetup copies to every PC. The vendor keeps it current on the GE work PC with title="Hash any live file the cache does not know; Sync-BPRT.ps1 starts this itself when it needs it">Rehash live folder</button>
MediaCreator Lite; <strong>Sync-BPRT.ps1</strong> on that PC compares it with this server's copy and uploads only what changed, </form>
over SSH, into <span class="mono">BPRT-incoming</span>. Here a batch is validated, its changes shown, and activated with the {% if script_available %}<a class="ui-btn pri" href="{{ url_for('bprt_sync.script') }}">Download Sync-BPRT.ps1</a>{% endif %}
old folder archived. The site's own files ({% for o in overlay %}<span class="mono">{{ o }}</span>{% if not loop.last %}, {% endif %}{% endfor %}) </div>
are never touched by a sync. </header>
</p>
<div class="ui-stack">
{% if live.missing_required %}
<div class="notice bad"><span><strong>Missing files the bulk-enroll flow calls by path:</strong>
{% for r in live.missing_required %}<span class="mono">{{ r }}</span>{% if not loop.last %}, {% endif %}{% endfor %}. Enrollment breaks on every PC until they are back.</span></div>
{% endif %}
{{ imaging_alert(imaging) }} {{ imaging_alert(imaging) }}
{{ job_panel(job) }} {{ job_panel(job) }}
<details>
<summary class="disc"><span class="chev" aria-hidden="true">&#9662;</span>What BPRT sync does</summary>
<p class="sub" style="margin: 4px 0 0; max-width: 900px; line-height: 1.6">
<span class="mono">_shared/Applications/BPRT</span> is the vendor's bulk-enroll folder (MCL packages, Office zips, orchestrator
scripts) that every image links to and PESetup copies to every PC. The vendor keeps it current on the GE work PC with
MediaCreator Lite; Sync-BPRT.ps1 on that PC compares it with this server's copy and uploads only what changed,
over SSH, into <span class="mono">BPRT-incoming</span>. Here a batch is validated, its changes shown, and activated with the
old folder archived. The site's own files ({% for o in overlay %}<span class="mono">{{ o }}</span>{% if not loop.last %}, {% endif %}{% endfor %})
are never touched by a sync.</p>
</details>
{# ---- Live folder --------------------------------------------------------- #} {# ---- Live folder --------------------------------------------------------- #}
<div class="section-card"> <section class="panel ui-list" aria-labelledby="live-h">
<div class="section-title"><i class="bi bi-folder-check"></i> Live BPRT</div> <div class="sec-h"><div><h2 id="live-h" class="sec">Live BPRT</h2></div>
{% if live.exists %}<span>{% if live.missing_required %}{{ ui.chip('Required files missing', 'fail') }}{% else %}{{ ui.chip('Required files present', 'ok') }}{% endif %}</span>{% endif %}</div>
{% if not live.exists %} {% if not live.exists %}
<div class="alert alert-danger mb-0"><span class="mono">{{ live.path }}</span> does not exist.</div> <div class="pad"><div class="notice bad"><span><span class="mono">{{ live.path }}</span> does not exist.</span></div></div>
{% else %} {% else %}
<p class="small text-muted"><span class="mono">{{ live.path }}</span>: {{ live.count }} files, {{ size(live.bytes) }}{% if live.newest %}, newest file {{ live.newest | timestamp_fmt }}{% endif %}.</p> <div class="pad">
{% if live.is_link %}<div class="alert alert-danger"><span class="mono">{{ live.path }}</span> is a symlink; activation needs it to be a real folder and refuses to run.</div>{% endif %} <dl class="facts">
{% for l in links if l.state in ('copy', 'elsewhere', 'none') %} <div><dt>Files</dt><dd>{{ live.count }}</dd></div>
<div class="alert alert-warning py-2 mb-2"> <div><dt>Size</dt><dd>{{ size(live.bytes) }}</dd></div>
<strong>{{ l.image }}</strong> {% if live.newest %}<div><dt>Newest file</dt><dd class="mono">{{ live.newest | timestamp_fmt }}</dd></div>{% endif %}
{% if l.state == 'copy' %}has its own copy of BPRT (<span class="mono">{{ l.path }}</span> is a real folder; an image import does this), so a sync here does not reach its PCs. </dl>
{% elif l.state == 'elsewhere' %}links BPRT to <span class="mono">{{ l.target or '?' }}</span>, not to the shared folder, so a sync here does not reach its PCs. <div class="sub" style="overflow-wrap: anywhere">Folder <span class="mono">{{ live.path }}</span></div>
{% else %}has no <span class="mono">Deploy/Applications/BPRT</span>.{% endif %} {% if live.is_link %}<div class="notice bad"><span><span class="mono">{{ live.path }}</span> is a symlink; activation needs it to be a real folder and refuses to run.</span></div>{% endif %}
{% for l in links if l.state in ('copy', 'elsewhere', 'none') %}
<div class="notice"><span><strong>{{ l.image }}</strong>
{% if l.state == 'copy' %}has its own copy of BPRT (<span class="mono">{{ l.path }}</span> is a real folder; an image import does this), so a sync here does not reach its PCs.
{% elif l.state == 'elsewhere' %}links BPRT to <span class="mono">{{ l.target or '?' }}</span>, not to the shared folder, so a sync here does not reach its PCs.
{% else %}has no <span class="mono">Deploy/Applications/BPRT</span>.{% endif %}</span></div>
{% endfor %}
{% if live.bom_needed %}
<div class="notice"><span>{{ live.bom_needed | length }} script{{ 's' if live.bom_needed | length != 1 }} hold{{ 's' if live.bom_needed | length == 1 }} non-ASCII text without a UTF-8 byte order mark, which Windows PowerShell 5.1 misreads:
{% for r in live.bom_needed %}<span class="mono">{{ r }}</span>{% if not loop.last %}, {% endif %}{% endfor %}. The next activation fixes the copies it brings in.</span></div>
{% endif %}
</div>
<div class="sub-h">MCL packages</div>
{% set clashes = packages | selectattr('conflict') | list %}
{% if clashes %}
<div class="pad" style="padding-top: 8px">{% for p in clashes %}<div class="notice bad"><span><span class="mono">{{ p.filename }}</span> also matches {{ p.conflict_with | join(', ') }}: one prefix finding two packages breaks enrollment for that tenant and purpose.</span></div>{% endfor %}</div>
{% endif %}
{% for p in packages %}
<div class="pgrid">
<span class="mono">{{ p.prefix or '-' }}{% if p.conflict %} {{ ui.chip('Duplicate', 'err') }}{% endif %}</span>
<span class="tip c-file" data-tip="{{ p.filename }}"><span class="trunc mono sub">{{ p.filename }}</span></span>
<span>{{ ui.chip(p.version or '?', 'w11') }}</span>
<span class="sub c-size">{{ size(p.size) }}</span>
</div>
{% else %}
<div class="hidden-bar">No packages.</div>
{% endfor %}
<div class="hidden-bar"><span>A single package can also be replaced on <a href="{{ url_for('enrollment') }}#mcl">Enrollment packages</a>.</span></div>
<div class="sub-h">Site files (kept on every sync)</div>
{% for o in live.overlay %}
<div class="ogrid">
<span class="mono" style="overflow-wrap: anywhere">{{ o.name }}</span>
<span class="chips">
{% if o.ok %}{{ ui.chip('Present', 'ok') }}
{% elif o.present %}{{ ui.chip('Vendor copy, not our shim', 'err') }}
{% else %}{{ ui.chip('Missing', 'fail' if o.name == 'Register-BulkEnrollTask-Standard.ps1' or o.name in renamed else 'off') }}{% endif %}
{% if o.vendor_copy is defined %}{% if o.vendor_copy %}{{ ui.chip('vendor dialog kept', 'neutral') }}{% else %}{{ ui.chip('no vendor dialog: ' ~ renamed[o.name] ~ ' missing', 'warn') }}{% endif %}{% endif %}
</span>
</div> </div>
{% endfor %} {% endfor %}
{% if live.missing_required %} <div class="hidden-bar"><span>
<div class="alert alert-danger py-2">Missing files the bulk-enroll flow calls by path: {% for r in live.missing_required %}<span class="mono">{{ r }}</span>{% if not loop.last %}, {% endif %}{% endfor %}.</div> <span class="mono">Select-PCConfig.ps1</span> is our shim (repo <span class="mono">playbook/shopfloor-setup/BPRT</span>); the vendor's
dialog is kept as <span class="mono">Select-PCConfig-vendor.ps1</span>. The standard and engineer unattends run
<span class="mono">Register-BulkEnrollTask-Standard.ps1</span> (specialize Order 39).</span></div>
{% endif %} {% endif %}
{% if live.bom_needed %} </section>
<div class="alert alert-warning py-2">{{ live.bom_needed | length }} script{{ 's' if live.bom_needed | length != 1 }} hold{{ 's' if live.bom_needed | length == 1 }} non-ASCII text without a UTF-8 byte order mark, which Windows PowerShell 5.1 misreads:
{% for r in live.bom_needed %}<span class="mono">{{ r }}</span>{% if not loop.last %}, {% endif %}{% endfor %}. The next activation fixes the copies it brings in.</div>
{% endif %}
<div class="row g-3">
<div class="col-xl-7">
<h6>MCL packages</h6>
{% if packages %}
<div class="table-container">
<table class="data-table">
<thead><tr><th>Tenant / purpose</th><th>File</th><th>Version</th><th>Size</th></tr></thead>
<tbody>
{% for p in packages %}
<tr>
<td class="mono text-nowrap">{{ p.prefix or '-' }}{% if p.conflict %} <span class="badge badge-danger" title="Also matches {{ p.conflict_with | join(', ') }}">Duplicate</span>{% endif %}</td>
<td class="mono small fname">{{ fname(p.filename) }}</td>
<td><span class="badge badge-info">{{ p.version or '?' }}</span></td>
<td class="text-nowrap">{{ size(p.size) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}<p class="text-muted">No packages.</p>{% endif %}
<div class="small text-muted mt-1">A single package can also be replaced on <a href="{{ url_for('enrollment') }}#mcl">Enrollment Packages</a>.</div>
</div>
<div class="col-xl-5">
<h6>Site files (kept on every sync)</h6>
<table class="table table-sm mb-0">
<tbody>
{% for o in live.overlay %}
<tr>
<td class="mono small wrap">{{ o.name }}</td>
<td class="text-nowrap">
{% if o.ok %}<span class="badge badge-success">Present</span>
{% elif o.present %}<span class="badge badge-danger" title="This is the vendor's dialog, not our shim">Vendor copy</span>
{% else %}<span class="badge badge-{{ 'danger' if o.name == 'Register-BulkEnrollTask-Standard.ps1' or o.name in renamed else 'secondary' }}">Missing</span>{% endif %}
{% if o.vendor_copy is defined %}{% if o.vendor_copy %}<span class="badge badge-secondary" title="{{ renamed[o.name] }}">vendor dialog kept</span>{% else %}<span class="badge badge-warning" title="{{ renamed[o.name] }} is missing: the shim cannot fall back to the vendor dialog">no vendor dialog</span>{% endif %}{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="small text-muted mt-1">
<span class="mono">Select-PCConfig.ps1</span> is our shim (repo <span class="mono">playbook/shopfloor-setup/BPRT</span>); the vendor's
dialog is kept as <span class="mono">Select-PCConfig-vendor.ps1</span>. The standard and engineer unattends run
<span class="mono">Register-BulkEnrollTask-Standard.ps1</span> (specialize Order 39).
</div>
</div>
</div>
{% endif %}
</div>
{# ---- Incoming batches ---------------------------------------------------- #} {# ---- Incoming batches ---------------------------------------------------- #}
<div class="section-card"> <section class="panel ui-list" aria-labelledby="in-h">
<div class="section-title"><i class="bi bi-inboxes"></i> Incoming batches <span class="badge badge-secondary">{{ batches | length }}</span></div> <div class="sec-h"><div><h2 id="in-h" class="sec">Incoming batches</h2>
{% if batches %} <div class="sub">Validate a batch, read its changes, then activate it from its page.</div></div>
<div class="table-container"> <span class="sub">{{ batches | length }}</span></div>
<table class="data-table"> {% for b in batches %}
<thead><tr><th>Batch</th><th>From</th><th>Uploaded</th><th>Status</th><th class="text-end"></th></tr></thead> <div class="ui-row">
<tbody> <details {% if b.sync_error or (b.validation and b.validation.errors) %}open{% endif %}>
{% for b in batches %} <summary class="bgrid">
<tr> <span class="tip" data-tip="{{ b.name }}"><span class="trunc mono">{{ b.name }}</span></span>
<td class="mono small wrap">{% if b.uploading %}{{ b.name }}{% else %}<a href="{{ url_for('bprt_sync.batch', name=b.name) }}">{{ b.name }}</a>{% endif %}</td> {% set frm = ((b.sync.computer or '-') ~ (', ' ~ (b.sync.created | local_dt) if b.sync.created else '')) if b.sync else '-' %}
<td class="small wrap">{% if b.sync %}{{ b.sync.computer or '-' }}{% if b.sync.created %}<div class="text-muted">{{ b.sync.created | local_dt }}</div>{% endif %}{% else %}-{% endif %}</td> <span class="tip c-from" data-tip="{{ frm }}"><span class="trunc sub">{{ frm }}</span></span>
<td class="small text-nowrap">{{ b.files }} file{{ 's' if b.files != 1 }}, {{ size(b.bytes) }}</td> <span class="sub c-size">{{ b.files }} file{{ 's' if b.files != 1 }}, {{ size(b.bytes) }}</span>
<td class="small"> <span class="c-st">
{% if b.uploading %}<span class="badge badge-info" title="Sync-BPRT.ps1 renames the folder when the upload is complete">Uploading</span> {% if b.uploading %}{{ ui.chip('Uploading', 'run') }}
{% elif b.sync_error %}<span class="badge badge-danger" title="{{ b.sync_error }}">Bad sync.json</span> {% elif b.sync_error %}{{ ui.chip('Bad sync.json', 'err') }}
{% elif not b.sync %}<span class="badge badge-danger" title="sync.json is uploaded last; this batch is incomplete">No sync.json</span> {% elif not b.sync %}{{ ui.chip('No sync.json', 'err') }}
{% else %}{{ val_badge(b.validation) }}{% endif %} {% else %}{{ val_chip(b.validation) }}{% endif %}
</td> </span>
<td class="text-end text-nowrap"> <span class="chev" aria-hidden="true">&#9662;</span>
{% if not b.uploading and b.sync %} </summary>
<a class="pxe-btn pxe-btn-ghost pxe-btn-sm" href="{{ url_for('bprt_sync.batch', name=b.name) }}"><i class="bi bi-eye"></i> {{ 'Review' if b.validation else 'Open' }}</a> <div class="rbody">
{% if not b.validation %} {% if b.uploading %}<div class="sub">Sync-BPRT.ps1 renames the folder when the upload is complete.</div>{% endif %}
<form method="post" action="{{ url_for('bprt_sync.validate', name=b.name) }}" style="display:inline;"> {% if b.sync_error %}<div class="errbox">{{ b.sync_error }}</div>
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}"> {% elif not b.uploading and not b.sync %}<div class="errbox">sync.json is uploaded last; this batch is incomplete.</div>{% endif %}
<button type="submit" class="pxe-btn pxe-btn-primary pxe-btn-sm" {% if job.running %}disabled{% endif %}><i class="bi bi-clipboard-check"></i> Validate</button> {% set v = b.validation %}
</form> {% if v %}
{% endif %} {% if v.errors %}<div class="errbox"><ul class="msg-list">{% for e in v.errors %}<li>{{ e }}</li>{% endfor %}</ul></div>{% endif %}
{% endif %} {% if v.stale %}<div class="notice">{{ v.stale }}</div>{% endif %}
<form method="post" action="{{ url_for('bprt_sync.delete', name=b.name) }}" style="display:inline;" {% if v.warnings %}<div class="notice"><ul class="msg-list">{% for w in v.warnings %}<li>{{ w }}</li>{% endfor %}</ul></div>{% endif %}
onsubmit="return confirm('Delete incoming batch {{ b.name }}?{% if b.uploading %} If Sync-BPRT.ps1 is still uploading it, its upload fails.{% endif %} The live BPRT is not touched.');"> {% if v.counts %}<div>{{ v.counts.new }} new, {{ v.counts.changed }} changed, {{ v.counts.removed }} removed, {{ v.counts.unchanged }} unchanged.</div>{% endif %}
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}"> {% for p in v.ppkg or [] %}<div class="sub mono">{{ p.prefix }}: {{ p.old | map(attribute='version') | join(', ') or 'none' }} to {{ p.new | map(attribute='version') | join(', ') or 'none' }}</div>{% endfor %}
<button type="submit" class="btn btn-sm btn-outline-danger btn-row-action" title="Delete batch"><i class="bi bi-trash"></i></button> <div class="sub">Validated {{ v.validated | local_dt }}.</div>
</form> {% elif b.sync %}<div class="sub">Not validated yet. Validate hashes the upload and works out what activating would change; nothing on the share changes.</div>{% endif %}
</td> {% if b.sync %}<div class="sub">From {{ b.sync.computer or 'unknown PC' }}{% if b.sync.created %}, {{ b.sync.created | local_dt }}{% endif %}. {{ b.sync.listed }} vendor files listed{% if b.sync.user %}, by {{ b.sync.user }}{% endif %}{% if b.sync.source %}, from <span class="mono">{{ b.sync.source }}</span>{% endif %}.</div>{% endif %}
</tr> </div>
{% endfor %} </details>
</tbody> <div class="act">
</table> {% if not b.uploading and b.sync %}
{% if not b.validation %}
<form method="post" action="{{ url_for('bprt_sync.validate', name=b.name) }}" style="margin: 0">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="ui-btn pri" {% if job.running %}disabled{% endif %}>Validate</button>
</form>
{% else %}
<a class="ui-btn{{ ' pri' if b.validation.state == 'valid' and not b.validation.stale }}" href="{{ url_for('bprt_sync.batch', name=b.name) }}">Review</a>
{% endif %}
{% endif %}
{% call ui.more('batch ' ~ b.name) %}
{% if not b.uploading and b.sync %}<a href="{{ url_for('bprt_sync.batch', name=b.name) }}">Open batch page</a><div class="sep"></div>{% endif %}
<form method="post" action="{{ url_for('bprt_sync.delete', name=b.name) }}"
onsubmit="return confirm('Delete incoming batch {{ b.name }}?{% if b.uploading %} If Sync-BPRT.ps1 is still uploading it, its upload fails.{% endif %} The live BPRT is not touched.');">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="del">Delete batch</button>
</form>
{% endcall %}
</div>
</div> </div>
{% else %} {% else %}
<p class="text-muted mb-0">Nothing waiting. Run Sync-BPRT.ps1 on the work PC (below), or upload a zip.</p> <div class="hidden-bar">Nothing waiting. Run Sync-BPRT.ps1 on the work PC (below), or upload a zip.</div>
{% endif %} {% endfor %}
</div> </section>
{# ---- How to sync --------------------------------------------------------- #} {# ---- How to sync --------------------------------------------------------- #}
<div class="section-card"> <section class="panel" aria-labelledby="how-h">
<div class="section-title"><i class="bi bi-terminal"></i> Sync from the work PC</div> <details>
<ol class="mb-2"> <summary class="sec-h">
<li>Let MediaCreator Lite finish updating its cache on the GE work PC.</li> <div><h2 id="how-h" class="sec" style="font-size: 15px">Sync from the work PC</h2>
<li>Get <span class="mono">Sync-BPRT.ps1</span> <div class="sub">Run Sync-BPRT.ps1 on the GE work PC; it uploads only what changed.</div></div>
{% if script_available %}(<a href="{{ url_for('bprt_sync.script') }}">download it from here</a>) <span class="chev" aria-hidden="true">&#9662;</span>
{% else %}from the pxe repo, <span class="mono">webapp/static/Sync-BPRT.ps1</span>{% endif %}, </summary>
and run it in Windows PowerShell. Look first with <span class="mono">-DryRun</span> (nothing is uploaded): <div style="padding: 0 24px 20px; display: flex; flex-direction: column; gap: 12px">
<pre class="cmd mono small">powershell -ExecutionPolicy Bypass -File .\Sync-BPRT.ps1 -DryRun <ol class="steps">
<li>Let MediaCreator Lite finish updating its cache on the GE work PC.</li>
<li>Get <span class="mono">Sync-BPRT.ps1</span>
{% if script_available %}(<a href="{{ url_for('bprt_sync.script') }}">download it from here</a>)
{% else %}from the pxe repo, <span class="mono">webapp/static/Sync-BPRT.ps1</span>{% endif %},
and run it in Windows PowerShell. Look first with <span class="mono">-DryRun</span> (nothing is uploaded):
<pre class="cmd mono">powershell -ExecutionPolicy Bypass -File .\Sync-BPRT.ps1 -DryRun
powershell -ExecutionPolicy Bypass -File .\Sync-BPRT.ps1</pre> powershell -ExecutionPolicy Bypass -File .\Sync-BPRT.ps1</pre>
It reads this server's manifest, hashes the local folder (<span class="mono">C:\ProgramData\GEAerospace\MediaCenter\Cache\Deploy\Applications\BPRT</span> It reads this server's manifest, hashes the local folder (<span class="mono" style="overflow-wrap: anywhere">C:\ProgramData\GEAerospace\MediaCenter\Cache\Deploy\Applications\BPRT</span>
unless <span class="mono">-Source</span> says otherwise), parse-checks the vendor scripts, and uploads the new and changed files with one unless <span class="mono">-Source</span> says otherwise), parse-checks the vendor scripts, and uploads the new and changed files with one
<span class="mono">sftp</span> session: type the <span class="mono">pxe</span> password once.</li> <span class="mono">sftp</span> session: type the <span class="mono">pxe</span> password once.</li>
<li>Come back here, open the new batch, <strong>Validate</strong>, read the changes, <strong>Activate</strong>.</li> <li>Come back here, open the new batch, Validate, read the changes, Activate.</li>
</ol> </ol>
<p class="small text-muted mb-0"> <p class="sub" style="margin: 0; overflow-wrap: anywhere">
No SSH from that PC? Run it with <span class="mono">-ZipOut C:\Temp\bprt.zip</span> and upload the zip below. A zip of the whole No SSH from that PC? Run it with <span class="mono">-ZipOut C:\Temp\bprt.zip</span> and upload the zip below. A zip of the whole
vendor folder works too, but is taken as the complete new folder: anything not in it is removed from BPRT (and kept in the archive). vendor folder works too, but is taken as the complete new folder: anything not in it is removed from BPRT (and kept in the archive).
Uploads land in <span class="mono">{{ incoming }}</span>. Uploads land in <span class="mono">{{ incoming }}</span>.</p>
</p> </div>
</div> </details>
</section>
{# ---- Zip upload ---------------------------------------------------------- #} {# ---- Zip upload ---------------------------------------------------------- #}
<div class="section-card"> <section class="panel ui-list" aria-labelledby="zip-h">
<div class="section-title"><i class="bi bi-file-earmark-zip"></i> Upload a zip instead</div> <div class="sec-h"><div><h2 id="zip-h" class="sec">Upload a zip instead</h2>
<form id="bprtUpload" method="post" action="{{ url_for('bprt_sync.upload') }}" enctype="multipart/form-data" <div class="sub">For a work PC without SSH: the output of Sync-BPRT.ps1 -ZipOut, or a zip of the whole vendor folder.</div></div></div>
class="d-flex flex-wrap gap-2 align-items-end"> <form id="bprtUpload" method="post" action="{{ url_for('bprt_sync.upload') }}" enctype="multipart/form-data" class="upl">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="form-field mb-0" style="min-width: min(22rem, 100%);"> <div class="fld">
<label for="bprtZip" class="form-label">Zip (.zip)</label> <label for="bprtZip">Zip (.zip)</label>
<input type="file" class="form-control" id="bprtZip" name="zip_file" accept=".zip" required> <input type="file" class="field" id="bprtZip" name="zip_file" accept=".zip" required>
</div> </div>
<button type="submit" class="pxe-btn pxe-btn-primary" {% if job.running %}disabled{% endif %}><i class="bi bi-upload"></i> Upload and validate</button> <button type="submit" class="ui-btn pri" {% if job.running %}disabled{% endif %}>Upload and validate</button>
</form> </form>
<div id="bprtUploadProgress" class="mt-2" style="display: none;"> <div id="bprtUploadProgress" class="prog" style="display: none;" role="status">
<div class="progress" style="height: 0.5rem;"><div id="bprtUploadBar" class="progress-bar" style="width: 0%;"></div></div> <div class="bar"><div id="bprtUploadBar" style="width: 0%;"></div></div>
<div id="bprtUploadText" class="small text-muted mt-1"></div> <span id="bprtUploadText"></span>
</div> </div>
{% if zips %} {% for z in zips %}
<div class="table-container mt-3"> <div class="ui-row">
<table class="table table-sm mb-0"> <div class="zgrid">
<thead><tr><th>Uploaded zip, not unpacked</th><th>Size</th><th class="text-end"></th></tr></thead> <span class="tip" data-tip="{{ z.name }}"><span class="trunc mono">{{ z.name }}</span></span>
<tbody> <span class="sub">{{ size(z.size) }}</span>
{% for z in zips %} </div>
<tr> <div class="act" style="top: 6px">
<td class="mono small wrap">{{ z.name }}</td><td class="text-nowrap">{{ size(z.size) }}</td> <form method="post" action="{{ url_for('bprt_sync.unzip', name=z.name) }}" style="margin: 0">
<td class="text-end text-nowrap"> <input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<form method="post" action="{{ url_for('bprt_sync.unzip', name=z.name) }}" style="display:inline;"> <button type="submit" class="ui-btn" {% if job.running %}disabled{% endif %}>Unpack</button>
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}"> </form>
<button type="submit" class="pxe-btn pxe-btn-ghost pxe-btn-sm" {% if job.running %}disabled{% endif %}>Unpack</button> {% call ui.more(z.name) %}
</form> <form method="post" action="{{ url_for('bprt_sync.delete_zip', name=z.name) }}" onsubmit="return confirm('Delete {{ z.name }}?');">
<form method="post" action="{{ url_for('bprt_sync.delete_zip', name=z.name) }}" style="display:inline;" onsubmit="return confirm('Delete {{ z.name }}?');"> <input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}"> <button type="submit" class="del">Delete zip</button>
<button type="submit" class="btn btn-sm btn-outline-danger btn-row-action" title="Delete"><i class="bi bi-trash"></i></button> </form>
</form> {% endcall %}
</td> </div>
</tr>
{% endfor %}
</tbody>
</table>
</div> </div>
{% endif %} {% endfor %}
</div> {% if zips %}<div class="hidden-bar">Uploaded zips, not unpacked yet.</div>{% endif %}
</section>
{# ---- Archives ------------------------------------------------------------ #} {# ---- Archives (folded) ---------------------------------------------------- #}
<div class="section-card"> <section class="panel ui-list" aria-labelledby="ar-h">
<div class="section-title"><i class="bi bi-archive"></i> Previous BPRT folders <span class="badge badge-secondary">{{ archives | length }}</span></div> <details>
{% if archives %} <summary class="sec-h">
<p class="small text-muted">Each activation and rollback keeps the folder it replaced in <span class="mono">BPRT-archive</span>. Unchanged files are <div><h2 id="ar-h" class="sec" style="font-size: 15px">Previous BPRT folders</h2>
hard links shared with the live folder, so an archive only costs the files that differ ("own size").</p> <div class="sub">Each activation and rollback keeps the folder it replaced in <span class="mono">BPRT-archive</span>.</div></div>
<div class="table-container"> <span style="display: flex; align-items: center; gap: 10px">{{ ui.chip(archives | length ~ ' archive' ~ ('s' if archives | length != 1), 'neutral') }}<span class="chev" aria-hidden="true">&#9662;</span></span>
<table class="data-table"> </summary>
<thead><tr><th>Archive</th><th>MCL packages</th><th>Files</th><th>Own size</th><th class="text-end"></th></tr></thead> {% for a in archives %}
<tbody> <div class="ui-row">
{% for a in archives %} <details>
<tr> <summary class="xgrid">
<td class="mono small text-nowrap">{{ a.name }}<div class="text-muted">{{ a.modified | timestamp_fmt }}</div></td> <span class="mono">{{ a.name }}</span>
<td class="small wrap">{% for p in a.packages %}<div class="mono fname">{{ fname(p) }}</div>{% else %}<span class="text-danger">none</span>{% endfor %} <span class="sub c-pk">{{ a.packages | length }} MCL package{{ 's' if a.packages | length != 1 }}, {{ a.count }} files, own size {{ size(a.own_bytes) }}</span>
{% if a.missing_required %}<div class="text-danger">missing {{ a.missing_required | join(', ') }}</div>{% endif %}</td> <span class="c-st">{% if a.missing_required %}{{ ui.chip('Required files missing', 'err') }}{% elif not a.packages %}{{ ui.chip('No packages', 'warn') }}{% else %}<span class="sub mono" style="white-space: nowrap">{{ a.modified | timestamp_fmt }}</span>{% endif %}</span>
<td class="small text-nowrap">{{ a.count }}, {{ size(a.bytes) }}</td> <span class="chev" aria-hidden="true">&#9662;</span>
<td class="small text-nowrap">{{ size(a.own_bytes) }}</td> </summary>
<td class="text-end text-nowrap"> <div class="rbody">
<form method="post" action="{{ url_for('bprt_sync.rollback') }}" style="display:inline;" {% if a.missing_required %}<div class="notice bad">Missing {{ a.missing_required | join(', ') }}</div>{% endif %}
onsubmit="return confirm('Make {{ a.name }} the live BPRT again?{% if imaging %} {{ imaging | length }} PC(s) are imaging right now.{% endif %} The current folder is archived first.');"> <div class="sub">Saved {{ a.modified | timestamp_fmt }}. {{ a.count }} files, {{ size(a.bytes) }}; {{ size(a.own_bytes) }} not shared with the live folder.</div>
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}"> {% for p in a.packages %}<div class="mono fname">{{ fname(p) }}</div>{% else %}<div style="color: var(--fail-text)">No MCL packages.</div>{% endfor %}
<input type="hidden" name="archive" value="{{ a.name }}"> </div>
<button type="submit" class="pxe-btn pxe-btn-ghost pxe-btn-sm" {% if job.running %}disabled{% endif %}><i class="bi bi-arrow-counterclockwise"></i> Roll back</button> </details>
</form> <div class="act">
<form method="post" action="{{ url_for('bprt_sync.delete_archive') }}" style="display:inline;" <form method="post" action="{{ url_for('bprt_sync.rollback') }}" style="margin: 0"
onsubmit="return confirm('Delete archive {{ a.name }}? This frees {{ size(a.own_bytes) }}; it cannot be rolled back to afterwards.');"> onsubmit="return confirm('Make {{ a.name }} the live BPRT again?{% if imaging %} {{ imaging | length }} PC(s) are imaging right now.{% endif %} The current folder is archived first.');">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="archive" value="{{ a.name }}"> <input type="hidden" name="archive" value="{{ a.name }}">
<button type="submit" class="btn btn-sm btn-outline-danger btn-row-action" title="Delete archive" {% if job.running %}disabled{% endif %}><i class="bi bi-trash"></i></button> <button type="submit" class="ui-btn" {% if job.running %}disabled{% endif %}>Roll back</button>
</form> </form>
</td> {% call ui.more('archive ' ~ a.name) %}
</tr> <form method="post" action="{{ url_for('bprt_sync.delete_archive') }}"
{% endfor %} onsubmit="return confirm('Delete archive {{ a.name }}? This frees {{ size(a.own_bytes) }}; it cannot be rolled back to afterwards.');">
</tbody> <input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
</table> <input type="hidden" name="archive" value="{{ a.name }}">
</div> <button type="submit" class="del" {% if job.running %}disabled{% endif %}>Delete archive</button>
{% else %} </form>
<p class="text-muted mb-0">None yet. The first activation archives the current folder here.</p> {% endcall %}
{% endif %} </div>
</div> </div>
{% else %}
<div class="hidden-bar">None yet. The first activation archives the current folder here.</div>
{% endfor %}
{% if archives %}<div class="hidden-bar">Unchanged files are hard links shared with the live folder, so an archive only costs the files that differ ("own size").</div>{% endif %}
</details>
</section>
{# ---- History (folded) ----------------------------------------------------- #}
{% if history %} {% if history %}
<div class="section-card"> <section class="panel ui-list" aria-labelledby="hi-h">
<div class="section-title"><i class="bi bi-clock-history"></i> History</div> <details>
<div class="table-container"> <summary class="sec-h">
<table class="data-table"> <div><h2 id="hi-h" class="sec" style="font-size: 15px">History</h2><div class="sub">Activations and rollbacks, newest first.</div></div>
<thead><tr><th>When</th><th>What</th><th>Previous folder</th></tr></thead> <span style="display: flex; align-items: center; gap: 10px">{{ ui.chip(history | length, 'neutral') }}<span class="chev" aria-hidden="true">&#9662;</span></span>
<tbody> </summary>
{% for h in history %} {% for h in history %}
<tr> <div class="ui-row">
<td class="small text-nowrap">{{ h.at | local_dt }}</td> <details>
<td class="small wrap"> <summary class="hgrid">
{% if h.kind == 'rollback' %}Rolled back to <span class="mono">{{ h.restored }}</span> <span class="sub when" style="white-space: nowrap">{{ h.at | local_dt }}</span>
{% else %}Activated <span class="mono">{{ h.batch }}</span>{% if h.sync and h.sync.computer %} from {{ h.sync.computer }}{% endif %}: {% if h.kind == 'rollback' %}
{{ h.counts.new or 0 }} new, {{ h.counts.changed or 0 }} changed, {{ h.counts.removed or 0 }} removed <span class="tip" data-tip="Rolled back to {{ h.restored }}"><span class="trunc">Rolled back to <span class="mono">{{ h.restored }}</span></span></span>
{% for p in h.ppkg %}<div class="mono">{{ p.prefix }}: {{ p.old | map(attribute='version') | join(', ') or 'none' }} to {{ p.new | map(attribute='version') | join(', ') or 'none' }}</div>{% endfor %} {% else %}
{% endif %} <span class="tip" data-tip="Activated {{ h.batch }}"><span class="trunc">Activated <span class="mono">{{ h.batch }}</span>: {{ h.counts.new or 0 }} new, {{ h.counts.changed or 0 }} changed, {{ h.counts.removed or 0 }} removed</span></span>
</td> {% endif %}
<td class="mono small">{{ h.archive }}</td> <span class="chev" aria-hidden="true">&#9662;</span>
</tr> </summary>
{% endfor %} <div class="rbody">
</tbody> {% if h.kind != 'rollback' %}
</table> <div>Activated <span class="mono">{{ h.batch }}</span>{% if h.sync and h.sync.computer %} from {{ h.sync.computer }}{% endif %}:
</div> {{ h.counts.new or 0 }} new, {{ h.counts.changed or 0 }} changed, {{ h.counts.removed or 0 }} removed.</div>
</div> {% for p in h.ppkg %}<div class="mono">{{ p.prefix }}: {{ p.old | map(attribute='version') | join(', ') or 'none' }} to {{ p.new | map(attribute='version') | join(', ') or 'none' }}</div>{% endfor %}
{% endif %} {% endif %}
<div class="sub">Previous folder kept as <span class="mono">{{ h.archive }}</span>.</div>
</div>
</details>
</div>
{% endfor %}
</details>
</section>
{% endif %} {% endif %}
</div> </div>
{% endif %}
{% endblock %} {% endblock %}
{% block extra_scripts %} {% block extra_scripts %}

View File

@@ -1,411 +1,500 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Dell Drivers and BIOS - PXE Server Manager{% endblock %} {% import '_ui.html' as ui %}
{% block title %}Dell drivers and BIOS - PXE Manager{% endblock %}
{% macro size(n) -%} {% 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 -%} {%- 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 %} {%- endmacro %}
{% macro drv_badge(s) -%} {% macro drv_chip(r) -%}
{% if s == 'update' %}<span class="badge badge-warning">Update</span> {% if r.status == 'update' and r.warnings %}{{ ui.chip('Update: decide', 'warn') }}
{% elif s == 'current' %}<span class="badge badge-success">Current</span> {% elif r.status == 'update' %}{{ ui.chip('Update', 'act') }}
{% elif s == 'no-match' %}<span class="badge badge-secondary" title="No Win11 pack in Dell's catalog matches these model names">No match</span> {% elif r.status == 'current' %}{{ ui.chip('Current', 'ok') }}
{% elif s == 'unknown-version' %}<span class="badge badge-info" title="The current file name has no A-number to compare">Unknown version</span> {% elif r.status == 'no-match' %}{{ ui.chip('No match', 'off') }}
{% else %}<span class="badge badge-secondary">{{ s }}</span>{% endif %} {% elif r.status == 'unknown-version' %}{{ ui.chip('Unknown version', 'neutral') }}
{% else %}{{ ui.chip(r.status, 'neutral') }}{% endif %}
{%- endmacro %} {%- endmacro %}
{% macro bios_badge(s) -%} {% macro bios_chip(s) -%}
{% if s == 'update' %}<span class="badge badge-warning">Update</span> {% if s == 'update' %}{{ ui.chip('Update', 'act') }}
{% elif s == 'current' %}<span class="badge badge-success">Current</span> {% elif s == 'current' %}{{ ui.chip('Current', 'ok') }}
{% elif s == 'different-file-family' %}<span class="badge badge-danger" title="Dell's latest BIOS for these models is a different file than ours">Different file</span> {% elif s == 'different-file-family' %}{{ ui.chip('Different file', 'err') }}
{% elif s == 'ambiguous' %}<span class="badge badge-danger" title="Several BIOS file families match this line">Ambiguous</span> {% elif s == 'ambiguous' %}{{ ui.chip('Ambiguous', 'err') }}
{% elif s == 'no-match' %}<span class="badge badge-secondary" title="No Dell model in the catalog index matches this line">No match</span> {% elif s == 'no-match' %}{{ ui.chip('No match', 'off') }}
{% elif s == 'error' %}<span class="badge badge-danger">Catalog error</span> {% elif s == 'error' %}{{ ui.chip('Catalog error', 'err') }}
{% else %}<span class="badge badge-secondary">{{ s }}</span>{% endif %} {% else %}{{ ui.chip(s, 'neutral') }}{% endif %}
{%- endmacro %} {%- endmacro %}
{% macro stage_badge(s) -%} {% macro stage_chip(s) -%}
{% if s == 'ready' %}<span class="badge badge-success">Ready</span> {% if s == 'ready' %}{{ ui.chip('Ready', 'ok') }}
{% elif s == 'applied' %}<span class="badge badge-primary">Applied</span> {% elif s == 'applied' %}{{ ui.chip('Applied', 'neutral') }}
{% elif s in ('error', 'interrupted', 'cancelled') %}<span class="badge badge-danger">{{ s | capitalize }}</span> {% elif s in ('error', 'interrupted', 'cancelled') %}{{ ui.chip(s | capitalize, 'err') }}
{% else %}<span class="badge badge-info">{{ s | capitalize }}</span>{% endif %} {% 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 %} {%- endmacro %}
{% block extra_head %} {% block extra_head %}
<style> <style>
/* Long Dell file names wrap instead of pushing Status off the page. */ .sec-h { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 8px 16px; padding: 18px 24px 12px; }
.dell-updates td.wrap { min-width: 9rem; max-width: 22rem; } .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> </style>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="dell-updates">
{% set chk = prog.check %} {% set chk = prog.check %}
{% set dl = prog.download %} {% set dl = prog.download %}
{% set n_drv = scan.drivers | selectattr('status', 'equalto', 'update') | list | length if scan else 0 %} {% 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 %} {% set n_bios = scan.bios | selectattr('status', 'equalto', 'update') | list | length if scan else 0 %}
<div class="page-header">
<h1><i class="bi bi-motherboard"></i> Dell Drivers and BIOS</h1> <header class="ui-head">
<div class="header-actions"> <div>
{% if scan %} <div class="eyebrow-t">Updates</div>
{% if n_drv or n_bios %}<span class="badge badge-warning badge-lg">{{ n_drv }} driver / {{ n_bios }} BIOS update{{ 's' if n_drv + n_bios != 1 }}</span> <h1>Dell drivers and BIOS</h1>
{% else %}<span class="badge badge-success badge-lg">Up to date</span>{% endif %} <div class="state-line">
{% endif %} {% 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 %} {% if prog.check_running %}
<form method="post" action="{{ url_for('dell_updates.cancel') }}" style="display:inline;"> <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="_csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="job" value="check"> <input type="hidden" name="job" value="check">
<button type="submit" class="pxe-btn pxe-btn-ghost"><i class="bi bi-x-circle"></i> Stop check</button> <button type="submit" class="ui-btn">Stop check</button>
</form> </form>
{% else %} {% else %}
<form method="post" action="{{ url_for('dell_updates.check') }}" style="display:inline;"> <form method="post" action="{{ url_for('dell_updates.check') }}" style="margin: 0">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="pxe-btn pxe-btn-secondary" {% if prog.download_running %}disabled title="A download is running"{% endif %}> <button type="submit" class="ui-btn pri" {% if prog.download_running %}disabled title="A download is running"{% endif %}>Check now</button>
<i class="bi bi-arrow-repeat"></i> Check for updates
</button>
</form> </form>
{% endif %} {% endif %}
</div> </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> </div>
{% elif chk.state == 'error' %}
<p class="text-muted"> <div class="errbox">Last check failed: {{ chk.error }}</div>
Compares every Win11 entry in the three images' <span class="mono">HardwareDriver.json</span> and every line of the WinPE {% elif chk.state == 'interrupted' %}
BIOS manifest <span class="mono">models.txt</span> with Dell's current catalogs. Downloads are checked against Dell's sha256, <div class="notice">The last check stopped before it finished. Run it again.</div>
driver packs are repacked into the zip PESetup expects, and nothing on the share changes until you preview and apply. {% endif %}
Applying puts the new file next to the one it replaces, rewrites the maps and <span class="mono">models.txt</span> (backed {% if prog.download_running %}
up first, never reordered) and never deletes old zips or BIOS files. <div class="panel strip" role="status">
</p> <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>
{# ---- Catalogs, check job, speed cap ------------------------------------- #} <form method="post" action="{{ url_for('dell_updates.cancel') }}"
<div class="section-card"> onsubmit="return confirm('Stop the download? Partial files are kept and resume next time.');">
<div class="section-title"><i class="bi bi-cloud-arrow-down"></i> Dell catalogs</div> <input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="row g-3 align-items-end"> <input type="hidden" name="job" value="download">
<div class="col-lg-8"> <button type="submit" class="ui-btn sm">Cancel download</button>
{% if prog.check_running %} </form>
<div id="checkJob">
<div class="small mb-1"><span class="spinner-border spinner-border-sm"></span>
<span id="checkStep">{{ chk.step or 'Starting' }}...</span></div>
</div>
{% elif chk.state == 'error' %}
<div class="alert alert-danger py-2 mb-2">Last check failed: {{ chk.error }}</div>
{% elif chk.state == 'interrupted' %}
<div class="alert alert-warning py-2 mb-2">The last check stopped before it finished. Run it again.</div>
{% endif %}
{% if scan %}
<div>Last check: <strong>{{ scan.checked | local_dt }}</strong></div>
{% if history and history[0].at and scan.checked and history[0].at > scan.checked %}
<div class="alert alert-warning py-2 my-2"><i class="bi bi-info-circle"></i>
These results are from before the last apply ({{ history[0].at | local_dt }}), so rows it changed can still
show as updates. Run <strong>Check for updates</strong> again to refresh them.</div>
{% endif %}
<div class="small text-muted">
{% set cats = scan.catalogs or {} %}
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' }}.
{% if scan.cab_errors %}<span class="text-danger">{{ scan.cab_errors | length }} per-model catalog(s) failed to load.</span>{% endif %}
</div>
{% for e in scan.map_errors %}<div class="small text-danger"><i class="bi bi-exclamation-triangle"></i> {{ e }}</div>{% endfor %}
{% elif not prog.check_running %}
<p class="text-muted mb-0">Not checked yet. <strong>Check for updates</strong> fetches Dell's two catalog files (under 1 MB)
and the small per-model BIOS catalogs it needs, then compares.</p>
{% endif %}
</div>
<div class="col-lg-4 d-flex justify-content-lg-end">
<form method="post" action="{{ url_for('dell_updates.save_settings') }}" class="d-flex gap-2 align-items-end">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="form-field mb-0">
<label for="maxMbps" class="form-label">Speed cap (MB/s)</label>
<input type="number" class="form-control form-control-sm" id="maxMbps" name="max_mbps"
min="0" max="1000" step="0.5" value="{{ '%g'|format(settings.max_mbps) }}" style="width: 7rem;">
</div>
<button type="submit" class="pxe-btn pxe-btn-ghost pxe-btn-sm" title="Shared by every download on the Updates pages; 0 = no cap. The site link is about 400 Mbps (50 MB/s).">Save</button>
</form>
</div>
</div>
</div> </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 %}
{% if scan %} <details>
<form method="post" action="{{ url_for('dell_updates.download') }}" id="dlForm" <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.');"> 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() }}"> <input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
{# ---- Driver packs ------------------------------------------------------ #} {# ---- Driver packs ------------------------------------------------------ #}
<div class="section-card"> {% set cur_d = scan.drivers | selectattr('status', 'equalto', 'current') | list %}
<div class="section-title d-flex flex-wrap align-items-center gap-2"> <section class="panel ui-list" id="drvTable" aria-labelledby="drv-h">
<span><i class="bi bi-hdd-network"></i> Win11 driver packs</span> <div class="sec-h">
<span class="badge badge-secondary">{{ scan.drivers | length }}</span> <div><h2 id="drv-h" class="sec">Win11 driver packs</h2>
<label class="small text-muted ms-auto"><input type="checkbox" class="form-check-input js-show-current" data-table="drvTable"> Show current rows</label> <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>
<p class="small text-muted"> <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>
One row per zip in use; standard and engineer share their zips. Updates are ticked unless a warning needs a decision. {% for r in scan.drivers if r.status != 'current' %}{{ drv_row(r) }}{% endfor %}
An entry whose current pack carries NVIDIA drivers is skipped at apply time if the Dell pack has no NVIDIA INFs. {% if cur_d %}
</p> <details class="fold">
<div class="table-container"> <summary class="disc"><span class="chev" aria-hidden="true">&#9662;</span>Show {{ cur_d | length }} current pack{{ 's' if cur_d | length != 1 }}</summary>
<table class="data-table" id="drvTable"> {% for r in cur_d %}{{ drv_row(r) }}{% endfor %}
<thead> </details>
<tr><th></th><th>Models (WMI)</th><th>Images</th><th>Current file</th><th>Dell pack</th><th>Status</th></tr> {% endif %}
</thead> <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>
<tbody> </section>
{% for r in scan.drivers %}
<tr data-status="{{ r.status }}">
<td>{% if r.pack and r.status != 'current' or r.pack and r.warnings %}<input type="checkbox" class="form-check-input" name="drv" value="{{ r.id }}" {% if r.default %}checked{% endif %}>{% endif %}</td>
<td class="small wrap">{{ r.tokens | join(', ') }}</td>
<td class="small" style="white-space: normal; min-width: 6rem;">{% for i in r.images %}{{ i | replace('gea-', '') }}{% if not loop.last %}, {% endif %}{% endfor %}</td>
<td class="wrap"><span class="mono small">{{ r.current }}</span>{% if r.cur_ver %} <span class="badge badge-secondary">{{ r.cur_ver }}</span>{% endif %}</td>
<td class="wrap">
{% if r.pack %}
<span class="mono small" title="{{ r.pack.models | join(', ') }}">{{ r.pack.name }}</span>
<div class="small text-muted">{{ r.pack.ver }}, {{ r.pack.date }}, {{ size(r.pack.size) }}, stages as <span class="mono">{{ r.pack.zip }}</span></div>
{% else %}<span class="text-muted">-</span>{% endif %}
</td>
<td class="wrap">{{ drv_badge(r.status) }}
{% for w in r.warnings %}<div class="small text-danger mt-1"><i class="bi bi-exclamation-triangle"></i> {{ w }}</div>{% endfor %}
{% for n in r.notes %}<div class="small text-muted mt-1">{{ n }}</div>{% endfor %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{# ---- BIOS -------------------------------------------------------------- #} {# ---- BIOS -------------------------------------------------------------- #}
<div class="section-card"> {% set cur_b = scan.bios | selectattr('status', 'equalto', 'current') | list %}
<div class="section-title d-flex flex-wrap align-items-center gap-2"> <section class="panel ui-list" id="biosTable" aria-labelledby="bios-h">
<span><i class="bi bi-cpu"></i> BIOS (models.txt)</span> <div class="sec-h">
<span class="badge badge-secondary">{{ scan.bios | length }}</span> <div><h2 id="bios-h" class="sec">BIOS (models.txt)</h2>
<label class="small text-muted ms-auto"><input type="checkbox" class="form-check-input js-show-current" data-table="biosTable"> Show current rows</label> <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>
<p class="small text-muted"> <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>
One row per manifest line. check-bios.cmd flashes the file of the FIRST line whose text is in the machine's model name, {% for r in scan.bios if r.status != 'current' %}{{ bios_row(r) }}{% endfor %}
so each Dell model is credited to the first line that catches it. Applying changes only the file and version fields {% if cur_b %}
of the ticked lines; their order and the comments stay as they are. <details class="fold">
</p> <summary class="disc"><span class="chev" aria-hidden="true">&#9662;</span>Show {{ cur_b | length }} current line{{ 's' if cur_b | length != 1 }}</summary>
<div class="table-container"> {% for r in cur_b %}{{ bios_row(r) }}{% endfor %}
<table class="data-table" id="biosTable"> </details>
<thead> {% endif %}
<tr><th></th><th>Line</th><th>Current file</th><th>Dell latest</th><th>Status</th></tr> <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>
</thead> <div class="list-foot">
<tbody> <button type="submit" class="ui-btn pri" {% if prog.download_running or prog.check_running %}disabled{% endif %}>Download selected</button>
{% for r in scan.bios %} <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)' }};
<tr data-status="{{ r.status }}"> you can leave this page. Already staged items are not fetched again.</span>
<td>{% if r.candidates and (r.status != 'current') %}<input type="checkbox" class="form-check-input" name="bios" value="{{ r.id }}" {% if r.default %}checked{% endif %}>{% endif %}</td>
<td class="mono small text-nowrap">{{ r.sub }}</td>
<td class="wrap"><span class="mono small">{{ r.current }}</span> <span class="badge badge-secondary">{{ r.cur_ver }}</span></td>
<td class="wrap">
{% if r.candidates | length > 1 %}
<select class="form-control form-control-sm" name="pick_{{ r.id }}" style="max-width: 28rem;">
{% 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 small" title="sha256 {{ c.sha256 }}">{{ c.file }}</span>
<div class="small text-muted">{{ c.version }}, {{ c.date }}, {{ size(c.size) }}; {{ c.models | join(', ') | truncate(90) }}</div>
{% else %}<span class="text-muted">-</span>{% endif %}
</td>
<td class="wrap">{{ bios_badge(r.status) }}
{% for w in r.warnings %}<div class="small text-danger mt-1"><i class="bi bi-exclamation-triangle"></i> {{ w }}</div>{% endfor %}
{% for n in r.notes %}<div class="small text-muted mt-1">{{ n }}</div>{% endfor %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div> </div>
</div> </section>
<div class="d-flex flex-wrap gap-2 align-items-center mb-4">
<button type="submit" class="pxe-btn pxe-btn-primary" {% if prog.download_running or prog.check_running %}disabled{% endif %}>
<i class="bi bi-download"></i> Download selected
</button>
<span class="small text-muted">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>
</form> </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 ---------------------------------------------------- #} {# ---- Missing coverage ---------------------------------------------------- #}
{% if scan.missing %} {% if scan.missing %}
<div class="section-card"> <section class="panel ui-list" aria-labelledby="miss-h">
<div class="section-title"><i class="bi bi-question-diamond"></i> Seen models without coverage <span class="badge badge-secondary">{{ scan.missing | length }}</span></div> <details>
<p class="small text-muted">Machines that booted WinPE here with no Win11 driver entry or no BIOS line. For information only: <summary class="sec-h" style="align-items: center">
new entries are added by hand (see Hardware Coverage).</p> <div><h2 id="miss-h" class="sec" style="font-size: 15px">Seen models without coverage</h2>
<div class="table-container"> <div class="sub">For information only: new entries are added by hand (see Hardware coverage).</div></div>
<table class="data-table"> <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>
<thead><tr><th>Model</th><th>SKU</th><th>Missing</th><th>Dell pack that would cover it</th><th>Dell BIOS</th></tr></thead> </summary>
<tbody> {% for m in scan.missing %}
{% for m in scan.missing %} <div class="mgrid">
<tr> <div><span class="mono">{{ m.model }}</span><div class="sub">SKU {{ m.skus | join(', ') or '-' }}. Missing
<td class="mono small">{{ m.model }}</td> {% 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>
<td class="mono small">{{ m.skus | join(', ') or '-' }}</td> <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>
<td class="small wrap">{% 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 %}</td> <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>
<td class="small wrap">{% if m.pack %}<span class="mono">{{ m.pack.name }}</span> ({{ m.pack.ver }}, {{ size(m.pack.size) }}){% elif m.no_drivers %}<span class="text-muted">none found</span>{% else %}-{% endif %}</td> </div>
<td class="small wrap">{% if m.bios %}<span class="mono">{{ m.bios.file }}</span> ({{ m.bios.version }}){% elif m.no_bios %}<span class="text-muted">none found</span>{% else %}-{% endif %}</td> {% endfor %}
</tr> </details>
{% endfor %} </section>
</tbody>
</table>
</div>
</div>
{% endif %} {% endif %}
{% endif %} {% endif %}
{# ---- Staged downloads ---------------------------------------------------- #} {# ---- Staged downloads ---------------------------------------------------- #}
<div class="section-card" id="staged"> <section class="panel ui-list" id="staged" aria-labelledby="st-h">
<div class="section-title d-flex flex-wrap align-items-center gap-2"> <div class="sec-h">
<span><i class="bi bi-box-seam"></i> Staged downloads</span> <div><h2 id="st-h" class="sec">Staged downloads</h2>
{% if dl.state %}<span class="small text-muted">last run {{ dl.state }}{% if dl.finished %} {{ dl.finished | local_dt }}{% endif %}{% if dl.error %}: {{ dl.error }}{% endif %}</span>{% endif %} <div class="sub">Tick what to apply, then preview. Nothing on the share changes until you apply.</div></div>
{% if prog.download_running %} {% if dl.state %}<span class="sub">Last run {{ dl.state }}{% if dl.finished %} {{ dl.finished | local_dt }}{% endif %}</span>{% endif %}
<form method="post" action="{{ url_for('dell_updates.cancel') }}" class="ms-auto"
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="pxe-btn pxe-btn-ghost pxe-btn-sm"><i class="bi bi-x-circle"></i> Cancel download</button>
</form>
{% endif %}
</div> </div>
{% if dl.error %}<div class="errbox" style="margin: 0 24px 12px">{{ dl.error }}</div>{% endif %}
{% if staged %} {% if staged %}
<form method="post" action="{{ url_for('dell_updates.preview') }}"> <form method="post" action="{{ url_for('dell_updates.preview') }}">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="table-container"> {% for it in staged %}
<table class="data-table"> {% set label = it.zip if it.kind == 'driver' else it.name %}
<thead><tr><th></th><th>Item</th><th>Replaces</th><th>Size</th><th>Status</th></tr></thead> <div class="ui-row dl-row">
<tbody> {% if it.status in ('ready', 'error', 'cancelled', 'interrupted', 'applied') %}
{% for it in staged %} <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 %}>
<tr> <label for="key-{{ it.key }}" class="vh">Select {{ label }}</label></div>
<td>{% if it.status in ('ready', 'error', 'cancelled', 'interrupted', 'applied') %}<input type="checkbox" class="form-check-input" name="key" value="{{ it.key }}" {% if it.status == 'ready' %}checked{% endif %}>{% endif %}</td> {% endif %}
<td class="wrap"> <details {% if it.error %}open{% endif %}>
<span class="mono small">{{ it.zip if it.kind == 'driver' else it.name }}</span> <summary class="sgrid">
<div class="small text-muted">{{ 'Driver pack' if it.kind == 'driver' else 'BIOS' }} {{ it.ver }}{% if it.kind == 'driver' %} from <span class="mono">{{ it.name }}</span>{% endif %} <span class="tip" data-tip="{{ label }}"><span class="trunc mono">{{ label }}</span></span>
{% if it.infs %}; {{ it.infs }} INFs, {{ it.nvidia_infs }} NVIDIA{% endif %}</div> <span class="sub c-kind">{{ 'Driver pack' if it.kind == 'driver' else 'BIOS' }} {{ it.ver }}</span>
</td> <span class="sub c-size">{{ size(it.size_file or it.size) }}</span>
<td class="small wrap"> <span id="st-{{ it.key }}" class="stat">
{% if it.kind == 'driver' %}{% for c in it.replaces %}<div class="mono">{{ c }}</div>{% endfor %} {{ stage_chip(it.status) }}
{% else %}{% for ln in it.lines %}<div class="mono">{{ ln.sub }}</div>{% endfor %}{% endif %} {% if it.status in ('queued', 'downloading', 'verified', 'extracting', 'zipping') %}
</td> <span class="bar" aria-hidden="true"><i style="width: {{ it.percent or 0 }}%"></i></span>
<td class="small text-nowrap">{{ size(it.size_file or it.size) }}</td> <span class="sub js-pct">{{ it.percent if it.percent is not none else 0 }}%</span>
<td id="st-{{ it.key }}" class="wrap" style="min-width: 10rem;"> {% endif %}
{{ stage_badge(it.status) }} </span>
{% if it.status in ('queued', 'downloading', 'verified', 'extracting', 'zipping') %} <span class="chev" aria-hidden="true">&#9662;</span>
<div class="progress mt-1" style="height: 6px;"><div class="progress-bar" style="width: {{ it.percent or 0 }}%;"></div></div> </summary>
<div class="small text-muted js-pct">{{ it.percent if it.percent is not none else 0 }}%</div> <div class="dbody">
{% endif %} {% if it.error %}<div class="errbox">{{ it.error }}</div>{% endif %}
{% if it.error %}<div class="small text-danger">{{ it.error }}</div>{% endif %} <dl class="kv">
{% if it.applied %}<div class="small text-muted">apply {{ it.applied }}</div>{% endif %} {% if it.kind == 'driver' %}
</td> <dt>Dell file</dt><dd class="mono">{{ it.name }}</dd>
</tr> <dt>Replaces</dt><dd>{% for c in it.replaces %}<div class="mono">{{ c }}</div>{% endfor %}</dd>
{% endfor %} {% if it.infs %}<dt>Contents</dt><dd>{{ it.infs }} INFs, {{ it.nvidia_infs }} NVIDIA</dd>{% endif %}
</tbody> {% else %}
</table> <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> </div>
<div class="d-flex flex-wrap gap-2 mt-2"> {% endfor %}
<button type="submit" class="pxe-btn pxe-btn-primary" {% if prog.download_running %}disabled{% endif %}><i class="bi bi-eye"></i> Preview apply</button> <div class="list-foot">
<button type="submit" class="pxe-btn pxe-btn-ghost" formaction="{{ url_for('dell_updates.discard') }}" <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.');" onclick="return confirm('Delete the ticked items from the staging area? Files on the share are not touched.');"
{% if prog.download_running %}disabled{% endif %}><i class="bi bi-trash"></i> Discard selected</button> {% if prog.download_running %}disabled{% endif %}>Discard selected</button>
</div> </div>
</form> </form>
{% else %} {% else %}
<p class="text-muted mb-0">Nothing staged. Tick rows above and use <strong>Download selected</strong>.</p> <div class="hidden-bar">Nothing staged. Tick rows above and use Download selected.</div>
{% endif %} {% endif %}
</div> </section>
{# ---- Preview ------------------------------------------------------------- #} {# ---- Preview ------------------------------------------------------------- #}
{% if preview %} {% if preview %}
<div class="section-card" id="preview"> <section class="panel" id="preview" aria-labelledby="pv-h" style="padding: 18px 24px; display: flex; flex-direction: column; gap: 12px">
<div class="section-title"><i class="bi bi-file-diff"></i> Preview: what Apply will change</div> <h2 id="pv-h" class="sec">Preview: what Apply will change</h2>
{% for image, rows in preview.drivers.items() %} {% for image, rows in preview.drivers.items() %}
<h6 class="mt-2">{{ image }} <span class="text-muted small">HardwareDriver.json</span></h6> <div>
{% if rows %} <div style="font-weight: 600; margin-bottom: 6px">{{ image }} <span class="sub mono">HardwareDriver.json</span></div>
<div class="table-container"> {% if rows %}
<table class="table table-sm mb-2"> <ul class="pv">
<thead><tr><th>Entry models</th><th>Old file</th><th></th><th>New file</th><th>Folder</th></tr></thead> {% for r in rows %}
<tbody> <li><span class="mono old">{{ r.old }}</span><span class="sub">to</span><span class="mono new">{{ r.new }}</span>
{% for r in rows %} <span class="sub why">{{ r.models }} | <span class="mono">{{ r.dir }}</span></span></li>
<tr><td class="small wrap">{{ r.models }}</td><td class="mono small wrap">{{ r.old }}</td><td class="text-muted">to</td> {% endfor %}
<td class="mono small wrap">{{ r.new }}</td><td class="mono small text-muted wrap">{{ r.dir }}</td></tr> </ul>
{% endfor %} {% else %}<div class="sub">No change.</div>{% endif %}
</tbody>
</table>
</div> </div>
{% else %}<p class="small text-muted">No change.</p>{% endif %}
{% endfor %} {% endfor %}
<h6 class="mt-3">models.txt</h6> <div>
{% if preview.bios %} <div style="font-weight: 600; margin-bottom: 6px">models.txt</div>
<div class="table-container"> {% if preview.bios %}
<table class="table table-sm mb-2"> <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>
<thead><tr><th>Old line</th><th></th><th>New line</th></tr></thead> {% else %}<div class="sub">No change.</div>{% endif %}
<tbody>
{% for b in preview.bios %}<tr><td class="mono small wrap">{{ b.old }}</td><td class="text-muted">to</td><td class="mono small wrap">{{ b.new }}</td></tr>{% endfor %}
</tbody>
</table>
</div> </div>
{% else %}<p class="small text-muted">No change.</p>{% endif %} {% if preview.errors %}<div class="errbox">{{ preview.errors | join('; ') }}</div>{% endif %}
{% for e in preview.errors %}<div class="small text-danger"><i class="bi bi-exclamation-triangle"></i> {{ e }}</div>{% endfor %} {% for s in preview.skipped %}<div class="notice">Will skip {{ s }}</div>{% endfor %}
{% for s in preview.skipped %}<div class="small text-warning"><i class="bi bi-exclamation-triangle"></i> Will skip {{ s }}</div>{% endfor %} {% if preview.missing %}<div class="sub">Not ready, ignored: {{ preview.missing | join(', ') }}</div>{% endif %}
{% if preview.missing %}<div class="small text-muted">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"
<form method="post" action="{{ url_for('dell_updates.apply') }}" class="mt-3"
onsubmit="return confirm('Apply these changes to the live share? The maps and models.txt are backed up first; old files are kept.');"> 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() }}"> <input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
{% for k in preview_keys %}<input type="hidden" name="key" value="{{ k }}">{% endfor %} {% for k in preview_keys %}<input type="hidden" name="key" value="{{ k }}">{% endfor %}
<button type="submit" class="pxe-btn pxe-btn-success"><i class="bi bi-check2-circle"></i> Apply</button> <a href="{{ url_for('dell_updates.page') }}" class="ui-btn">Cancel</a>
<a href="{{ url_for('dell_updates.page') }}" class="pxe-btn pxe-btn-ghost">Cancel</a> <button type="submit" class="ui-btn pri">Apply</button>
</form> </form>
</div> </section>
{% endif %} {% endif %}
{# ---- History ------------------------------------------------------------- #} {# ---- History ------------------------------------------------------------- #}
{% if history %} {% if history %}
<div class="section-card"> <section class="panel ui-list" aria-labelledby="h-h">
<div class="section-title"><i class="bi bi-clock-history"></i> Applied</div> <div class="sec-h"><div><h2 id="h-h" class="sec">Applied</h2>
<div class="table-container"> <div class="sub">Rollback opens a review page first; it restores only files nobody has changed since the apply.</div></div></div>
<table class="data-table"> {% for h in history %}
<thead><tr><th>When</th><th>Changes</th><th>Backups</th><th class="text-end"></th></tr></thead> {% set can = not h.rolled_back and h.unchanged > 0 %}
<tbody> <div class="ui-row">
{% for h in history %} <details>
<tr> <summary class="agrid">
<td class="mono small text-nowrap">{{ h.at | local_dt }}<div class="text-muted">{{ h.id }}</div></td> <span class="mono sub when">{{ h.at | local_dt }}</span>
<td class="small wrap"> <span class="tip" data-tip="{{ h.title }}"><span class="trunc">{{ h.title }}</span></span>
{% for d in h.drivers %}<div><span class="mono">{{ d.old }}</span> to <span class="mono">{{ d.new }}</span></div>{% endfor %} <span>
{% for b in h.bios %}<div><span class="mono">{{ b.new }}</span></div>{% endfor %} {% if h.rolled_back %}{{ ui.chip('Rolled back', 'neutral') }}
{% for s in h.skipped %}<div class="text-warning">skipped {{ s }}</div>{% endfor %} {% elif h.errors and not h.total %}{{ ui.chip('Apply failed', 'err') }}
{% for e in h.errors %}<div class="text-danger">{{ e }}</div>{% endfor %} {% elif not h.total %}{{ ui.chip('Nothing written', 'off') }}
</td> {% elif h.unchanged == h.total %}{{ ui.chip(h.unchanged ~ '/' ~ h.total ~ ' unchanged', 'ok') }}
<td class="small wrap">{% for p, b in h.backups.items() %}<div class="mono">{{ b.rsplit('/', 1)[-1] }}</div>{% endfor %}</td> {% elif h.unchanged %}{{ ui.chip(h.unchanged ~ '/' ~ h.total ~ ' unchanged', 'warn') }}
<td class="text-end text-nowrap"> {% else %}{{ ui.chip('0/' ~ h.total ~ ' unchanged', 'off') }}{% endif %}
{% if h.rolled_back %}<span class="badge badge-secondary">Rolled back</span> </span>
{% elif h.backups %} <span class="chev" aria-hidden="true">&#9662;</span>
<form method="post" action="{{ url_for('dell_updates.rollback') }}" style="display:inline;" </summary>
onsubmit="return confirm('Put the maps and models.txt back as they were before {{ h.id }}? Files changed again since are left alone. The new zips and BIOS files stay on the share.');"> <div class="abody">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}"> <ul>{% for c in h.changes %}<li>{{ c }}</li>{% endfor %}
<input type="hidden" name="apply_id" value="{{ h.id }}"> {% for d in h.drivers %}<li class="sub"><span class="mono">{{ d.old }}</span> to <span class="mono">{{ d.new }}</span></li>{% endfor %}</ul>
<button type="submit" class="pxe-btn pxe-btn-ghost btn-row-action"><i class="bi bi-arrow-counterclockwise"></i> Rollback</button> {% if h.skipped %}<div class="notice">Skipped: {{ h.skipped | join('; ') }}</div>{% endif %}
</form> {% if h.errors %}<div class="errbox">{{ h.errors | join('; ') }}</div>{% endif %}
{% endif %} {% if h.files %}
</td> <ul>
</tr> {% for f in h.files %}
{% endfor %} <li><span class="mono">{{ f.label }}</span>
</tbody> {% if f.state == 'unchanged' %}<span class="muted">unchanged since this apply</span>
</table> {% 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) }}">Review rollback</a></div>{% endif %}
</div> </div>
</div> {% endfor %}
</section>
{% endif %} {% endif %}
<script>
// Hide rows that need nothing unless asked; without JS every row shows.
(function () {
var boxes = document.querySelectorAll('.js-show-current');
function paint(box) {
var t = document.getElementById(box.dataset.table);
if (!t) return;
t.querySelectorAll('tbody tr[data-status=current]').forEach(function (tr) {
var tick = tr.querySelector('input[type=checkbox]');
if (!tick || !tick.checked) tr.style.display = box.checked ? '' : 'none';
});
}
boxes.forEach(function (b) { b.addEventListener('change', function () { paint(b); }); paint(b); });
})();
</script>
</div> </div>
{% if prog.check_running or prog.download_running %} {% if prog.check_running or prog.download_running %}
@@ -429,9 +518,9 @@
Object.keys(j.download.items || {}).forEach(function (k) { Object.keys(j.download.items || {}).forEach(function (k) {
var it = j.download.items[k], cell = document.getElementById('st-' + k); var it = j.download.items[k], cell = document.getElementById('st-' + k);
if (!cell) return; if (!cell) return;
var badge = cell.querySelector('.badge'); var chip = cell.querySelector('.chip');
if (badge) badge.textContent = it.status.charAt(0).toUpperCase() + it.status.slice(1); if (chip && it.status) chip.textContent = it.status.charAt(0).toUpperCase() + it.status.slice(1);
var bar = cell.querySelector('.progress-bar'), pct = cell.querySelector('.js-pct'); var bar = cell.querySelector('.bar > i'), pct = cell.querySelector('.js-pct');
if (bar && it.percent != null) bar.style.width = it.percent + '%'; if (bar && it.percent != null) bar.style.width = it.percent + '%';
if (pct && it.percent != null) pct.textContent = it.percent + '%'; if (pct && it.percent != null) pct.textContent = it.percent + '%';
}); });

View File

@@ -1,322 +1,424 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}iPXE Boot Files - PXE Server Manager{% endblock %} {% import '_ui.html' as ui %}
{% block title %}iPXE boot files - PXE Manager{% endblock %}
{% macro sig_badge(info, chain_ok=none) -%} {# Signer as a short chip; the long explanation goes in the row body (it is
{% if info.kind == 'microsoft-2011' %}<span class="badge badge-warning" title="Signed only under Microsoft Corporation UEFI CA 2011. That CA's signing period ended in 2026: machines that trust only the 2023 CA, or a DBX/SBAT revocation update, will refuse this file.">Microsoft UEFI CA 2011 only</span> a safety warning, so never hover-only). #}
{% elif info.kind == 'microsoft-2023' %}<span class="badge badge-success" title="Signed under Microsoft UEFI CA 2023">Microsoft UEFI CA 2023</span> {% macro sig_chip(info) -%}
{% elif info.kind == 'microsoft' %}<span class="badge badge-info">Microsoft (other CA)</span> {% if info.kind == 'microsoft-2011' %}{{ ui.chip('MS UEFI CA 2011 only', 'warn') }}
{% elif info.kind == 'ipxe' %}<span class="badge badge-success" title="Chains to the iPXE Secure Boot CA, which the iPXE shim trusts">iPXE Secure Boot CA</span> {% elif info.kind == 'microsoft-2023' %}{{ ui.chip('MS UEFI CA 2023', 'ok') }}
{% elif info.kind == 'bios' %}<span class="badge badge-secondary" title="Legacy BIOS has no Secure Boot">Legacy BIOS, unsigned</span> {% elif info.kind == 'microsoft' %}{{ ui.chip('Microsoft, other CA', 'neutral') }}
{% elif info.kind == 'unsigned' %}<span class="badge badge-danger" title="Secure Boot firmware will refuse it">Unsigned</span> {% elif info.kind == 'ipxe' %}{{ ui.chip('iPXE Secure Boot CA', 'ok') }}
{% elif info.kind == 'error' %}<span class="badge badge-danger" title="{{ info.sig_error }}">Not checked</span> {% elif info.kind == 'bios' %}{{ ui.chip('Legacy BIOS', 'neutral') }}
{% else %}<span class="badge badge-warning">Other signer</span>{% endif %} {% elif info.kind == 'unsigned' %}{{ ui.chip('Unsigned', 'err') }}
{% if chain_ok == false %}<span class="badge badge-danger" title="The installed shim trusts {{ info.trusts_needed }}; this file is not signed under it, so the shim will refuse to start it">Shim will refuse</span>{% endif %} {% elif info.kind == 'error' %}{{ ui.chip('Not checked', 'err') }}
{% else %}{{ ui.chip('Other signer', 'warn') }}{% endif %}
{%- endmacro %}
{% macro sig_text(info, chain_ok=none) -%}
{% if info.kind == 'microsoft-2011' %}<div class="notice">Signed only under Microsoft Corporation UEFI CA 2011. That CA's signing period ended in 2026: machines that trust only the 2023 CA, or a DBX/SBAT revocation update, will refuse this file.</div>
{% elif info.kind == 'microsoft-2023' %}<div class="sub">Signed under Microsoft UEFI CA 2023.</div>
{% elif info.kind == 'ipxe' %}<div class="sub">Chains to the iPXE Secure Boot CA, which the iPXE shim trusts.</div>
{% elif info.kind == 'bios' %}<div class="sub">Legacy BIOS has no Secure Boot; the file is unsigned by design.</div>
{% elif info.kind == 'unsigned' %}<div class="notice bad">Unsigned: Secure Boot firmware will refuse it.</div>
{% elif info.kind == 'error' %}<div class="notice bad">Signature not checked: {{ info.sig_error }}</div>
{% elif info.kind == 'microsoft' %}<div class="sub">Signed by Microsoft under a CA other than UEFI CA 2011 or 2023.</div>
{% elif info.kind %}<div class="notice">Signed by a signer that is neither Microsoft nor the iPXE CA.</div>{% endif %}
{% if chain_ok == false %}<div class="notice bad">Shim will refuse: the installed shim trusts <span class="mono">{{ info.trusts_needed }}</span>; this file is not signed under it, so the shim will not start it.</div>{% endif %}
{%- endmacro %} {%- endmacro %}
{% macro chain(info) -%} {% macro chain(info) -%}
{% if info.signers %} {% if info.signers %}
<details class="mt-1"> <div><span class="sub">Signer chain ({{ info.signers | length }})</span>
<summary class="small text-muted" style="cursor:pointer;">Signer chain ({{ info.signers | length }})</summary>
{% for subject, issuer in info.signers %} {% for subject, issuer in info.signers %}
<div class="small"><span class="mono">{{ subject }}</span> <span class="text-muted">issued by</span> <span class="mono">{{ issuer }}</span></div> <div><span class="mono">{{ subject }}</span> <span class="muted">issued by</span> <span class="mono">{{ issuer }}</span></div>
{% endfor %} {% endfor %}
</details> </div>
{% endif %} {% endif %}
{% if info.trusts %}<div class="small text-muted">Starts second stages signed by <span class="mono">{{ info.trusts }}</span></div>{% endif %} {% if info.trusts %}<div class="sub">Starts second stages signed by <span class="mono">{{ info.trusts }}</span></div>{% endif %}
{% if info.sbat %}<div class="small text-muted">SBAT: <span class="mono">{{ info.sbat }}</span></div>{% endif %} {% if info.sbat %}<div class="sub">SBAT: <span class="mono">{{ info.sbat }}</span></div>{% endif %}
{%- endmacro %} {%- 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; }
.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; } }
.bar { flex: 1 1 160px; height: 6px; border-radius: 3px; background: var(--track-empty); overflow: hidden; min-width: 60px; max-width: 360px; }
.bar > div { height: 100%; background: var(--run); }
.igrid { display: grid; grid-template-columns: 190px minmax(0, 1fr) 190px 14px; align-items: center; gap: 0 16px;
padding: 12px 24px; font-size: 14px; min-height: 24px; }
.igrid.picked { padding-left: 56px; }
.ihead { padding-top: 4px; padding-bottom: 6px; font-size: 12px; }
.ibody { padding: 2px 24px 16px 24px; font-size: 13px; display: flex; flex-direction: column; gap: 8px; }
.picked + .ibody, .ibody.picked { padding-left: 56px; }
.kv { display: grid; grid-template-columns: 120px 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; }
.i-row > .pick { position: absolute; left: 20px; top: 13px; z-index: 1; }
.i-row > .pick input { width: 18px; height: 18px; margin: 0; }
.name { display: flex; align-items: baseline; gap: 8px; min-width: 0; white-space: nowrap; }
.igrid .name { overflow: hidden; }
.rgrid { display: grid; grid-template-columns: 210px minmax(0, 1fr) 14px; align-items: center; gap: 0 16px; padding: 12px 24px; font-size: 14px; }
.bgrid { display: grid; grid-template-columns: 160px minmax(0, 1fr) minmax(0, 1fr) 14px; align-items: center; gap: 0 16px;
padding: 12px 80px 12px 24px; font-size: 14px; min-height: 24px; }
.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; }
.rel-h { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; padding: 12px 24px; border-top: 1px solid var(--line-2); background: var(--surface-2); font-size: 13px; }
pre.cmd { white-space: pre-wrap; word-break: break-all; margin: 8px 0 4px; padding: 10px 12px; border-radius: 8px; background: var(--surface-2); border: 1px solid var(--line); font-size: 12px; color: var(--text); }
.howto .mono { overflow-wrap: anywhere; }
.howto { margin: 0; padding-left: 20px; display: flex; flex-direction: column; gap: 14px; font-size: 13px; line-height: 1.6; }
@container (max-width: 900px) {
.igrid { grid-template-columns: 205px minmax(0, 1fr) 170px 14px; }
.bgrid { grid-template-columns: 150px minmax(0, 1fr) 14px; }
.bgrid > :nth-child(3) { display: none; }
}
@container (max-width: 560px) {
.bgrid > :nth-child(3) { display: block; }
.igrid { grid-template-columns: minmax(0, 1fr) auto 14px; padding: 12px 16px; gap: 4px 10px; }
.igrid.picked { padding-left: 50px; }
.igrid .c-ver { grid-column: 1 / 2; grid-row: 2; font-size: 12px; color: var(--muted); }
.ihead { display: none; }
.ibody, .picked + .ibody { padding: 2px 16px 14px; }
.i-row > .pick { left: 16px; }
.kv { grid-template-columns: minmax(0, 1fr); }
.kv dd { margin-bottom: 6px; }
.rgrid { grid-template-columns: minmax(0, 1fr) 14px; padding: 12px 16px; gap: 4px 10px; }
.rgrid .c-who { grid-column: 1 / 2; grid-row: 2; }
.bgrid { grid-template-columns: minmax(0, 1fr) 14px; padding: 12px 70px 12px 16px; gap: 2px 10px; }
.bgrid > :nth-child(2), .bgrid > :nth-child(3) { grid-column: 1 / 2; }
.bgrid > .chev { grid-column: 2; grid-row: 1; }
.ui-row > .act { right: 12px; }
.sec-h, .list-foot, .rel-h { padding-left: 16px; padding-right: 16px; }
}
</style>
{% endblock %}
{% block content %} {% block content %}
<div class="page-header"> <header class="ui-head">
<h1><i class="bi bi-shield-lock"></i> iPXE Boot Files</h1> <div>
<div class="header-actions"> <div class="eyebrow-t">Updates</div>
{% if status == 'update' %}<span class="badge badge-warning badge-lg">Update available: {{ latest.tag }}</span> <h1>iPXE boot files</h1>
{% elif status == 'current' %}<span class="badge badge-success badge-lg">Up to date ({{ latest.tag }})</span> <div class="state-line">
{% elif status == 'newer' %}<span class="badge badge-info badge-lg">Ahead of {{ latest.tag }}</span> {% if status == 'update' %}{{ ui.chip('Update available: ' ~ latest.tag, 'act') }}
{% elif latest %}<span class="badge badge-secondary badge-lg">Latest: {{ latest.tag }}</span>{% endif %} {% elif status == 'current' %}{{ ui.chip('Up to date (' ~ latest.tag ~ ')', 'ok') }}
<form method="post" action="{{ url_for('ipxe.check') }}" style="display:inline;"> {% elif status == 'newer' %}{{ ui.chip('Ahead of ' ~ latest.tag, 'neutral') }}
{% elif latest %}{{ ui.chip('Latest: ' ~ latest.tag, 'neutral') }}
{% else %}{{ ui.chip('Upstream not checked', 'off') }}{% endif %}
{% if latest %}<span class="sub">Checked {{ latest.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('ipxe.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">Applies to every download on the Updates pages (Dell and iPXE); 0 means no cap. The site link is about 400 Mbps (50 MB/s).</span>
</form>
</details>
{% set can_stage = latest and latest.sha256 and job.state not in ('queued', 'running') %}
<form method="post" action="{{ url_for('ipxe.check') }}" style="margin: 0">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="pxe-btn pxe-btn-secondary"><i class="bi bi-arrow-repeat"></i> Check for updates</button> <button type="submit" class="ui-btn{{ ' pri' if status != 'update' }}">Check now</button>
</form>
<form method="post" action="{{ url_for('ipxe.stage') }}" style="margin: 0">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="ui-btn{{ ' pri' if status == 'update' }}" {% if not can_stage %}disabled{% endif %}>Download and verify</button>
</form> </form>
</div> </div>
</div> </header>
<p class="text-muted"> <div class="ui-stack">
The Secure Boot chain that dnsmasq hands out over TFTP: the Microsoft-signed shim (<span class="mono">ipxe-shim.efi</span>)
starts <span class="mono">ipxe.efi</span>, which is signed by the iPXE Secure Boot CA. Updates come from the upstream
iPXE release tarball on GitHub; its sha256 is checked against GitHub's published digest and each file's signer chain is
read with <span class="mono">sbverify</span> before it can be installed. <span class="mono">ipxe-bcm.efi</span>,
<span class="mono">dnsmasq.conf</span> and the Blancco files are never changed here.
</p>
{# ---- Upstream ------------------------------------------------------------ #} {# ---- Job ----------------------------------------------------------------- #}
<div class="section-card"> {% if job.state %}
<div class="section-title"><i class="bi bi-cloud-arrow-down"></i> Upstream release</div> <div id="ipxeJob" data-state="{{ job.state }}">
<div class="row g-3 align-items-end"> {% if job.state in ('queued', 'running') %}
<div class="col-lg-7"> <div class="panel strip" role="status">
{% if latest %} <span class="pulse" aria-hidden="true"></span>
<div> <span id="ipxeJobText">{{ 'Starting' if job.state == 'queued' else (job.step | capitalize) }} {{ job.tag }}...</span>
<strong class="mono">{{ latest.tag }}</strong> <div class="bar" aria-hidden="true"><div id="ipxeJobBar" style="width: {{ job.percent or 0 }}%"></div></div>
{% if latest.name and latest.name != latest.tag %}<span class="text-muted">{{ latest.name }}</span>{% endif %}
{% if status == 'update' %}<span class="badge badge-warning">newer than installed</span>
{% elif status == 'current' %}<span class="badge badge-success">installed</span>
{% elif status == 'newer' %}<span class="badge badge-info">installed build is newer</span>
{% else %}<span class="badge badge-secondary">installed version unknown</span>{% endif %}
</div>
<div class="small text-muted">
Published {{ latest.published | local_dt }} | checked {{ latest.checked | local_dt }}
{% if latest.size %}| {{ "%.1f"|format(latest.size / 1048576) }} MB{% endif %}
| <a href="{{ latest.page }}" target="_blank" rel="noopener">release page</a>
</div>
<div class="small">sha256 <span class="mono" style="white-space: normal; word-break: break-all;">{{ latest.sha256 or 'not published' }}</span></div>
{% else %}
<p class="text-muted mb-0">Not checked yet. <strong>Check for updates</strong> asks the GitHub API for the latest release (a few seconds).</p>
{% endif %}
</div>
<div class="col-lg-5 d-flex flex-wrap gap-2 justify-content-lg-end align-items-end">
<form method="post" action="{{ url_for('ipxe.save_settings') }}" class="d-flex gap-2 align-items-end">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="form-field mb-0">
<label for="maxMbps" class="form-label">Speed cap (MB/s)</label>
<input type="number" class="form-control form-control-sm" id="maxMbps" name="max_mbps"
min="0" max="1000" step="0.5" value="{{ '%g'|format(settings.max_mbps) }}" style="width: 7rem;">
</div>
<button type="submit" class="pxe-btn pxe-btn-ghost pxe-btn-sm" title="Shared by every download on the Updates pages; 0 = no cap. The site link is about 400 Mbps (50 MB/s).">Save</button>
</form>
<form method="post" action="{{ url_for('ipxe.stage') }}">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="pxe-btn pxe-btn-primary"
{% if not latest or not latest.sha256 or job.state in ('queued', 'running') %}disabled{% endif %}>
<i class="bi bi-download"></i> Download and verify
</button>
</form>
</div>
</div>
<div class="form-text">Speed cap applies to every download on the Updates pages (Dell and iPXE); 0 means no cap.</div>
{% if job.state %}
<div id="ipxeJob" class="mt-3" data-state="{{ job.state }}">
{% if job.state in ('queued', 'running') %}
<div class="d-flex align-items-center gap-2 mb-1">
<span class="spinner-border spinner-border-sm" role="status"></span>
<span id="ipxeJobText">{{ 'Starting' if job.state == 'queued' else (job.step | capitalize) }} {{ job.tag }}...</span>
</div>
<div class="progress" style="height: 0.5rem;">
<div id="ipxeJobBar" class="progress-bar" style="width: {{ job.percent or 0 }}%;"></div>
</div>
{% elif job.state == 'error' %}
<div class="alert alert-danger mb-0"><strong>Download of {{ job.tag }} failed:</strong> <span class="mono">{{ job.error }}</span></div>
{% elif job.state == 'interrupted' %}
<div class="alert alert-warning mb-0">The download of {{ job.tag }} stopped without finishing (server restart?). Start it again; it resumes the partial file.</div>
{% elif job.state == 'done' and job.failed %}
<div class="alert alert-warning mb-0">{{ job.tag }} downloaded, but {{ job.failed | join(', ') }} failed verification; see below.</div>
{% endif %}
</div> </div>
{% elif job.state == 'error' %}
<div class="errbox"><strong>Download of {{ job.tag }} failed:</strong> <span class="mono">{{ job.error }}</span></div>
{% elif job.state == 'interrupted' %}
<div class="notice">The download of {{ job.tag }} stopped without finishing (server restart?). Start it again; it resumes the partial file.</div>
{% elif job.state == 'done' and job.failed %}
<div class="notice">{{ job.tag }} downloaded, but {{ job.failed | join(', ') }} failed verification; see Downloaded and verified below.</div>
{% endif %} {% endif %}
</div> </div>
{# ---- Installed ----------------------------------------------------------- #}
<div class="section-card">
<div class="section-title"><i class="bi bi-hdd"></i> Installed on the TFTP root</div>
<div class="table-container">
<table class="data-table">
<thead>
<tr><th>File</th><th>Version</th><th>Size</th><th>Modified</th><th>sha256</th><th>Signature</th></tr>
</thead>
<tbody>
{% for r in rows %}
{% set i = r.info %}
<tr>
<td class="text-nowrap">
<span class="mono">{{ r.name }}</span>
{% if not r.managed %}<div class="small text-muted">read-only here</div>{% endif %}
</td>
{% if i %}
<td class="mono">{{ i.version or '-' }}{% if i.release %}<div class="small text-muted">from {{ i.release }}</div>{% endif %}</td>
<td class="text-nowrap">{{ "{:,}".format(i.size) }} B</td>
<td class="mono text-nowrap">{{ i.modified | timestamp_fmt }}</td>
<td class="mono" title="{{ i.sha256 }}">{{ i.sha256[:12] }}</td>
<td class="wrap">{{ sig_badge(i, i.chain_ok if i.chain_ok is defined else none) }}{{ chain(i) }}</td>
{% else %}
<td colspan="5" class="text-muted">Not present</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{# ---- Staged -------------------------------------------------------------- #}
{% if staged %}
<div class="section-card">
<div class="section-title"><i class="bi bi-box-seam"></i> Downloaded and verified</div>
{% for s in staged %}
<form method="post" action="{{ url_for('ipxe.install') }}" class="{{ 'mt-4' if not loop.first }}" data-tag="{{ s.tag }}"
onsubmit="var n=[].slice.call(this.querySelectorAll('input[name=names]:checked')).map(function(c){return c.value;}); if(!n.length){alert('Select at least one file.');return false;} return confirm('Install ' + n.join(', ') + ' from ' + this.dataset.tag + ' into the TFTP root? The current files are backed up first and every client that boots from now on gets the new ones.');">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="tag" value="{{ s.tag }}">
<div class="d-flex flex-wrap align-items-center gap-2 mb-2">
<strong class="mono">{{ s.tag }}</strong>
<span class="small text-muted">verified {{ s.staged | local_dt }} | tarball sha256 <span class="mono" title="{{ s.tarball_sha256 }}">{{ (s.tarball_sha256 or '')[:12] }}</span> matches GitHub</span>
</div>
<div class="table-container">
<table class="data-table">
<thead>
<tr><th style="width:2rem;"></th><th>File</th><th>From tarball</th><th>Version</th><th>sha256</th><th>Check</th></tr>
</thead>
<tbody>
{% for name, f in s.files.items() %}
<tr>
<td><input type="checkbox" class="form-check-input" name="names" value="{{ name }}" id="inst-{{ loop.index }}-{{ s.tag }}"
{% if not f.ok %}disabled{% elif name in default_install %}checked{% endif %}></td>
<td class="text-nowrap"><label for="inst-{{ loop.index }}-{{ s.tag }}" class="mono">{{ name }}</label>
{% if name not in default_install %}<div class="small text-muted">optional</div>{% endif %}</td>
<td class="mono small">{{ f.source or '-' }}</td>
<td class="mono">{{ f.version or '-' }}</td>
<td class="mono" title="{{ f.sha256 }}">{{ (f.sha256 or '')[:12] }}
{% if f.sha256 and f.sha256 == installed_sha[name] %}<div class="small text-muted">same as installed</div>{% endif %}</td>
<td class="wrap">
{% if f.ok %}<span class="badge badge-success">OK</span> {% if f.kind %}{{ sig_badge(f) }}{% endif %}
{% else %}<span class="badge badge-danger">Failed</span> <div class="small">{{ f.error }}</div>{% endif %}
{% if f.signers is defined %}{{ chain(f) }}{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="d-flex flex-wrap align-items-center gap-2 mt-2">
<button type="submit" class="pxe-btn pxe-btn-primary"><i class="bi bi-box-arrow-in-down"></i> Install selected</button>
<span class="form-text">ipxe-shim.efi is only installed together with ipxe.efi from the same release. ipxe.pxe is for legacy BIOS clients.</span>
</div>
</form>
{% endfor %}
</div>
{% endif %} {% endif %}
{# ---- dnsmasq routing ----------------------------------------------------- #} <details>
<div class="section-card"> <summary class="disc"><span class="chev" aria-hidden="true">&#9662;</span>What this page manages</summary>
<div class="section-title"><i class="bi bi-diagram-3"></i> Who gets which file (dnsmasq, read-only)</div> <p class="sub" style="margin: 4px 0 0; max-width: 900px; line-height: 1.6">
{% if routing.boots %} The Secure Boot chain that dnsmasq hands out over TFTP: the Microsoft-signed shim (<span class="mono">ipxe-shim.efi</span>)
<div class="table-container"> starts <span class="mono">ipxe.efi</span>, which is signed by the iPXE Secure Boot CA. Updates come from the upstream
<table class="data-table"> iPXE release tarball on GitHub; its sha256 is checked against GitHub's published digest and each file's signer chain is
<thead><tr><th>Boot file</th><th>Requires tag</th><th>Unless tagged</th><th>Clients</th></tr></thead> read with <span class="mono">sbverify</span> before it can be installed. <span class="mono">ipxe-bcm.efi</span>,
<tbody> <span class="mono">dnsmasq.conf</span> and the Blancco files are never changed here.
{% for b in routing.boots %} </p>
<tr> </details>
<td class="mono">{{ b.file }}
{% if b.managed %}<span class="badge badge-info">updated here</span>{% elif b.read_only %}<span class="badge badge-secondary">never touched</span> {# ---- Installed ----------------------------------------------------------- #}
{% elif b.other_copy %}<span class="badge badge-warning" title="This page installs into the TFTP root only; this copy in a subfolder is not updated">other copy, not updated here</span>{% endif %}</td> <section class="panel ui-list" aria-labelledby="inst-h">
<td class="mono">{{ b.need | join(', ') or '-' }}</td> <div class="sec-h"><div><h2 id="inst-h" class="sec">Installed on the TFTP root</h2>
<td class="mono">{{ b.exclude | join(', ') or '-' }}</td> <div class="sub">One line per boot file. Open a row for its signer chain, SBAT and hash.</div></div></div>
<td class="wrap"> <div class="igrid ihead ui-rowhead" aria-hidden="true"><span>File</span><span>Version</span><span>Signature</span><span></span></div>
{% if b.macs %}<span class="mono">{{ b.macs | join(', ') }}</span> {% for r in rows %}
{% elif b.need %}<span class="text-muted">clients matching {{ b.need | join(' and ') }}</span> {% set i = r.info %}
{% else %}<span class="text-muted">every client not excluded</span>{% endif %} {% set chain_ok = i.chain_ok if i and i.chain_ok is defined else none %}
</td> <div class="ui-row i-row">
</tr> <details>
{% endfor %} <summary class="igrid">
</tbody> <span class="name"><span class="mono">{{ r.name }}</span>{% if not r.managed %}<span class="sub">read-only here</span>{% endif %}</span>
</table> <span class="tip c-ver" data-tip="{{ (i.version or '-') if i else 'Not present' }}"><span class="trunc mono">{% if i %}{{ i.version or '-' }}{% else %}<span class="muted">Not present</span>{% endif %}</span></span>
<span>{% if not i %}{{ ui.chip('Missing', 'off') }}{% elif chain_ok == false %}{{ ui.chip('Shim will refuse', 'err') }}{% else %}{{ sig_chip(i) }}{% endif %}</span>
<span class="chev" aria-hidden="true">&#9662;</span>
</summary>
<div class="ibody">
{% if i %}
{{ sig_text(i, chain_ok) }}
<dl class="kv">
{% if i.release %}<dt>From release</dt><dd class="mono">{{ i.release }}</dd>{% endif %}
<dt>Size</dt><dd>{{ "{:,}".format(i.size) }} B</dd>
<dt>Modified</dt><dd class="mono">{{ i.modified | timestamp_fmt }}</dd>
<dt>sha256</dt><dd class="mono">{{ i.sha256 }}</dd>
</dl>
{{ chain(i) }}
{% else %}<div class="sub">Not present on the TFTP root.</div>{% endif %}
</div>
</details>
</div> </div>
<details class="mt-2"> {% endfor %}
<summary class="eyebrow" style="cursor:pointer;">Relevant dnsmasq lines ({{ routing.lines | length }})</summary> </section>
<pre class="mono mb-0 mt-2" style="white-space: pre-wrap; background: var(--bg); padding: 0.75rem; border-radius: 0.25rem;">{% for l in routing.lines %}{{ l }}
{# ---- Upstream ------------------------------------------------------------ #}
<section class="panel ui-list" aria-labelledby="up-h">
<div class="sec-h"><div><h2 id="up-h" class="sec">Upstream release</h2>
<div class="sub">The latest GitHub release of iPXE, as last checked.</div></div></div>
{% if latest %}
<div class="ui-row">
<details>
<summary class="igrid">
<span class="mono" style="font-weight: 600">{{ latest.tag }}</span>
<span class="tip c-ver" data-tip="{{ latest.name }}"><span class="trunc sub">{% if latest.name and latest.name != latest.tag %}{{ latest.name }}, {% endif %}published {{ latest.published | local_dt }}</span></span>
<span>{% if status == 'update' %}{{ ui.chip('Newer than installed', 'act') }}
{% elif status == 'current' %}{{ ui.chip('Installed', 'ok') }}
{% elif status == 'newer' %}{{ ui.chip('Installed is newer', 'neutral') }}
{% else %}{{ ui.chip('Installed version unknown', 'off') }}{% endif %}</span>
<span class="chev" aria-hidden="true">&#9662;</span>
</summary>
<div class="ibody">
<dl class="kv">
<dt>Published</dt><dd>{{ latest.published | local_dt }}</dd>
<dt>Checked</dt><dd>{{ latest.checked | local_dt }}</dd>
{% if latest.size %}<dt>Tarball</dt><dd>{{ "%.1f"|format(latest.size / 1048576) }} MB</dd>{% endif %}
<dt>sha256</dt><dd class="mono">{{ latest.sha256 or 'not published' }}</dd>
<dt>Release page</dt><dd><a href="{{ latest.page }}" target="_blank" rel="noopener">{{ latest.page }}</a></dd>
</dl>
{% if not latest.sha256 %}<div class="notice">GitHub lists no sha256 for this release, so it cannot be downloaded here; install it by hand (see the instructions below).</div>{% endif %}
</div>
</details>
</div>
{% else %}
<div class="hidden-bar">Not checked yet. Check now asks the GitHub API for the latest release (a few seconds).</div>
{% endif %}
</section>
{# ---- Staged -------------------------------------------------------------- #}
{% if staged %}
<section class="panel ui-list" aria-labelledby="stg-h">
<div class="sec-h"><div><h2 id="stg-h" class="sec">Downloaded and verified</h2>
<div class="sub">Tick the files to install. ipxe-shim.efi is only installed together with ipxe.efi from the same release; ipxe.pxe is for legacy BIOS clients.</div></div></div>
{% for s in staged %}
<form method="post" action="{{ url_for('ipxe.install') }}" data-tag="{{ s.tag }}" style="margin: 0"
onsubmit="var n=[].slice.call(this.querySelectorAll('input[name=names]:checked')).map(function(c){return c.value;}); if(!n.length){alert('Select at least one file.');return false;} return confirm('Install ' + n.join(', ') + ' from ' + this.dataset.tag + ' into the TFTP root? The current files are backed up first and every client that boots from now on gets the new ones.');">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="tag" value="{{ s.tag }}">
<div class="rel-h">
<strong class="mono">{{ s.tag }}</strong>
<span class="sub">verified {{ s.staged | local_dt }}</span>
<span class="tip inl" data-tip="{{ s.tarball_sha256 }}"><span class="sub">tarball sha256 <span class="mono">{{ (s.tarball_sha256 or '')[:12] }}</span> matches GitHub</span></span>
</div>
{% for name, f in s.files.items() %}
{% set fid = 'inst-' ~ loop.index ~ '-' ~ s.tag %}
<div class="ui-row i-row">
<div class="pick"><input type="checkbox" class="form-check-input" name="names" value="{{ name }}" id="{{ fid }}"
{% if not f.ok %}disabled{% elif name in default_install %}checked{% endif %}>
<label for="{{ fid }}" class="vh">Install {{ name }} from {{ s.tag }}</label></div>
<details {% if not f.ok %}open{% endif %}>
<summary class="igrid picked">
<span class="name"><span class="mono">{{ name }}</span>{% if name not in default_install %}<span class="sub">optional</span>{% endif %}</span>
<span class="tip c-ver" data-tip="{{ f.version or '-' }}"><span class="trunc mono">{{ f.version or '-' }}{% if f.sha256 and f.sha256 == installed_sha[name] %} <span class="sub">(same as installed)</span>{% endif %}</span></span>
<span>{% if not f.ok %}{{ ui.chip('Failed', 'err') }}{% elif f.kind %}{{ sig_chip(f) }}{% else %}{{ ui.chip('OK', 'ok') }}{% endif %}</span>
<span class="chev" aria-hidden="true">&#9662;</span>
</summary>
<div class="ibody picked">
{% if not f.ok %}<div class="errbox">{{ f.error }}</div>{% elif f.kind %}{{ sig_text(f) }}{% endif %}
<dl class="kv">
<dt>From tarball</dt><dd class="mono">{{ f.source or '-' }}</dd>
{% if f.sha256 %}<dt>sha256</dt><dd class="mono">{{ f.sha256 }}</dd>{% endif %}
</dl>
{% if f.signers is defined %}{{ chain(f) }}{% endif %}
</div>
</details>
</div>
{% endfor %}
<div class="list-foot">
<button type="submit" class="ui-btn pri">Install selected</button>
<span class="sub">The current files are backed up first.</span>
</div>
</form>
{% endfor %}
</section>
{% endif %}
{# ---- dnsmasq routing ----------------------------------------------------- #}
<section class="panel ui-list" aria-labelledby="rt-h">
<div class="sec-h"><div><h2 id="rt-h" class="sec">Who gets which file</h2>
<div class="sub">Read from the dnsmasq configuration; never changed here.</div></div></div>
{% if routing.boots %}
{% for b in routing.boots %}
<div class="ui-row">
<details>
<summary class="rgrid">
<span class="name"><span class="tip" data-tip="{{ b.file }}"><span class="trunc mono">{{ b.file }}</span></span>
{% if b.managed %}{{ ui.chip('updated here', 'w11') }}{% elif b.read_only %}{{ ui.chip('never touched', 'neutral') }}{% elif b.other_copy %}{{ ui.chip('other copy, not updated here', 'warn') }}{% endif %}</span>
<span class="sub c-who">
{% if b.macs %}{{ b.macs | length }} tagged client{{ 's' if b.macs | length != 1 }} ({{ b.need | join(', ') }})
{%- elif b.need %}clients matching {{ b.need | join(' and ') }}
{% else %}every client not excluded{% endif -%}{% if b.exclude %}; not {{ b.exclude | join(', ') }}{% endif %}</span>
<span class="chev" aria-hidden="true">&#9662;</span>
</summary>
<div class="ibody">
{% if b.other_copy %}<div class="notice">This page installs into the TFTP root only; this copy in a subfolder is not updated.</div>{% endif %}
<dl class="kv">
<dt>Requires tag</dt><dd class="mono">{{ b.need | join(', ') or '-' }}</dd>
<dt>Unless tagged</dt><dd class="mono">{{ b.exclude | join(', ') or '-' }}</dd>
{% if b.macs %}<dt>Clients</dt><dd class="mono">{{ b.macs | join(', ') }}</dd>{% endif %}
<dt>Line</dt><dd class="mono">{{ b.line }}</dd>
</dl>
</div>
</details>
</div>
{% endfor %}
<details style="border-top: 1px solid var(--line)">
<summary class="disc" style="padding: 0 24px; min-height: 44px"><span class="chev" aria-hidden="true">&#9662;</span>Relevant dnsmasq lines ({{ routing.lines | length }})</summary>
<pre class="cmd mono" style="margin: 0 24px 16px">{% for l in routing.lines %}{{ l }}
{% endfor %}</pre> {% endfor %}</pre>
</details> </details>
{% else %} {% else %}
<p class="text-muted mb-0">No dhcp-boot lines found in the dnsmasq configuration.</p> <div class="hidden-bar">No dhcp-boot lines found in the dnsmasq configuration.</div>
{% endif %} {% endif %}
</div> </section>
{# ---- Backups ------------------------------------------------------------- #} {# ---- Backups ------------------------------------------------------------- #}
<div class="section-card"> <section class="panel ui-list" aria-labelledby="bk-h">
<div class="section-title"><i class="bi bi-clock-history"></i> Backups</div> <div class="sec-h"><div><h2 id="bk-h" class="sec">Backups</h2>
{% if backups %} <div class="sub">One is made automatically before every install and rollback. Restore is in each row's menu.</div></div></div>
<div class="table-container"> {% for b in backups %}
<table class="data-table"> <div class="ui-row">
<thead><tr><th>Backup</th><th>Reason</th><th>Files</th><th class="text-end"></th></tr></thead> <details>
<tbody> <summary class="bgrid">
{% for b in backups %} <span class="mono">{{ b.id }}</span>
<tr> <span class="tip" data-tip="{{ b.reason or '-' }}"><span class="trunc">{{ b.reason or '-' }}</span></span>
<td class="mono text-nowrap">{{ b.id }}</td> <span class="tip" data-tip="{{ b.files | join(', ') or 'empty' }}"><span class="trunc sub mono">{{ b.files | join(', ') or 'empty' }}</span></span>
<td>{{ b.reason or '-' }}</td> <span class="chev" aria-hidden="true">&#9662;</span>
<td> </summary>
{% for f in b.files %}<div class="small"><span class="mono">{{ f }}</span> <span class="text-muted">{{ b.versions[f] or '' }}</span></div>{% endfor %} <div class="ibody">
{% if not b.files %}<span class="text-muted">empty</span>{% endif %} {% if b.reason %}<div>{{ b.reason }}</div>{% endif %}
</td> {% for f in b.files %}<div><span class="mono">{{ f }}</span> <span class="muted">{{ b.versions[f] or '' }}</span></div>{% else %}<div class="muted">Empty backup.</div>{% endfor %}
<td class="text-end text-nowrap"> </div>
{% if b.files %} </details>
{# The id is a folder name (hand-made backups too): keep it out of the JS source. #} {% if b.files %}
<form method="post" action="{{ url_for('ipxe.rollback') }}" style="display:inline;" <div class="act">
data-confirm="Put back {{ b.files | join(', ') }} from backup {{ b.id }}? The current files are backed up first." {% call ui.more('backup ' ~ b.id) %}
onsubmit="return confirm(this.dataset.confirm);"> {# The id is a folder name (hand-made backups too): keep it out of the JS source. #}
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}"> <form method="post" action="{{ url_for('ipxe.rollback') }}"
<input type="hidden" name="backup" value="{{ b.id }}"> data-confirm="Put back {{ b.files | join(', ') }} from backup {{ b.id }}? The current files are backed up first."
<button type="submit" class="pxe-btn pxe-btn-ghost btn-row-action"><i class="bi bi-arrow-counterclockwise"></i> Rollback</button> onsubmit="return confirm(this.dataset.confirm);">
</form> <input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
{% endif %} <input type="hidden" name="backup" value="{{ b.id }}">
</td> <button type="submit">Restore these files</button>
</tr> </form>
{% endfor %} {% endcall %}
</tbody> </div>
</table> {% endif %}
</div> </div>
{% else %} {% else %}
<p class="text-muted mb-0">No backups yet. One is made automatically before every install and rollback.</p> <div class="hidden-bar">No backups yet. One is made automatically before every install and rollback.</div>
{% endif %} {% endfor %}
</div> </section>
{# ---- How-to -------------------------------------------------------------- #} {# ---- How-to -------------------------------------------------------------- #}
<div class="section-card"> <section class="panel" aria-labelledby="how-h">
<div class="section-title"><i class="bi bi-journal-text"></i> How to find and install the latest signed build by hand</div> <details>
<p>Use this when the buttons above cannot reach GitHub, or to check what they do. All commands run on the PXE server as root.</p> <summary class="sec-h" style="align-items: center">
<ol> <div><h2 id="how-h" class="sec" style="font-size: 15px">Find and install the latest signed build by hand</h2>
<li class="mb-3"><strong>Find the release.</strong> The latest release is at <div class="sub">For when the buttons above cannot reach GitHub, or to check what they do.</div></div>
<span class="chev" aria-hidden="true">&#9662;</span>
</summary>
<div style="padding: 0 24px 20px">
<p class="sub" style="margin: 0 0 12px">All commands run on the PXE server as root.</p>
<ol class="howto">
<li><strong>Find the release.</strong> The latest release is at
<a href="{{ releases_page }}" target="_blank" rel="noopener" class="mono">{{ releases_page }}</a>. The signed boot files are the <a href="{{ releases_page }}" target="_blank" rel="noopener" class="mono">{{ releases_page }}</a>. The signed boot files are the
asset <span class="mono">ipxeboot.tar.gz</span>; GitHub shows its sha256 next to the asset (or ask the API: asset <span class="mono">ipxeboot.tar.gz</span>; GitHub shows its sha256 next to the asset (or ask the API:
<span class="mono">curl -s https://api.github.com/repos/ipxe/ipxe/releases/latest | grep -B2 -A2 '"digest"'</span>).</li> <span class="mono">curl -s https://api.github.com/repos/ipxe/ipxe/releases/latest | grep -B2 -A2 '"digest"'</span>).</li>
<li class="mb-3"><strong>Download and check the sha256.</strong> It must equal the digest on the release page. <li><strong>Download and check the sha256.</strong> It must equal the digest on the release page.
<pre class="mono mt-2 mb-1" style="white-space: pre-wrap; background: var(--bg); padding: 0.75rem; border-radius: 0.25rem;">cd /tmp <pre class="cmd mono">cd /tmp
curl -fLO {{ tarball_url }} curl -fLO {{ tarball_url }}
sha256sum ipxeboot.tar.gz</pre> sha256sum ipxeboot.tar.gz</pre>
<span class="small text-muted">GitHub goes through Zscaler. A certificate error from curl means the Zscaler root CA is missing from <span class="sub">GitHub goes through Zscaler. A certificate error from curl means the Zscaler root CA is missing from
<span class="mono">/usr/local/share/ca-certificates/</span> (the playbook installs it).</span></li> <span class="mono">/usr/local/share/ca-certificates/</span> (the playbook installs it).</span></li>
<li class="mb-3"><strong>Unpack.</strong> The Secure Boot files are in <span class="mono">ipxeboot/x86_64-sb/</span>: <li><strong>Unpack.</strong> The Secure Boot files are in <span class="mono">ipxeboot/x86_64-sb/</span>:
<span class="mono">shimx64.efi</span> (the shim), <span class="mono">ipxe.efi</span> and <span class="mono">snponly.efi</span>. <span class="mono">shimx64.efi</span> (the shim), <span class="mono">ipxe.efi</span> and <span class="mono">snponly.efi</span>.
<span class="mono">ipxe-shim.efi</span> and <span class="mono">snponly-shim.efi</span> there are only symlinks to <span class="mono">ipxe-shim.efi</span> and <span class="mono">snponly-shim.efi</span> there are only symlinks to
<span class="mono">shimx64.efi</span>. The legacy BIOS build is <span class="mono">ipxeboot/x86_64/ipxe.pxe</span>. <span class="mono">shimx64.efi</span>. The legacy BIOS build is <span class="mono">ipxeboot/x86_64/ipxe.pxe</span>.
<pre class="mono mt-2 mb-0" style="white-space: pre-wrap; background: var(--bg); padding: 0.75rem; border-radius: 0.25rem;">tar xzf ipxeboot.tar.gz <pre class="cmd mono">tar xzf ipxeboot.tar.gz
ls -l ipxeboot/x86_64-sb/</pre></li> ls -l ipxeboot/x86_64-sb/</pre></li>
<li class="mb-3"><strong>Check the signatures.</strong> <li><strong>Check the signatures.</strong>
<pre class="mono mt-2 mb-1" style="white-space: pre-wrap; background: var(--bg); padding: 0.75rem; border-radius: 0.25rem;">sbverify --list ipxeboot/x86_64-sb/shimx64.efi <pre class="cmd mono">sbverify --list ipxeboot/x86_64-sb/shimx64.efi
sbverify --list ipxeboot/x86_64-sb/ipxe.efi</pre> sbverify --list ipxeboot/x86_64-sb/ipxe.efi</pre>
The shim must be issued by <span class="mono">CN=Microsoft Corporation UEFI CA 2011</span> or, on a newer build, The shim must be issued by <span class="mono">CN=Microsoft Corporation UEFI CA 2011</span> or, on a newer build,
<span class="mono">CN=Microsoft UEFI CA 2023</span> (prefer a 2023-signed shim when upstream ships one). <span class="mono">CN=Microsoft UEFI CA 2023</span> (prefer a 2023-signed shim when upstream ships one).
<span class="mono">ipxe.efi</span> must show <span class="mono">CN=iPXE Secure Boot Intermediate ...</span> issued by <span class="mono">ipxe.efi</span> must show <span class="mono">CN=iPXE Secure Boot Intermediate ...</span> issued by
<span class="mono">CN=iPXE Secure Boot CA</span>. The shim's <span class="mono">warning: data remaining ... gaps between PE/COFF sections?</span> <span class="mono">CN=iPXE Secure Boot CA</span>. The shim's <span class="mono">warning: data remaining ... gaps between PE/COFF sections?</span>
is normal. Do not rely on <span class="mono">sbverify --cert</span>: it reports OK even for the wrong certificate.</li> is normal. Do not rely on <span class="mono">sbverify --cert</span>: it reports OK even for the wrong certificate.</li>
<li class="mb-3"><strong>Back up the current files.</strong> A backup made in this folder also shows in the Backups list above, with Rollback. <li><strong>Back up the current files.</strong> A backup made in this folder also shows in the Backups list above, with Restore.
<pre class="mono mt-2 mb-0" style="white-space: pre-wrap; background: var(--bg); padding: 0.75rem; border-radius: 0.25rem;">B=/var/lib/pxe-webapp/updates/ipxe/backups/$(date +%Y%m%d-%H%M%S) <pre class="cmd mono">B=/var/lib/pxe-webapp/updates/ipxe/backups/$(date +%Y%m%d-%H%M%S)
mkdir -p "$B" &amp;&amp; cp -p /srv/tftp/ipxe-shim.efi /srv/tftp/ipxe.efi "$B"/</pre></li> mkdir -p "$B" &amp;&amp; cp -p /srv/tftp/ipxe-shim.efi /srv/tftp/ipxe.efi "$B"/</pre></li>
<li class="mb-3"><strong>Install both files, from the same release.</strong> The shim only starts an <span class="mono">ipxe.efi</span> signed under the <li><strong>Install both files, from the same release.</strong> The shim only starts an <span class="mono">ipxe.efi</span> signed under the
CA it carries, and it always loads <span class="mono">ipxe.efi</span> from the TFTP root, so the name must stay CA it carries, and it always loads <span class="mono">ipxe.efi</span> from the TFTP root, so the name must stay
<span class="mono">ipxe.efi</span>. Copy to a temporary name and rename, so a client that is booting never reads half a file: <span class="mono">ipxe.efi</span>. Copy to a temporary name and rename, so a client that is booting never reads half a file:
<pre class="mono mt-2 mb-1" style="white-space: pre-wrap; background: var(--bg); padding: 0.75rem; border-radius: 0.25rem;">install -m 0644 ipxeboot/x86_64-sb/ipxe.efi /srv/tftp/.ipxe.efi.new &amp;&amp; mv -f /srv/tftp/.ipxe.efi.new /srv/tftp/ipxe.efi <pre class="cmd mono">install -m 0644 ipxeboot/x86_64-sb/ipxe.efi /srv/tftp/.ipxe.efi.new &amp;&amp; mv -f /srv/tftp/.ipxe.efi.new /srv/tftp/ipxe.efi
install -m 0644 ipxeboot/x86_64-sb/shimx64.efi /srv/tftp/.ipxe-shim.efi.new &amp;&amp; mv -f /srv/tftp/.ipxe-shim.efi.new /srv/tftp/ipxe-shim.efi</pre> install -m 0644 ipxeboot/x86_64-sb/shimx64.efi /srv/tftp/.ipxe-shim.efi.new &amp;&amp; mv -f /srv/tftp/.ipxe-shim.efi.new /srv/tftp/ipxe-shim.efi</pre>
<span class="small text-muted">No dnsmasq restart is needed. Never replace <span class="mono">ipxe-bcm.efi</span>; it serves every other UEFI client.</span></li> <span class="sub">No dnsmasq restart is needed. Never replace <span class="mono">ipxe-bcm.efi</span>; it serves every other UEFI client.</span></li>
<li class="mb-3"><strong>Who gets it.</strong> dnsmasq decides, in <span class="mono">/etc/dnsmasq.conf</span>: clients listed as <li><strong>Who gets it.</strong> dnsmasq decides, in <span class="mono">/etc/dnsmasq.conf</span>: clients listed as
<span class="mono">dhcp-host=&lt;mac&gt;,set:ipxe2</span> get the shim through <span class="mono">dhcp-host=&lt;mac&gt;,set:ipxe2</span> get the shim through
<span class="mono">dhcp-boot=tag:ipxe2,tag:!ipxe,tag:!bios,ipxe-shim.efi</span>; every other UEFI client gets <span class="mono">dhcp-boot=tag:ipxe2,tag:!ipxe,tag:!bios,ipxe-shim.efi</span>; every other UEFI client gets
<span class="mono">ipxe-bcm.efi</span> through <span class="mono">dhcp-boot=tag:!bios,tag:!ipxe2,ipxe-bcm.efi</span>. The table above shows the <span class="mono">ipxe-bcm.efi</span> through <span class="mono">dhcp-boot=tag:!bios,tag:!ipxe2,ipxe-bcm.efi</span>. Who gets which file
live lines. Test on one tagged machine before tagging more.</li> above shows the live lines. Test on one tagged machine before tagging more.</li>
<li class="mb-3"><strong>Roll back.</strong> Use Rollback above, or copy the backed-up files back the same way (temporary name, then <li><strong>Roll back.</strong> Use Restore in the Backups list, or copy the backed-up files back the same way (temporary name, then
<span class="mono">mv -f</span>).</li> <span class="mono">mv -f</span>).</li>
<li><strong>Normal log noise.</strong> While booting, the shim asks TFTP for optional files such as <li><strong>Normal log noise.</strong> While booting, the shim asks TFTP for optional files such as
<span class="mono">revocations_*.efi</span> and <span class="mono">shim_certificate_0.efi</span>. The "not found" lines for them in the <span class="mono">revocations_*.efi</span> and <span class="mono">shim_certificate_0.efi</span>. The "not found" lines for them in the
dnsmasq log are expected and harmless.</li> dnsmasq log are expected and harmless.</li>
</ol> </ol>
</div>
</details>
</section>
</div> </div>
{% if job.state in ('queued', 'running') %} {% if job.state in ('queued', 'running') %}