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)
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)
audit("BACKUP_UPLOAD", filename)
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');
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) {
cb.addEventListener('change', function () {

View File

@@ -167,6 +167,8 @@
var rd = rawDirty();
var head = document.querySelector('.image-head');
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);
var now = sectionState(f);
var groups = {};
@@ -415,7 +417,7 @@
return box;
}
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
? '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.'
@@ -423,8 +425,8 @@
var ul = el('ul', 'una-lint-list');
lint.findings.forEach(function (f) {
var li = el('li');
li.appendChild(el('span', 'badge ' + (f.level === 'ERROR' ? 'badge-danger' : 'badge-warning'), f.level));
if (f['new']) li.appendChild(el('span', 'badge badge-info ms-1', 'new'));
li.appendChild(el('span', 'chip ' + (f.level === 'ERROR' ? 'c-err' : 'c-warn'), f.level));
if (f['new']) li.appendChild(el('span', 'chip c-edit', 'new'));
li.appendChild(document.createTextNode(' ' + f.message));
ul.appendChild(li);
});
@@ -462,7 +464,7 @@
if (!r.ok) { out.innerHTML = ''; fail(r.data.error || 'Preview failed.'); return; }
var n = PxeImage.renderDiff(out, r.data.files);
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') +
' 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" %}
{% 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 %}
<div class="page-header">
<h1>Audit Log</h1>
<div class="header-actions">
<span class="badge badge-secondary badge-lg">{{ entries|length }} entries</span>
</div>
</div>
{{ ui.head('Audit log', 'Tools', (entries | length) ~ ' entr' ~ ('ies' if entries | length != 1 else 'y') ~ ', newest first.') }}
<div class="section-card">
<div class="section-title">Activity History</div>
<section class="panel ui-list" aria-labelledby="al-h">
<h2 id="al-h" class="vh">Activity history</h2>
{% if entries %}
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th style="width: 180px;">Timestamp</th>
<th style="width: 130px;">Source</th>
<th style="width: 180px;">Action</th>
<th>Details</th>
</tr>
</thead>
<tbody>
{% for entry in entries %}
<tr>
{% set parts = entry.split(' ', 1) %}
{% if parts|length == 2 %}
{% set meta = parts[1].split('] ', 1) %}
<td class="mono">{{ parts[0] }}</td>
{% if meta|length == 2 %}
<td class="mono">{{ meta[0].lstrip('[') }}</td>
{% set action_detail = meta[1].split(': ', 1) %}
{% if action_detail|length == 2 %}
<td><span class="badge badge-primary">{{ action_detail[0] }}</span></td>
<td>{{ action_detail[1] }}</td>
{% else %}
<td colspan="2">{{ meta[1] }}</td>
{% endif %}
{% else %}
<td colspan="3">{{ parts[1] }}</td>
{% endif %}
{% else %}
<td colspan="4">{{ entry }}</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
{# Parse once, so the filter pills can show counts. #}
{% set rows = [] %}
{% for entry in entries %}
{% set parts = entry.split(' ', 1) %}
{% set r = {'raw': entry, 'when': '', 'src': '', 'action': '', 'detail': entry} %}
{% if parts | length == 2 %}
{% set _ = r.update({'when': parts[0].split(',')[0], 'detail': parts[1]}) %}
{% set meta = parts[1].split('] ', 1) %}
{% if meta | length == 2 %}
{% set _ = r.update({'src': meta[0].lstrip('['), 'detail': meta[1]}) %}
{% set ad = meta[1].split(': ', 1) %}
{% if ad | length == 2 %}{% set _ = r.update({'action': ad[0], 'detail': ad[1]}) %}{% endif %}
{% endif %}
{% endif %}
{% set fam = family(r.action) if r.action else 'other' %}
{% set _ = r.update({'fam': fam, 'bad': r.action and ('DELETE' in r.action or 'ROLLBACK' in r.action or 'REMOVE' in r.action)}) %}
{% set _ = rows.append(r) %}
{% endfor %}
{% set bad_count = rows | selectattr('bad') | list | length %}
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 10px; padding: 16px 24px">
<div role="search" style="flex: 1 1 220px; min-width: 160px; display: flex">
<label for="aq" class="vh">Filter by time, source, action or details</label>
<input id="aq" type="search" class="field" style="flex: 1; min-width: 0" placeholder="Time, source, action or details">
</div>
<div role="group" aria-label="Action family" id="afilters" style="display: flex; gap: 6px; flex-wrap: wrap">
<button type="button" class="fbtn" data-f="" aria-pressed="true">All</button>
{% if bad_count %}<button type="button" class="fbtn" data-f="destructive" aria-pressed="false">Destructive {{ bad_count }}</button>{% endif %}
{% for k, label, c in families %}
{% set n = rows | selectattr('fam', 'equalto', k) | list | length %}
{% if n %}<button type="button" class="fbtn" data-f="{{ k }}" aria-pressed="false">{{ label }} {{ n }}</button>{% endif %}
{% endfor %}
</div>
</div>
<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>
{% 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>
{% else %}
<div class="text-center text-muted py-5">
<i class="bi bi-list-check" style="font-size: 2rem; opacity: 0.4;"></i>
<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 class="al-empty">
No audit log entries yet. Image imports, unattend edits, backup operations and other changes are logged here.
</div>
{% 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 %}

View File

@@ -1,100 +1,107 @@
{% 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 %}
<div class="page-header">
<h1>Clonezilla Backups</h1>
<div class="header-actions">
<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>
{% call ui.head('Clonezilla backups', 'Tools', (backups | length) ~ ' backup' ~ ('s' if backups | length != 1) ~ ' on the share') %}
<button type="button" class="ui-btn pri" data-bs-toggle="modal" data-bs-target="#uploadModal">Upload backup</button>
{% endcall %}
<div class="card">
<div class="card-header d-flex align-items-center">
<i class="bi bi-hdd-stack me-2"></i>
Machine Backups
<span class="badge badge-secondary ms-2">{{ backups|length }}</span>
</div>
<div class="card-body p-0">
{% if backups %}
<div class="table-container">
<table class="data-table mb-0">
<thead>
<tr>
<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 class="ui-stack">
<section class="panel ui-list" aria-labelledby="bl-h">
<h2 id="bl-h" class="vh">Machine backups</h2>
{% if backups %}
<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>
{% for b in backups %}
<div class="ui-row">
<div class="bgrid">
<span class="b-mach mono tip" data-tip="{{ b.machine }}"><span class="trunc">{{ b.machine }}</span></span>
<span class="b-file tip" data-tip="{{ b.filename }}"><span class="trunc mono sub">{{ b.filename }}</span></span>
<span class="b-size">{{ "%.1f"|format(b.size / 1073741824) }} GB</span>
<span class="b-when mono">{{ b.modified | timestamp_fmt }}</span>
</div>
{% else %}
<div class="text-center text-muted py-5">
<i class="bi bi-hdd-stack" style="font-size: 2rem; opacity: 0.4;"></i>
<p class="mt-2 mb-0">No backups found. Upload a Clonezilla backup .zip to get started.</p>
<div class="act">
<a class="ui-btn" href="{{ url_for('clonezilla_download', filename=b.filename) }}">Download</a>
{% call ui.more(b.machine) %}
<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>
{% endif %}
</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">
<div class="section-title">Backup Naming Convention</div>
<p class="mb-0">
Name backup files with the machine number (e.g., <span class="mono">6501.zip</span>).
The Samba share <span class="mono">\\pxe-server\clonezilla</span> is also available on the network for direct Clonezilla save/restore operations.
</p>
<section class="panel bnote" aria-labelledby="bn-h">
<h2 id="bn-h" class="sec" style="font-size: 15px">Naming</h2>
<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>
<p>The Samba share <span class="mono">\\pxe-server\clonezilla</span> is also on the network for direct Clonezilla save and restore.</p>
</section>
</div>
<!-- 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-content">
<form action="{{ url_for('clonezilla_upload') }}" method="post" enctype="multipart/form-data">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="modal-header">
<h5 class="modal-title">Upload Backup</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
<h3 class="modal-title" id="uploadTitle">Upload backup</h3>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<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"
accept=".zip" required>
<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 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 class="modal-footer">
<button type="button" class="pxe-btn pxe-btn-ghost" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="pxe-btn pxe-btn-primary">
<i class="bi bi-upload"></i> Upload
</button>
<button type="button" class="ui-btn" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="ui-btn pri">Upload</button>
</div>
</form>
</div>
@@ -102,24 +109,22 @@
</div>
<!-- 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-content">
<form id="deleteForm" method="post">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="modal-header">
<h5 class="modal-title">Confirm Delete</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
<h3 class="modal-title" id="deleteTitle">Delete backup</h3>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<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 class="modal-footer">
<button type="button" class="pxe-btn pxe-btn-ghost" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="pxe-btn pxe-btn-danger">
<i class="bi bi-trash"></i> Delete
</button>
<button type="button" class="ui-btn pri" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="ui-btn danger">Delete</button>
</div>
</form>
</div>

View File

@@ -1,213 +1,315 @@
{% 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 %}
<div class="page-header">
<h1><i class="bi bi-pc-display"></i> Enrollment Packages</h1>
<div class="header-actions">
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#uploadModal">
<i class="bi bi-upload"></i> Upload Package
</button>
</div>
</div>
{% call ui.head('Enrollment packages', 'Tools', 'Shop floor (SFLD) and Standard / Engineer (MCL) provisioning packages.') %}
<button type="button" class="ui-btn pri" data-bs-toggle="modal" data-bs-target="#uploadModal">Upload SFLD package</button>
{% endcall %}
{% if active_ppkg and active_ppkg.SOURCE_PPKG %}
<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="ui-stack">
<div class="card">
<div class="card-header">
<span><i class="bi bi-box-seam"></i> Shop Floor (SFLD) Provisioning Packages</span>
<span class="badge badge-secondary">{{ packages|length }}</span>
{# ---------- Active PPKG ---------- #}
<section class="panel" aria-labelledby="act-h">
<div class="phead" style="padding-bottom: 0"><h2 id="act-h" class="sec">Active SFLD package</h2></div>
{% 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 class="card-body p-0">
{% if packages %}
<div class="table-container">
<table class="table table-hover mb-0">
<thead>
<tr>
<th>Filename</th>
<th>Size</th>
<th>Last Modified</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
{% for p in packages %}
{% set is_active = active_ppkg and active_ppkg.SOURCE_PPKG and p.filename|lower == active_ppkg.SOURCE_PPKG|lower %}
<tr>
<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>
<td>{{ "%.1f"|format(p.size / 1048576) }} MB</td>
<td>{{ p.modified | timestamp_fmt }}</td>
<td class="actions text-end text-nowrap">
<a href="{{ url_for('enrollment_download', filename=p.filename) }}"
class="btn btn-sm btn-outline-primary btn-row-action" title="Download">
<i class="bi bi-download"></i> Download
</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>
{% else %}
<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>
{% endif %}
</section>
{# ---------- SFLD packages ---------- #}
<section class="panel ui-list" aria-labelledby="sfld-h">
<div class="phead">
<h2 id="sfld-h" class="sec">Shop floor (SFLD) packages</h2>
<span class="sub">{{ packages | length }}</span>
</div>
{% if packages %}
<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 %}
{% set is_active = active_ppkg and active_ppkg.SOURCE_PPKG and p.filename|lower == active_ppkg.SOURCE_PPKG|lower %}
<div class="ui-row">
<div class="sgrid">
<span class="s-name">
<span class="tip" data-tip="{{ p.filename }}"><span class="trunc mono">{{ p.filename }}</span></span>
{% if is_active %}{{ ui.chip('Active', 'ok') }}{% endif %}
</span>
<span class="num">{{ "%.1f"|format(p.size / 1048576) }} MB</span>
<span class="when mono">{{ p.modified | timestamp_fmt }}</span>
</div>
{% else %}
<div class="text-center text-muted py-5">
<i class="bi bi-inbox" style="font-size: 2.5rem; opacity: 0.5;"></i>
<p class="mt-2 mb-0">No enrollment packages found. Upload a <span class="mono">.ppkg</span> file to get started.</p>
<div class="act wide">
<a class="ui-btn" href="{{ url_for('enrollment_download', filename=p.filename) }}">Download</a>
{% call ui.more(p.filename) %}
<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>
{% endif %}
</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">
<h3 class="pxe-card-title"><i class="bi bi-info-circle"></i> About Enrollment Packages</h3>
<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 class="mb-0">
<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>
{# ---------- MCL packages ---------- #}
{% set mcl_users = mcl_links | selectattr('state', 'equalto', 'ok') | map(attribute='image') | list %}
{% set any_dup = mcl_packages | selectattr('conflict') | list | length %}
{% set any_ign = mcl_packages | rejectattr('prefix') | list | length %}
{% set any_used = mcl_packages | selectattr('prefix') | rejectattr('placeholder') | list | length %}
<section class="panel ui-list" id="mcl" aria-labelledby="mcl-h">
<div class="phead">
<h2 id="mcl-h" class="sec">Standard / Engineer (MCL) packages</h2>
<span class="sub">{{ mcl_packages | length }}</span>
</div>
<div class="card-body">
{% set mcl_users = mcl_links | selectattr('state', 'equalto', 'ok') | map(attribute='image') | list %}
<p class="text-muted">
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.
The bulk-enroll scripts pick the package by its first three name fields (tenant, environment, purpose) and fail
enrollment if two match, so an upload <strong>replaces</strong> the package with the same fields; the old one moves
to the archive below and can be restored. Every PC built from those images copies this folder while it images, so
upload, archive or restore only when no build is in progress.
</p>
<p class="text-muted">
<i class="bi bi-arrow-left-right"></i> A whole new vendor drop (packages, Office zips and scripts from MediaCreator Lite)
goes through <a href="/updates/bprt/">BPRT Sync</a> instead: it uploads only the changed files from the work PC, checks
them and swaps the folder in one step, keeping the site's own scripts.
</p>
{% for l in mcl_links if l.state in ('copy', 'elsewhere') %}
<div class="alert alert-warning py-2 mb-2">
<div class="ptext">
<p>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. The bulk-enroll scripts pick the package by its first three name fields
(tenant, environment, purpose) and fail enrollment if two match, so an upload <strong>replaces</strong> the package with
the same fields; the old one moves to the archive below and can be restored. Every PC built from those images copies
this folder while it images, so upload, archive or restore only when no build is in progress.</p>
<p>A whole new vendor drop (packages, Office zips and scripts from MediaCreator Lite) goes through
<a href="/updates/bprt/">BPRT Sync</a> instead: it uploads only the changed files from the work PC, checks them and
swaps the folder in one step, keeping the site's own scripts.</p>
</div>
{% for l in mcl_links if l.state in ('copy', 'elsewhere') %}
<div style="padding: 0 24px 10px">
<div class="notice"><span aria-hidden="true" style="font-weight: 700">!</span><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, not a link to
<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>,
{% endif %}so packages changed here do not reach its PCs. Move that folder aside and link it back to the shared one.
</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 %}
</span></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>
<!-- 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-content">
<form action="{{ url_for('enrollment_upload') }}" method="post" enctype="multipart/form-data">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="modal-header">
<h5 class="modal-title">Upload Enrollment Package</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
<h3 class="modal-title" id="uploadTitle">Upload SFLD package</h3>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<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"
accept=".ppkg" required>
<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
(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 class="form-field">
@@ -218,8 +320,8 @@
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary"><i class="bi bi-upload"></i> Upload</button>
<button type="button" class="ui-btn" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="ui-btn pri">Upload</button>
</div>
</form>
</div>
@@ -227,22 +329,22 @@
</div>
<!-- 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-content">
<form id="deleteForm" method="post">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="modal-header">
<h5 class="modal-title">Confirm Delete</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
<h3 class="modal-title" id="deleteTitle">Delete package</h3>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<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 class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-danger"><i class="bi bi-trash"></i> Delete</button>
<button type="button" class="ui-btn pri" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="ui-btn danger">Delete</button>
</div>
</form>
</div>

View File

@@ -1,88 +1,224 @@
{% 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) -%}
{% if d.status == 'ok' %}<span class="badge badge-success" title="{{ d.file }}">OK</span>
{% elif d.status == 'no-entry' %}<span class="badge badge-danger" title="No HardwareDriver.json entry matches this model">Missing</span>
{% 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>
{% else %}<span class="text-muted">-</span>{% endif %}
{# Hardware coverage: one line per model WinPE has reported. The line shows
the model, one coverage chip per image, the BIOS state and when it was
last seen; the body holds SKUs, serials, PC types, BIOS versions and the
driver files each image resolves to. #}
{% 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 %}
{% macro bios_badge(b) -%}
{% if b.status == 'current' %}<span class="badge badge-success">{{ b.target }} (current)</span>
{% elif b.status == 'update' %}<span class="badge badge-info" title="{{ b.file }}">Flashes to {{ b.target }}</span>
{% elif b.status == 'file-missing' %}<span class="badge badge-danger" title="{{ b.file }} not in the BIOS folder">File missing</span>
{% else %}<span class="badge badge-danger">No BIOS entry</span>{% endif %}
{% macro bios_chip(b) -%}
{% if b.status == 'current' %}{{ ui.chip('Current ' ~ b.target, 'ok') }}
{% elif b.status == 'update' %}{{ ui.chip('Flashes to ' ~ b.target, 'neutral') }}
{% elif b.status == 'file-missing' %}{{ ui.chip('File missing', 'err') }}
{% else %}{{ ui.chip('No BIOS entry', 'err') }}{% endif %}
{%- endmacro %}
{# 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 %}
<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 %}
{%- 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 %}
<div class="page-header">
<h1><i class="bi bi-cpu"></i> Hardware Coverage</h1>
<div class="header-actions">
<span class="badge badge-secondary badge-lg">{{ rows | length }} model{{ 's' if rows | length != 1 }} seen</span>
</div>
</div>
{% set gaps = rows | selectattr('gap') | rejectattr('dismissed') | list %}
{% set dismissed = rows | selectattr('gap') | selectattr('dismissed') | list %}
{{ ui.head('Hardware coverage', 'Operate', (rows | length) ~ ' model' ~ ('s' if rows | length != 1) ~ ' seen' ~ (', ' ~ (gaps | length) ~ ' with open gaps' if gaps else '')) }}
<p class="text-muted">
Every model that has booted WinPE here, as reported by the machine itself (WMI model, Dell system SKU,
BIOS version), checked against each image's driver map and the WinPE BIOS manifest. A model missing a
Win11 driver pack or a BIOS entry also shows on the Technician To-Do until it is added or dismissed.
</p>
<div class="section-card">
<section class="panel ui-list" aria-labelledby="hl-h">
<h2 id="hl-h" class="vh">Models seen</h2>
<p class="sub hintro">
Every model that has booted WinPE here, as the machine reported it (WMI model, Dell system SKU, BIOS version),
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.
</p>
{% if rows %}
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>Model (WMI)</th>
<th>System SKU</th>
<th>BIOS seen</th>
{% for image, os_label in images %}<th title="{{ image }}">{{ image | replace('gea-', '') }} <span class="text-muted">({{ os_label }})</span></th>{% endfor %}
<th>BIOS update</th>
<th>Last seen</th>
<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 style="display: flex; flex-wrap: wrap; align-items: center; gap: 10px; padding: 16px 24px">
<div role="search" style="flex: 1 1 220px; min-width: 160px; display: flex">
<label for="hq" class="vh">Search by model, SKU, serial, PC type or driver file</label>
<input id="hq" type="search" class="field" style="flex: 1; min-width: 0" placeholder="Model, SKU, serial, PC type or file">
</div>
<div role="group" aria-label="Show only" id="hfilters" style="display: flex; gap: 6px; flex-wrap: wrap">
<button type="button" class="fbtn" data-f="" aria-pressed="true">All {{ rows | length }}</button>
<button type="button" class="fbtn" data-f="gap" aria-pressed="false">Gaps {{ gaps | length }}</button>
<button type="button" class="fbtn" data-f="dismissed" aria-pressed="false">Dismissed {{ dismissed | length }}</button>
</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 %}
<div class="text-center text-muted py-5">
<p class="mb-1">No machines have reported yet.</p>
<p class="small">Models appear here the next time a machine boots WinPE from this server.</p>
<div class="hl-empty">
No machines have reported yet. Models appear here the next time a machine boots WinPE from this server.
</div>
{% 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 %}

View File

@@ -1,270 +1,305 @@
{% extends "base.html" %}
{% import '_ui.html' as ui %}
{% 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 %}
<style>
.osp-table td { vertical-align: top; white-space: normal; }
.osp-table td.num { width: 40px; color: var(--text-light); font-weight: 600; text-align: center; }
.osp-table .wrap { overflow-wrap: anywhere; }
.osp-table .sub { font-size: 12px; color: var(--text-light); }
.osp-table .form-control-sm { min-width: 10rem; }
.osp-table tr.row-new td { background: rgba(65, 129, 255, 0.06); }
#ospModelTable td { vertical-align: middle; }
.osp-note { font-size: 13px; color: var(--text-light); padding: 0.75rem 1.25rem; border-top: 1px solid var(--border); }
.osp-explain { font-size: 13.5px; }
.osp-explain p:last-child { margin-bottom: 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; }
.empty-row td { text-align: center; color: var(--text-light); padding: 1.25rem; }
.osp-list, .osp-list tbody, .osp-list tr, .osp-list td { display: block; }
.osp-list { width: 100%; border-collapse: collapse; margin: 0; }
.osp-list td { padding: 0; }
.osp-list tr.row-new > td { background: var(--edit-soft); }
.osp-phead { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; padding: 16px 24px 12px; }
.osp-phead h2 { margin: 0; font-size: 16px; font-weight: 600; }
.osp-phead .sp { margin-left: auto; display: flex; align-items: center; gap: 10px; }
.osp-note { font-size: 13px; color: var(--text-2); padding: 12px 24px; border-top: 1px solid var(--line); line-height: 1.55; }
.osp-list td.osp-empty { padding: 20px 24px; text-align: center; color: var(--muted); border-top: 1px solid var(--line); font-size: 14px; }
.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; }
.osp-list .num { color: var(--muted); font-size: 13px; }
.osp-list .chips { display: flex; gap: 6px; flex-wrap: nowrap; min-width: 0; }
.osp-list .name { font-weight: 600; }
/* 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>
{% endblock %}
{% block content %}
{% 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 %}
<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 }}?');">
<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 }}">
<i class="bi bi-arrow-counterclockwise"></i> Undo last save
</button>
<button type="submit" class="ui-btn" title="Last save: {{ last_save.ts | local_dt }}">Undo last save</button>
</form>
{% else %}
<span class="sub">Undo last save: nothing saved from this page yet</span>
{% 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 %}
{% set header_subtitle = 'Deploy/Control' %}
{% 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
<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>).
Edits here are staged until <strong>Review and save</strong>, which shows the exact diff of every file first.
<span class="mono">packages.json</span>. Edits are staged until <strong>Review and save</strong>,
which shows the exact diff of every file first.
</p>
{# ==================== Operating system ==================== #}
<div class="card" data-section="operating_systems">
<div class="card-header">
<span class="d-flex align-items-center gap-2">
<i class="bi bi-windows"></i> Operating system
<span class="badge badge-secondary">{{ oses | length }}</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>
<section class="panel ui-list" data-section="operating_systems" aria-labelledby="osp-os-h">
<div class="osp-phead">
<h2 id="osp-os-h">Operating system</h2>
{{ ui.chip(oses | length, 'neutral') }}
<span class="sp"><span class="sub mono">OperatingSystem.json</span></span>
</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">
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).
{% 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 %}
</div>
</div>
</section>
{# ==================== Packages ==================== #}
<div class="card" data-section="packages">
<div class="card-header">
<span class="d-flex align-items-center gap-2">
<i class="bi bi-box-seam"></i> Packages
<span class="badge badge-secondary">{{ packages | length }}</span>
<section class="panel ui-list" data-section="packages" aria-labelledby="osp-pk-h">
<div class="osp-phead">
<h2 id="osp-pk-h">Packages</h2>
{{ ui.chip(packages | length, 'neutral') }}
<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>
<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 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">
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.
</div>
</div>
</section>
{# ==================== Hardware models (user_selections.json) ==================== #}
<div class="card" data-section="hardware_models">
<div class="card-header">
<span class="d-flex align-items-center gap-2">
<i class="bi bi-cpu"></i> Hardware Models
<span class="badge badge-secondary">{{ models | length }}</span>
</span>
<div class="header-actions">
<span class="small text-muted fw-normal mono align-self-center">Tools/user_selections.json</span>
<button type="button" class="btn btn-sm btn-outline-primary" id="ospAddModel" {% if not has_selections %}disabled{% endif %}>
<i class="bi bi-plus-lg"></i> Add
</button>
</div>
</div>
<div class="card-body osp-explain border-bottom">
<p class="mb-2">
<strong>Media build selection, not used while imaging.</strong>
{% set n_none = models | selectattr('fam.entries', 'equalto', 0) | list | length %}
<details class="panel ui-list osp-models" data-section="hardware_models" {% if not models %}open{% endif %}>
<summary>
<span class="chev" aria-hidden="true">&#9662;</span>
<h2>Hardware models</h2>
{{ ui.chip(models | length, 'neutral') }}
{% if n_none %}{{ ui.chip(n_none ~ ' with no driver entry', 'warn') }}{% endif %}
<span class="sub">Media build selection, not used while imaging</span>
</summary>
<div class="explain">
<p>
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
<span class="mono">Upload-Image.ps1</span> read it to choose which driver packs and OS to fetch and upload.
</p>
<p class="text-muted">
<p>
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
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>
<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 class="table-container">
<table class="table mb-0 osp-table" id="ospModelTable">
<thead>
<tr>
<th>#</th>
<th>Model</th>
<th>Family ID</th>
<th>Driver entries</th>
<th></th>
</tr>
</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">
<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="osp-list" id="ospModelTable">
<tbody>
{% for r in models %}
<tr data-json='{{ r.m | tojson_ordered }}'><td class="ui-row">
<div class="mg">
<span class="num order-num">{{ loop.index }}</span>
<input type="text" class="field c-model" data-field="Model" value="{{ r.m.Model }}" aria-label="Model">
<input type="text" class="field c-fam" data-field="Id" value="{{ r.m.Id }}" aria-label="Family ID">
<span class="ents tip right"{% if r.fam.entries %} data-tip="{{ r.fam.on_disk }} of {{ r.fam.entries }} zips on disk"{% endif %}>
{% 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>
<div class="sub">{{ r.fam.on_disk }} zip{{ 's' if r.fam.on_disk != 1 }} on disk</div>
{{ 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') }}
<span class="sub">{{ r.fam.on_disk }} zip{{ 's' if r.fam.on_disk != 1 }} on disk</span>
{% else %}
<span class="badge badge-secondary">None</span>
{{ ui.chip('None', 'off') }}
{% endif %}
</td>
<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>
</tr>
{% endfor %}
<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>
</tbody>
</table>
</span>
<button type="button" class="ui-btn ghost sm remove-row" aria-label="Remove model {{ r.m.Model }}">Remove</button>
</div>
</td></tr>
{% endfor %}
<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>
</tbody>
</table>
</details>
<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>
<!-- Package upload modal (unchanged from the old Configuration page) -->
<div class="modal fade" id="packageUploadModal" tabindex="-1">
<!-- Package upload modal -->
<div class="modal fade" id="packageUploadModal" tabindex="-1" aria-labelledby="pkgUploadTitle">
<div class="modal-dialog">
<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() }}">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title">Upload package</h3>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
<h3 class="modal-title" id="pkgUploadTitle">Upload package</h3>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="form-field">
<label class="form-label">Package file</label>
<input type="file" class="form-control" name="package_file" required>
<div class="form-text">Lands in <code>{{ image_type }}/Deploy/Packages/</code>.</div>
<label class="form-label" for="pkgUploadFile">Package file</label>
<input type="file" class="form-control" name="package_file" id="pkgUploadFile" required>
<div class="form-text">Lands in <span class="mono">{{ image_type }}/Deploy/Packages/</span>.</div>
</div>
<div class="form-field">
<label class="form-label">Destination directory (optional)</label>
<input type="text" class="form-control mono" name="destination_dir"
<label class="form-label" for="pkgUploadDest">Destination directory (optional)</label>
<input type="text" class="form-control mono" name="destination_dir" id="pkgUploadDest"
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 class="form-check">
<input class="form-check-input" type="checkbox" name="overwrite" value="1" id="pkgOverwriteCheck">
@@ -272,8 +307,8 @@
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Upload</button>
<button type="button" class="ui-btn" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="ui-btn pri">Upload</button>
</div>
</div>
</form>
@@ -289,11 +324,13 @@
var dirty = {};
var reviewBtn = document.getElementById('ospReview');
var head = document.querySelector('.image-head');
var page = document.getElementById('ospPage');
function markDirty(section) {
dirty[section] = true;
reviewBtn.disabled = false;
if (head) head.classList.add('dirty');
if (page) page.classList.add('dirty');
}
function rowsOf(tableId) {
@@ -315,7 +352,15 @@
document.querySelectorAll('[data-section]').forEach(function (card) {
var section = card.getAttribute('data-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) {
// app.js removes the row and renumbers (delegated on document, so it
// runs after this); refresh the empty row once it is gone.
@@ -334,11 +379,13 @@
var tr = document.createElement('tr');
tr.className = 'row-new';
tr.innerHTML =
'<td class="num order-num"></td>' +
'<td><input type="text" class="form-control form-control-sm" data-field="Model" placeholder="Model name" aria-label="Model"></td>' +
'<td><input type="text" class="form-control form-control-sm" data-field="Id" placeholder="Driver family ID" aria-label="Family ID"></td>' +
'<td><span class="badge badge-info">New</span></td>' +
'<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>';
'<td class="ui-row"><div class="mg">' +
'<span class="num order-num"></span>' +
'<input type="text" class="field c-model" data-field="Model" placeholder="Model name" aria-label="Model">' +
'<input type="text" class="field c-fam" data-field="Id" placeholder="Driver family ID" aria-label="Family ID">' +
'<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'));
rowsOf('ospModelTable').forEach(function (r, i) { r.querySelector('.order-num').textContent = i + 1; });
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) {
if (Object.keys(dirty).length) { e.preventDefault(); e.returnValue = ''; }
});

View File

@@ -1,261 +1,313 @@
{% extends "base.html" %}
{% import '_ui.html' as ui %}
{% 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 %}
<style>
.ov-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; }
.ov-tile {
display: block; text-decoration: none; color: var(--text);
background: var(--bg-card); border: 1px solid var(--border); border-radius: 0.25rem;
border-left: 4px solid var(--border);
padding: 0.9rem 1rem; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06);
transition: border-color 0.15s;
/* Needs attention */
.ov-attn { list-style: none; margin: 0; padding: 0; }
.ov-attn li { display: grid; grid-template-columns: 74px minmax(0, 1fr) auto; gap: 4px 14px; align-items: start;
padding: 12px 24px; border-top: 1px solid var(--line); font-size: 14px; line-height: 1.5; }
.ov-attn li .chip { justify-self: start; margin-top: 1px; }
.ov-attn li a { white-space: nowrap; font-size: 13px; font-weight: 600; text-decoration: none; }
.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>
{% endblock %}
{% block content %}
{% 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('dell_updates.page') }}" class="btn btn-outline-secondary btn-sm"><i class="bi bi-motherboard"></i> Dell Updates</a>
<a href="{{ url_for('hardware_page') }}" class="ui-btn">Hardware coverage</a>
<a href="{{ url_for('dell_updates.page') }}" class="ui-btn">Dell updates</a>
{% endset %}
{% include "_image_header.html" %}
{% set d = ov.drivers %}
{% 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 os_row = ov.os.current %}
{% set drv_url = image_tab_url(image_type, 'drivers') %}
{% set os_url = image_tab_url(image_type, 'os') %}
{# -------- tiles -------- #}
<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 -------- #}
{# -------- needs attention: (kind, text, link, label) -------- #}
{% 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 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 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')) %}
{% 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 %}
{% 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.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.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.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.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.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 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(('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(('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(('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(('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(('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(('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(('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(('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(('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="card-header">
<span class="d-flex align-items-center gap-2"><i class="bi bi-clipboard-pulse"></i> Needs attention</span>
<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="ui-stack">
<section class="panel" aria-labelledby="ov-attn-h">
<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>
{% if attn %}
<ul class="ov-attn">
{% for icon, 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>
{% for kind, text, link, label in attn %}
<li>{{ ui.chip(kind_label[kind], kind) }}<span>{{ text }}</span><a href="{{ link }}">Open {{ label }}</a></li>
{% endfor %}
</ul>
{% 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 %}
</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 -------- #}
<div class="card">
<div class="card-header">
<span class="d-flex align-items-center gap-2"><i class="bi bi-folder2-open"></i> Control files</span>
</div>
<div class="table-container">
<table class="table mb-0 ov-table">
<thead><tr><th>File</th><th>Last modified</th><th class="text-end">Size</th><th class="text-end">Backups</th></tr></thead>
<tbody>
{% for f in ov.files %}
<tr>
<td>
<a href="{{ image_tab_url(image_type, f.tab) }}" class="mono">{{ f.name }}</a>
<div class="small text-muted">{{ f.what }}</div>
<div class="small text-muted mono file-cell">{{ f.rel }}</div>
</td>
<td class="text-nowrap">
{% if f.exists %}{{ f.mtime | local_dt }}{% else %}<span class="badge badge-danger">Missing</span>{% endif %}
</td>
<td class="text-end text-nowrap mono">{% if f.exists %}{{ f.size | filesizeformat }}{% else %}-{% endif %}</td>
<td class="text-end mono">{{ f.backups or '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<section class="panel ui-list" aria-labelledby="ov-cf-h">
<div class="ov-phead"><h2 id="ov-cf-h">Control files</h2></div>
<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>
{% for f in ov.files %}
<div class="ui-row">
<details>
<summary class="cf-grid">
<span class="tip" data-tip="{{ f.rel }}"><span class="trunc mono">{{ f.name }}</span></span>
<span class="when">{% if f.exists %}{{ f.mtime | local_dt }}{% else %}{{ ui.chip('Missing', 'err') }}{% endif %}</span>
<span class="r mono c-size">{% if f.exists %}{{ f.size | filesizeformat }}{% else %}-{% endif %}</span>
<span class="r mono c-bak">{{ f.backups or '-' }}</span>
<span class="chev" aria-hidden="true">&#9662;</span>
</summary>
<div class="cf-body">
<span>{{ f.what }}</span>
<span class="mono">{{ f.rel }}</span>
<span>{% if f.exists %}{{ f.size | filesizeformat }}, {% endif %}{{ f.backups }} backup{{ 's' if f.backups != 1 }} next to the file</span>
</div>
</details>
<div class="act"><a class="ui-btn sm" href="{{ image_tab_url(image_type, f.tab) }}" aria-label="Open {{ f.name }}">Open</a></div>
</div>
{% endfor %}
{% set saves = ov.last_saves | dictsort | selectattr(1) | list %}
{% if saves %}
<div class="card-footer small text-muted">
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 %}
<div class="hidden-bar">
<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 %}</span>
</div>
{% 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 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>
{% endblock %}

View File

@@ -1,189 +1,200 @@
{% extends "base.html" %}
{% import '_ui.html' as ui %}
{% 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 %}
<div class="page-header">
<div>
<h1>{{ session.serial }}
{% if session.hostname_target %}<span class="text-light" style="font-size:14px; font-weight:400;">{{ session.hostname_target }}</span>{% endif %}
<header class="ui-head">
<div style="min-width: 0">
<a href="{{ url_for('imaging_dashboard') }}" class="eyebrow-t" style="text-decoration: none">Imaging</a>
<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>
<div style="margin-top:0.5rem; display:flex; gap:0.4rem; flex-wrap:wrap;">
{% if session.pctype %}<span class="badge badge-info">{{ session.pctype }}</span>{% endif %}
{% if session.machinenumber %}<span class="badge badge-secondary">#{{ session.machinenumber }}</span>{% endif %}
<span class="badge badge-{{ 'success' if session.status == 'succeeded' else ('danger' if session.status == 'failed' else 'primary') }}">{{ session.status or 'in_progress' }}</span>
<div class="sub" style="margin-top: 4px; font-size: 13px">
Stage {{ stage_idx }} of {{ session.stage_total or '?' }}{% if session.current_stage %}: <span class="mono">{{ session.current_stage }}</span>{% endif %}
<span class="muted"> | updated {{ session.last_updated | local_dt(true) }}</span>
</div>
{% if stage_total %}<div style="max-width: 360px">{{ ui.track(stage_total, stage_idx, st_kind) }}</div>{% endif %}
</div>
<div class="header-actions">
<a href="{{ url_for('imaging_dashboard') }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left"></i> Back to dashboard
</a>
<button type="button" class="btn btn-primary btn-sm copy-summary-btn">
<i class="bi bi-clipboard"></i> Copy support summary
</button>
<div class="actions">
<a href="{{ url_for('imaging_dashboard') }}" class="ui-btn">Back to imaging</a>
<button type="button" class="ui-btn pri copy-btn copy-summary-btn" data-copy-text="{{ summary_lines | join('\n') }}">Copy support summary</button>
</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 class="row">
<div class="col-lg-5">
<div class="section-card">
<div class="section-title">Session metadata</div>
<dl class="row mb-0">
<dt class="col-4 text-light">Serial</dt><dd class="col-8"><span class="mono">{{ session.serial }}</span></dd>
<dt class="col-4 text-light">Hostname</dt><dd class="col-8"><span class="mono">{{ session.hostname_target or '-' }}</span></dd>
<dt class="col-4 text-light">MAC</dt><dd class="col-8"><span class="mono">{{ session.mac or '-' }}</span></dd>
<dt class="col-4 text-light">PC type</dt><dd class="col-8">{{ session.pctype or '-' }}</dd>
<dt class="col-4 text-light">Machine #</dt><dd class="col-8">{{ session.machinenumber or '-' }}</dd>
<dt class="col-4 text-light">Intune ID</dt><dd class="col-8">
{% if session.intune_device_id %}
<span class="mono" style="word-break: break-all;">{{ session.intune_device_id }}</span>
{% else %}-{% endif %}
</dd>
<dt class="col-4 text-light">Started</dt><dd class="col-8"><span class="mono">{{ session.started_at | local_dt(true) }}</span></dd>
<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>
<div class="id-cols">
<div class="panel id-side">
<section aria-labelledby="shHead">
<h2 id="shHead">Stage history</h2>
{% if history %}
<ol class="sh-list">
{% for h in history %}
{% set hk = {'succeeded': 'ok', 'failed': 'fail'}.get(h.status, 'run' if loop.last else '') %}
<li class="sh-item">
<span class="sh-dot {{ hk }}" aria-hidden="true"></span>
<span class="sh-name">{{ h.current_stage or ('Stage ' ~ h.stage_index) }}</span>
<span class="sh-when mono">{{ h.ts | local_dt(true) }}</span>
<span class="sh-meta">Stage {{ h.stage_index }}{% if stage_total %} of {{ stage_total }}{% endif %}
{% if h.status == 'failed' %}{{ ui.chip('Failed', 'fail') }}{% elif h.status == 'succeeded' %}{{ ui.chip('Succeeded', 'ok') }}{% endif %}</span>
</li>
{% endfor %}
</ol>
{% else %}
<div class="status-indicator">
<i class="bi bi-info-circle"></i>
<span>No stage transitions recorded yet. The client only logs a row when
stage_index advances or status flips to succeeded/failed.</span>
</div>
<p class="sub" style="margin: 8px 0 0">No stage transitions recorded yet. The client only logs a row when
stage_index advances or status flips to succeeded or failed.</p>
{% 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 class="col-lg-7">
<div class="section-card">
<div class="section-title" style="display:flex; justify-content:space-between; align-items:center;">
<span>Full log
{% if full_log_truncated %}
<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 class="id-main">
<section class="panel id-log" aria-labelledby="logHead">
<div class="id-loghead">
<div class="grow"><h2 id="logHead">Full log</h2>
{% if full_log_truncated %}{{ ui.chip('Truncated: last 1 MB', 'warn') }}{% endif %}</div>
{% if full_log %}<button type="button" class="ui-btn sm copy-log-btn">Copy log</button>{% endif %}
</div>
{% 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 %}
<div class="status-indicator">
<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>
<div class="notice info">No log content. The client has not pushed any log_lines entries yet, or the sidecar file was cleared.</div>
{% endif %}
</div>
</section>
{% if session.log_tail %}
<div class="section-card">
<div class="section-title">Recent tail ({{ session.log_tail | length }} line{{ 's' if session.log_tail | length != 1 }})</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 }}
<section class="panel id-log" aria-labelledby="tailHead">
<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="id-pre id-tail" tabindex="0" aria-label="Recent log tail">{% for line in session.log_tail %}{{ line }}
{% endfor %}</pre>
</div>
</section>
{% endif %}
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
function copyText(text) {
if (navigator.clipboard && window.isSecureContext) {
return navigator.clipboard.writeText(text);
}
return new Promise(function(resolve, reject) {
var ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed'; ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.focus(); ta.select();
try {
var ok = document.execCommand('copy');
document.body.removeChild(ta);
if (ok) resolve(); else reject(new Error('execCommand returned false'));
} catch (err) {
document.body.removeChild(ta);
reject(err);
}
// Copy log: the log can be up to 1 MB, so it is read from the <pre> rather
// than carried in a data-copy-text attribute. Same fallback as static/app.js
// (the LAN is plain http, so navigator.clipboard is often unavailable).
(function () {
function copyText(text) {
if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.writeText(text);
return new Promise(function (resolve, reject) {
var ta = document.createElement('textarea');
ta.value = text; ta.setAttribute('readonly', ''); ta.style.position = 'fixed'; ta.style.opacity = '0';
document.body.appendChild(ta); ta.select();
var ok = false;
try { ok = document.execCommand('copy'); } catch (err) { ok = false; }
document.body.removeChild(ta);
if (ok) resolve(); else reject(new Error('copy failed'));
});
}
document.addEventListener('click', function(e) {
var logBtn = e.target.closest('.copy-log-btn');
var summaryBtn = e.target.closest('.copy-summary-btn');
if (logBtn) {
var pre = document.getElementById('full-log');
if (pre) {
copyText(pre.textContent).then(function() {
logBtn.innerHTML = '<i class="bi bi-check2"></i> copied!';
setTimeout(function() { logBtn.innerHTML = '<i class="bi bi-clipboard"></i> Copy'; }, 1200);
});
}
} 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);
});
}
});
}
document.addEventListener('click', function (e) {
var btn = e.target.closest && e.target.closest('.copy-log-btn');
if (!btn) return;
var pre = document.getElementById('full-log');
if (!pre) return;
var done = function (ok) {
btn.textContent = ok ? 'Copied' : 'Copy failed';
setTimeout(function () { btn.textContent = 'Copy log'; }, 1400);
};
copyText(pre.textContent).then(function () { done(true); }, function () { done(false); });
});
})();
</script>
{% endblock %}

View File

@@ -1,164 +1,151 @@
{% extends "base.html" %}
{% import '_ui.html' as ui %}
{% 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 %}
<div class="page-header">
<h1><i class="bi bi-box-arrow-in-down"></i> Image Import</h1>
{{ ui.head('Image import', 'Images', "Copy WinPE deployment content into an image's Deploy folder, from the network upload share or a USB drive.") }}
<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 class="row g-4">
<div class="col-lg-8">
<!-- Network Upload Import -->
<div class="section-card">
<div class="section-title"><i class="bi bi-hdd-network"></i> Import from Network Upload</div>
{% if upload_sources %}
<form method="POST" id="uploadImportForm">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="form-field">
<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>
<section class="panel ui-list" aria-labelledby="imp-st-h">
<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>
{% for img in images %}
<div class="ig">
<span class="tip" data-tip="{{ img.friendly_name }}"><span class="trunc name">{{ img.friendly_name }}</span></span>
<span class="tip c-path" data-tip="{{ img.deploy_path }}"><span class="trunc mono sub">{{ img.image_type }}/Deploy</span></span>
<span>{% if img.has_content %}{{ ui.chip('Present', 'ok') }}{% else %}{{ ui.chip('Empty', 'off') }}{% endif %}</span>
<span class="c-un">{% if img.has_unattend %}{{ ui.chip('Unattend', 'neutral') }}{% else %}<span class="sub">No unattend</span>{% endif %}</span>
{% 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 %}
</div>
{% endfor %}
</section>
</div>
{% endblock %}
@@ -181,7 +168,7 @@ document.addEventListener('DOMContentLoaded', function() {
if (!confirm(msg)) { e.preventDefault(); return; }
var btn = form.querySelector('button[type="submit"]');
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" %}
{% import '_ui.html' as ui %}
{% block title %}{{ friendly_name }} - Unattend{% endblock %}
{#
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,
"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 %}
<style>
.una-bar {
display: flex; flex-wrap: wrap; gap: 0.5rem 1rem; align-items: center;
justify-content: space-between; margin-bottom: 1rem;
}
.una-bar .nav-pills .nav-link {
font-size: 12px; padding: 0.4rem 0.9rem; display: flex; align-items: center; gap: 0.4rem;
color: var(--text-light); border: 1px solid var(--border); background: transparent;
}
.una-bar .nav-pills .nav-item + .nav-item .nav-link { margin-left: 0.4rem; }
.una-bar .nav-pills .nav-link.active { background: var(--primary); border-color: var(--primary); color: #fff; }
.una-meta { font-size: 12.5px; color: var(--text-light); display: flex; flex-wrap: wrap; gap: 0.25rem 0.75rem; align-items: center; }
.una-meta .badge { font-size: 10.5px; }
.una-lint-list { font-size: 13px; margin: 0.5rem 0 0; padding-left: 1.1rem; }
.una-lint-list li { margin-bottom: 0.25rem; }
.una-bar { display: flex; flex-wrap: wrap; gap: 10px 16px; align-items: center; justify-content: space-between; }
/* Form / Raw XML switch: Bootstrap pills underneath, drawn as a segmented control */
.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);
font-size: 13px; color: var(--text); text-decoration: none; }
.una-seg .nav-item + .nav-item .nav-link { border-left: 1px solid var(--control); }
.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-meta a.chip { text-decoration: none; }
.una-lint-list { font-size: 13px; margin: 8px 0 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 6px; }
.una-lint-list .chip { font-size: 11px; padding: 1px 6px; margin-right: 6px; }
.una-lintbox { flex-direction: column; gap: 0; }
/* Form view: section index beside the sections (wide), or a sticky row of
links above them (narrow). */
.una-layout { display: block; }
.una-index {
position: sticky; top: 0; z-index: 20; background: var(--bg);
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 { position: sticky; top: 0; z-index: 20; background: var(--bg); margin: 0 0 14px; padding: 8px 0; }
.una-index ul { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: nowrap; gap: 6px; overflow-x: auto; scrollbar-width: none; }
.una-index .ix-item:not(.ix-keep), .una-index .ix-wide, .una-index .ix-long { display: none; }
.una-index a {
display: flex; align-items: center; gap: 0.4rem; text-decoration: none;
font-size: 12.5px; color: var(--text-light); padding: 0.25rem 0.6rem;
border: 1px solid var(--border); border-radius: 999px; white-space: nowrap;
}
.una-index a:hover { color: var(--text); border-color: var(--primary); }
.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);
}
.una-index a { display: flex; align-items: center; gap: 6px; text-decoration: none; font-size: 13px; color: var(--text-2);
padding: 0 12px; min-height: 30px; border: 1px solid var(--line-2); border-radius: 15px; white-space: nowrap; background: var(--surface-3); }
.una-index a:hover { color: var(--text); }
.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.has-edits::after { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--act); flex: none; }
@media (min-width: 1280px) {
.una-layout { display: grid; grid-template-columns: 200px minmax(0, 1fr); gap: 1.5rem; align-items: start; }
.una-index {
top: 1rem; margin: 0; padding: 0; border-bottom: 0; background: transparent;
max-height: calc(100vh - 2rem); overflow-y: auto;
}
.una-layout { display: grid; grid-template-columns: 210px minmax(0, 1fr); gap: 24px; align-items: start; }
.una-index { top: 16px; margin: 0; padding: 0; background: transparent; max-height: calc(100vh - 32px); overflow-y: auto; }
.una-index ul { display: block; }
.una-index .ix-item:not(.ix-keep), .una-index .ix-wide { display: flex; }
.una-index .ix-long { display: inline; }
.una-index .ix-short { display: none; }
.una-index a.ix-group {
font-size: 11px; font-weight: 600; letter-spacing: 1px; text-transform: uppercase;
margin-top: 0.9rem; border-left-color: transparent !important; background: transparent !important;
}
.una-index a { border: 0; border-radius: 7px; background: transparent; min-height: 32px; padding: 0 10px; justify-content: flex-start; }
.una-index a.ix-group { font-size: 12px; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); margin-top: 14px; }
.una-index li:first-child a.ix-group { margin-top: 0; }
.una-index a {
border: 0; border-left: 2px solid transparent; border-radius: 0;
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.active { background: var(--surface); color: var(--text); font-weight: 600; box-shadow: inset 3px 0 0 var(--accent), 0 1px 2px var(--shadow); }
.una-index a.ix-group.active { background: transparent; box-shadow: none; color: var(--text); }
.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; }
@media (min-width: 1280px) { .una-section { scroll-margin-top: 1rem; } }
.group-heading {
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;
}
.group-heading { margin: 14px 0 0; font-size: 18px; font-weight: 600; }
.una-main > .group-heading:first-child { margin-top: 0; }
.group-heading .bi { color: var(--primary); }
.card-header .title-group { display: flex; align-items: center; gap: 0.5rem; font-weight: 600; }
.card-header .title-group .bi { color: var(--primary); }
.count-pill {
font-size: 11px; font-weight: 600; background: var(--bg); color: var(--text-light);
border: 1px solid var(--border); border-radius: 999px; padding: 1px 9px;
}
.una-hint { font-size: 12.5px; color: var(--text-light); }
.una-empty { text-align: center; color: var(--text-light); padding: 1rem; font-size: 13.5px; }
.una-phead { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; padding: 16px 24px 12px; }
.una-phead h3 { margin: 0; font-size: 16px; font-weight: 600; }
.una-phead .sp { margin-left: auto; }
.una-pbody { padding: 0 24px 20px; }
.una-pbody .form-label { font-size: 13px; color: var(--text-2); margin-bottom: 4px; }
.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: 13px; color: var(--muted); }
.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). */
.una-filter {
display: flex; flex-wrap: wrap; gap: 0.5rem 0.75rem; align-items: center;
background: var(--bg); padding: 0.5rem 0; margin-bottom: 0.75rem;
}
.una-filter { display: flex; flex-wrap: wrap; gap: 8px 14px; align-items: center; background: var(--bg); padding: 8px 0; }
@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 .bi { position: absolute; left: 0.7rem; top: 50%; transform: translateY(-50%); color: var(--text-light); }
.una-filter input { padding-left: 2rem; }
.una-filter .input-wrap { flex: 1 1 18rem; max-width: 34rem; display: flex; }
.una-filter .field { flex: 1; min-width: 0; }
.una-filter .una-hint strong { color: var(--text); }
/* One command: number + grip | description, full command, counters | actions */
.una-list { border-top: 1px solid var(--border); }
/* One command: number + grip | description, full command, counters | menu */
.una-list { border-top: 1px solid var(--line); }
.una-list:empty { border-top: 0; }
.una-row {
display: grid; grid-template-columns: 2.4rem minmax(0, 1fr) auto; gap: 0.6rem;
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 { display: grid; grid-template-columns: 44px minmax(0, 1fr) auto; gap: 10px; padding: 10px 16px 10px 8px;
border-bottom: 1px solid var(--line); box-shadow: inset 3px 0 0 transparent; transition: background-color .15s; }
.una-row:last-child { border-bottom: 0; }
.una-row.is-edited { border-left-color: var(--warning); }
.una-row.is-new { border-left-color: var(--success); }
.una-row.dragging { opacity: 0.45; }
.una-row.drop-before { box-shadow: inset 0 2px 0 var(--primary); }
.una-row.drop-after { box-shadow: inset 0 -2px 0 var(--primary); }
.una-side { display: flex; flex-direction: column; align-items: center; gap: 0.35rem; padding-top: 0.2rem; }
.una-pos { font-weight: 600; color: var(--text-light); font-size: 13px; line-height: 1; }
.una-grip { color: var(--text-light); cursor: grab; padding: 0.1rem 0.3rem; border-radius: 4px; }
.una-grip:hover { color: var(--text); background: var(--bg); }
.una-body { min-width: 0; display: flex; flex-direction: column; gap: 0.4rem; }
.una-note {
font-size: 12.5px; color: var(--text-light); font-style: italic;
display: flex; gap: 0.4rem; align-items: baseline;
}
.una-row.is-edited, .una-simple.is-edited { background: var(--edit-soft); box-shadow: inset 3px 0 0 var(--accent); }
.una-row.is-new, .una-simple.is-new { background: var(--edit-soft); box-shadow: inset 3px 0 0 var(--ok); }
.una-row.dragging { opacity: .45; }
.una-row.drop-before { box-shadow: inset 0 2px 0 var(--accent); }
.una-row.drop-after { box-shadow: inset 0 -2px 0 var(--accent); }
.una-side { display: flex; flex-direction: column; align-items: center; gap: 6px; padding-top: 6px; }
.una-pos { font-size: 13px; color: var(--muted); line-height: 1; }
.una-grip { color: var(--muted); cursor: grab; padding: 2px 5px; border-radius: 4px; }
.una-grip:hover { color: var(--text); background: var(--surface-3); }
.una-body { min-width: 0; display: flex; flex-direction: column; gap: 6px; }
.una-note { font-size: 13px; color: var(--muted); font-style: italic; display: flex; gap: 6px; align-items: baseline; }
.una-note .bi { font-style: normal; flex: none; }
.una-head { display: flex; gap: 0.5rem; align-items: center; }
.una-head .una-desc { flex: 1 1 auto; font-weight: 500; }
.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; }
textarea.una-cmd {
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace; font-size: 12.5px; line-height: 1.5;
resize: none; overflow: hidden; white-space: pre-wrap; overflow-wrap: anywhere; word-break: normal;
min-height: 2.1rem;
}
.una-foot { display: flex; flex-wrap: wrap; gap: 0.25rem 1rem; font-size: 11.5px; color: var(--text-light); }
.una-head { display: flex; gap: 10px; align-items: center; }
.una-head .una-desc { flex: 1 1 auto; font-weight: 600; }
.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; }
.una-tag .chip { font-size: 11px; padding: 1px 6px; }
textarea.una-cmd { font-family: var(--font-mono); font-size: 13px; line-height: 1.5; resize: none; overflow: hidden; white-space: pre-wrap;
overflow-wrap: anywhere; word-break: normal; min-height: 2.1rem; }
.una-foot { display: flex; flex-wrap: wrap; gap: 4px 16px; font-size: 12px; color: var(--muted); }
.una-foot[hidden] { display: none; }
.una-inner { font-style: italic; }
.una-count.near { color: var(--warning-dark); }
[data-theme="dark"] .una-count.near { color: var(--warning); }
.una-count.over { color: var(--danger); font-weight: 600; }
.una-actions { display: flex; flex-direction: column; gap: 0.3rem; }
.una-actions .btn { padding: 6px 9px; }
.filtering .una-grip, .filtering .una-up, .filtering .una-down { opacity: 0.3; pointer-events: none; }
.una-count.near { color: var(--warn-text); }
.una-count.over { color: var(--fail-text); font-weight: 600; }
.una-actions { display: flex; align-items: flex-start; padding-top: 2px; }
.filtering .una-grip, .filtering .una-up, .filtering .una-down { opacity: .3; pointer-events: none; }
.una-row[hidden] { display: none; }
/* 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.is-edited { border-left-color: var(--warning); }
.una-simple.is-new { border-left-color: var(--success); }
.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)); } }
.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 {
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;
}
.raw-xml-editor { font-family: var(--font-mono); font-size: 13px; min-height: 600px; height: 70vh; tab-size: 4; white-space: pre; resize: vertical; }
/* Review modal additions (the modal itself is in _image_header.html) */
.una-review-lint { margin-top: 1rem; }
#imageReviewBody > .notice { margin-bottom: 12px; }
.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 .bi { color: var(--success-dark); }
[data-theme="dark"] .una-lint-ok .bi { color: var(--success); }
.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: 0.75rem; margin: 0.5rem 0 0;
background: var(--bg-card-solid); border: 1px solid var(--border); border-radius: 0.25rem; white-space: pre;
.una-lint-ok { font-size: 13px; color: var(--muted); display: flex; gap: 6px; align-items: center; }
.una-lint-ok .bi { color: var(--ok); }
.una-review-xml summary { cursor: pointer; font-size: 13px; color: var(--accent); font-weight: 600; 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);
border: 1px solid var(--line-2); border-radius: 8px; 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>
{% endblock %}
{% 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 %}
{% macro cmd_row(cmd, idx, field, limit) -%}
<div class="una-row" data-orig="{{ idx }}">
<div class="una-side">
<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 class="una-body">
{{ note(cmd) }}
@@ -197,63 +183,51 @@
data-limit="{{ limit }}">{{ cmd[field] }}</textarea>
<div class="una-foot">
<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 class="una-actions">
<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 class="una-actions">{{ row_menu('command ' ~ (idx + 1)) }}</div>
</div>
{%- endmacro %}
{% block content %}
{% set header_subtitle = 'Deploy/FlatUnattendW10.xml' %}
{% 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 %}
<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 }}?');">
<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 }})">
<i class="bi bi-arrow-counterclockwise"></i> Undo last save
</button>
<button type="submit" class="ui-btn" title="Last save: {{ last_save.ts | local_dt }} ({{ last_save.action }})">Undo last save</button>
</form>
{% else %}
<span class="sub">Undo last save: nothing saved from this page yet</span>
{% 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 %}
{% include "_image_header.html" %}
<div class="ui-stack" id="unaPage">
{% if unattend_override %}
<div class="alert alert-warning">
<i class="bi bi-exclamation-triangle"></i>
<div class="notice"><i class="bi bi-exclamation-triangle" aria-hidden="true"></i><span>
<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>
{% endif %}
{% if not file_exists %}
<div class="alert alert-info">
<i class="bi bi-info-circle"></i>
<div class="notice info"><i class="bi bi-info-circle" aria-hidden="true"></i><span>
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>
{% endif %}
{% set lint_errors = lint | selectattr('level', 'equalto', 'ERROR') | list %}
<div>
<div class="una-bar">
<ul class="nav nav-pills" role="tablist">
<li class="nav-item">
<a class="nav-link active" id="unaFormTab" data-bs-toggle="pill" href="#unaFormView" role="tab">
<i class="bi bi-ui-checks-grid"></i> Form
</a>
<ul class="nav nav-pills una-seg" role="tablist" aria-label="Editor view">
<li class="nav-item" role="presentation">
<a class="nav-link active" id="unaFormTab" data-bs-toggle="pill" href="#unaFormView" role="tab" aria-controls="unaFormView" aria-selected="true">Form</a>
</li>
<li class="nav-item">
<a class="nav-link" id="unaRawTab" data-bs-toggle="pill" href="#unaRawView" role="tab">
<i class="bi bi-code-slash"></i> Raw XML
</a>
<li class="nav-item" role="presentation">
<a class="nav-link" id="unaRawTab" data-bs-toggle="pill" href="#unaRawView" role="tab" aria-controls="unaRawView" aria-selected="false">Raw XML</a>
</li>
</ul>
<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>
{% endif %}
{% 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 %}
<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 %}
<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 %}
</div>
</div>
{% if lint %}
<div class="collapse {% if lint_errors %}show{% endif %}" id="unaLint">
<div class="alert {% if lint_errors %}alert-danger{% else %}alert-warning{% endif %}">
<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 %}
<div class="collapse {% if lint_errors %}show{% endif %}" id="unaLint" style="margin-top: 12px">
<div class="notice una-lintbox {{ 'bad' if lint_errors }}">
<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 %}</div>
<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>
</div>
</div>
{% endif %}
</div>
<div class="tab-content">
@@ -312,14 +287,14 @@
<div class="una-main" id="unaMain">
<!-- ============= 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">
<div class="card-header">
<span class="title-group"><i class="bi bi-hdd-network"></i> Machine settings</span>
<section class="panel una-section" id="sec-machine" data-section="machine" data-group="system" aria-labelledby="h-machine">
<div class="una-phead">
<h3 id="h-machine">Machine settings</h3>
<span class="una-hint">specialize pass, Microsoft-Windows-Shell-Setup</span>
</div>
<div class="card-body">
<div class="una-pbody">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label" for="unaComputerName">Computer name</label>
@@ -346,12 +321,12 @@
</div>
</section>
<section class="card una-section" id="sec-intl" data-section="intl" data-group="system">
<div class="card-header">
<span class="title-group"><i class="bi bi-globe2"></i> International</span>
<section class="panel una-section" id="sec-intl" data-section="intl" data-group="system" aria-labelledby="h-intl">
<div class="una-phead">
<h3 id="h-intl">International</h3>
<span class="una-hint">oobeSystem pass, Microsoft-Windows-International-Core</span>
</div>
<div class="card-body">
<div class="una-pbody">
<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")] %}
<div class="col-md-3 col-6">
@@ -364,17 +339,13 @@
</div>
</section>
<section class="card una-section" id="sec-driverpaths" data-section="driverpaths" data-group="system">
<div class="card-header">
<span class="title-group">
<i class="bi bi-usb-drive"></i> Driver paths
<span class="count-pill" data-count="driverpaths">{{ data.driver_paths | length }}</span>
</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>
<section class="panel una-section" id="sec-driverpaths" data-section="driverpaths" data-group="system" aria-labelledby="h-dp">
<div class="una-phead">
<h3 id="h-dp">Driver paths</h3>
<span class="chip c-neutral" data-count="driverpaths">{{ data.driver_paths | length }}</span>
<button type="button" class="ui-btn sp una-add" data-list="driverpaths">Add path</button>
</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).
<span class="mono">W:\Drivers</span> is where PESetup unpacks this model's driver pack.
</div>
@@ -382,8 +353,8 @@
{% for dp in data.driver_paths %}
<div class="una-simple" data-orig="{{ loop.index0 }}">
<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">
<button type="button" class="btn btn-outline-danger btn-sm una-remove" title="Remove"><i class="bi bi-trash"></i></button>
<input type="text" class="form-control form-control-sm mono una-dp" value="{{ dp }}" aria-label="Driver path {{ loop.index }}">
<button type="button" class="ui-btn ghost sm una-remove" aria-label="Remove driver path {{ loop.index }}">Remove</button>
</div>
{% endfor %}
</div>
@@ -391,44 +362,38 @@
</section>
<!-- ============= 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">
<div class="card-header">
<span class="title-group">
<i class="bi bi-person-badge"></i> User accounts (local)
<span class="count-pill" data-count="accounts">{{ data.user_accounts | length }}</span>
</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>
<section class="panel una-section" id="sec-accounts" data-section="accounts" data-group="accounts" aria-labelledby="h-acct">
<div class="una-phead">
<h3 id="h-acct">User accounts (local)</h3>
<span class="chip c-neutral" data-count="accounts">{{ data.user_accounts | length }}</span>
<button type="button" class="ui-btn sp una-add" data-list="accounts">Add account</button>
</div>
<div class="una-list" id="unaAccounts" data-list="accounts">
{% for acct in data.user_accounts %}
<div class="una-simple" data-orig="{{ loop.index0 }}" data-plain="{{ acct.plain_text }}">
<span class="una-pos">{{ loop.index }}</span>
<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>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>Group</label><input type="text" class="form-control form-control-sm" 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 }}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 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 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 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>
<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>
{% endfor %}
</div>
<div class="una-empty" data-empty="accounts" {% if data.user_accounts %}hidden{% endif %}>No local accounts.</div>
<div class="card-footer una-hint">
<i class="bi bi-shield-lock"></i> Passwords sit in the answer file as plain text. Treat this file as sensitive.
</div>
<div class="una-foot-note">Passwords sit in the answer file as plain text. Treat this file as sensitive.</div>
</section>
<section class="card una-section" id="sec-autologon" data-section="autologon" data-group="accounts">
<div class="card-header">
<span class="title-group"><i class="bi bi-box-arrow-in-right"></i> AutoLogon</span>
<section class="panel una-section" id="sec-autologon" data-section="autologon" data-group="accounts" aria-labelledby="h-al">
<div class="una-phead">
<h3 id="h-al">AutoLogon</h3>
<span class="una-hint">clear the user name to remove AutoLogon</span>
</div>
<div class="card-body">
<div class="una-pbody">
<div class="row g-3 align-items-end">
<div class="col-md-3 col-6">
<label class="form-label" for="unaAlEnabled">Enabled</label>
@@ -460,14 +425,14 @@
</section>
<!-- ============= 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">
<div class="card-header">
<span class="title-group"><i class="bi bi-toggles"></i> OOBE settings</span>
<section class="panel una-section" id="sec-oobe" data-section="oobe" data-group="oobe" aria-labelledby="h-oobe">
<div class="una-phead">
<h3 id="h-oobe">OOBE settings</h3>
<span class="una-hint">"Not set" = not in the file (Windows default)</span>
</div>
<div class="card-body">
<div class="una-pbody">
<div class="row g-3">
{% set oobe_fields = [
("HideEULAPage", "Hide EULA page", ["true", "false"]),
@@ -495,12 +460,12 @@
</div>
</section>
<section class="card una-section" id="sec-oobetz" data-section="oobetz" data-group="oobe">
<div class="card-header">
<span class="title-group"><i class="bi bi-clock-history"></i> OOBE time zone</span>
<section class="panel una-section" id="sec-oobetz" data-section="oobetz" data-group="oobe" aria-labelledby="h-oobetz">
<div class="una-phead">
<h3 id="h-oobetz">OOBE time zone</h3>
<span class="una-hint">oobeSystem pass; separate from the specialize time zone above</span>
</div>
<div class="card-body">
<div class="una-pbody">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label" for="unaOobeTz">Time zone</label>
@@ -512,37 +477,38 @@
</section>
<!-- ============= 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="input-wrap">
<i class="bi bi-search"></i>
<input type="search" class="form-control form-control-sm" id="unaFilter"
placeholder="Filter commands (text, description or position)" aria-label="Filter commands">
<div class="input-wrap" role="search">
<label for="unaFilter" class="vh">Filter commands</label>
<input type="search" class="field" id="unaFilter"
placeholder="Filter commands (text, description or position)">
</div>
<span class="una-hint" id="unaFilterCount"></span>
</div>
<div class="alert alert-warning">
<i class="bi bi-exclamation-triangle"></i>
Each list runs top to bottom as a tested sequence; <strong>order matters</strong>. Reorder with the arrows
or by dragging the <i class="bi bi-grip-vertical"></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 }}.
</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>
<section class="panel una-section" id="sec-spec" data-section="spec" data-group="commands" aria-labelledby="h-spec">
<div class="una-phead">
<h3 id="h-spec">Specialize commands</h3>
<span class="chip c-neutral" data-count="spec">{{ data.specialize_commands | length }}</span>
<button type="button" class="ui-btn sp una-add" data-list="spec">Add command</button>
</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 }}">
{% for cmd in data.specialize_commands %}{{ cmd_row(cmd, loop.index0, 'path', limits.path) }}{% endfor %}
</div>
@@ -550,17 +516,13 @@
<div class="una-empty" data-nomatch="spec" hidden>No specialize command matches the filter.</div>
</section>
<section class="card una-section" id="sec-fl" data-section="fl" data-group="commands">
<div class="card-header">
<span class="title-group">
<i class="bi bi-play-circle"></i> First logon commands
<span class="count-pill" data-count="fl">{{ data.firstlogon_commands | length }}</span>
</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>
<section class="panel una-section" id="sec-fl" data-section="fl" data-group="commands" aria-labelledby="h-fl">
<div class="una-phead">
<h3 id="h-fl">First logon commands</h3>
<span class="chip c-neutral" data-count="fl">{{ data.firstlogon_commands | length }}</span>
<button type="button" class="ui-btn sp una-add" data-list="fl">Add command</button>
</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 }}">
{% for cmd in data.firstlogon_commands %}{{ cmd_row(cmd, loop.index0, 'commandline', limits.commandline) }}{% endfor %}
</div>
@@ -574,63 +536,65 @@
<!-- ==================== RAW XML VIEW ==================== -->
<div class="tab-pane fade" id="unaRawView" role="tabpanel" aria-labelledby="unaRawTab">
<div class="card">
<div class="card-header">
<span class="title-group"><i class="bi bi-filetype-xml"></i> Raw XML</span>
<section class="panel" aria-labelledby="h-raw">
<div class="una-phead">
<h3 id="h-raw">Raw XML</h3>
<span class="una-hint">Review changes shows the diff of this text against the file</span>
</div>
<div class="card-body">
<p class="una-hint mb-2">
<div class="una-pbody">
<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
Form view are not part of a Raw XML save (and the other way round).
</p>
<textarea class="form-control raw-xml-editor" id="unaRawXml" spellcheck="false"
aria-label="Raw XML">{{ data.raw_xml }}</textarea>
</div>
</div>
</section>
</div>
</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 #}
<template id="unaTplCmd">
<div class="una-row is-new">
<div class="una-side">
<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 class="una-body">
<div class="una-head">
<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>
<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>
<div class="una-actions">
<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 class="una-actions">{{ row_menu('new command') }}</div>
</div>
</template>
<template id="unaTplDp">
<div class="una-simple is-new">
<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">
<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>
</template>
<template id="unaTplAcct">
<div class="una-simple is-new" data-plain="true">
<span class="una-pos"></span>
<div class="una-acct-fields">
<div><label>Name</label><input type="text" class="form-control form-control-sm" data-f="name"></div>
<div><label>Password</label><input type="text" class="form-control form-control-sm mono" data-f="password" autocomplete="off"></div>
<div><label>Group</label><input type="text" class="form-control form-control-sm" data-f="group" value="Administrators"></div>
<div><label>Display name</label><input type="text" class="form-control form-control-sm" data-f="display_name"></div>
<div><label>Name <input type="text" class="form-control form-control-sm" data-f="name"></label></div>
<div><label>Password <input type="text" class="form-control form-control-sm mono" data-f="password" autocomplete="off"></label></div>
<div><label>Group <input type="text" class="form-control form-control-sm" data-f="group" value="Administrators"></label></div>
<div><label>Display name <input type="text" class="form-control form-control-sm" data-f="display_name"></label></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>
</template>
{% endblock %}

View File

@@ -1,337 +1,413 @@
{% 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 release_cells(r) -%}
<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>
<td class="text-nowrap">{{ mb(r.size) }}</td>
{%- endmacro %}
{% 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 %}
{% 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; 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 %}
<div class="page-header">
<h1><i class="bi bi-eraser"></i> Blancco Updates</h1>
<div class="header-actions">
<span class="badge badge-info badge-lg">Live: {{ current.version }}{% if current.inferred %} (installed by hand){% endif %}</span>
{% if test %}<span class="badge badge-warning badge-lg">Test entry: {{ test.version or '?' }}</span>{% endif %}
<header class="ui-head">
<div>
<div class="eyebrow-t">Updates</div>
<h1>Blancco releases</h1>
<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 class="actions"><a class="ui-btn pri" href="#upload">Upload release</a></div>
</header>
<p class="text-muted">
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>
<div class="ui-stack">
{# ---- Job ----------------------------------------------------------------- #}
{% if job.state %}
<div class="section-card" id="blanccoJob" data-state="{{ job.state }}">
<div class="section-title"><i class="bi bi-gear"></i> Staging {{ job.source or '' }}</div>
<div id="blanccoJob" data-state="{{ job.state }}" class="ui-stack" style="gap: 8px">
{% if job_running %}
<div class="d-flex align-items-center gap-2 mb-1">
<span class="spinner-border spinner-border-sm" role="status"></span>
<span id="blanccoJobStep">{{ job.step or 'Starting' }}</span>
<span id="blanccoJobMsg" class="small text-muted">{{ job.message or '' }}</span>
<div class="panel strip" role="status">
<span class="pulse" aria-hidden="true"></span>
<span>Staging {{ job.source or '' }}: <span id="blanccoJobStep">{{ job.step or 'Starting' }}</span></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 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' %}
<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' %}
<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' %}
<div class="alert alert-warning mb-2">Staging was cancelled.</div>
<div class="notice">Staging {{ job.source or '' }} was cancelled.</div>
{% 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 %}
{% if job.log %}
<details {% if job_running or job.state in ('failed', 'blocked') %}open{% endif %}>
<summary class="small text-muted" style="cursor:pointer;">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>
<summary class="disc"><span class="chev" aria-hidden="true">&#9662;</span>Job log</summary>
<pre id="blanccoJobLog" class="mono joblog">{{ job.log | join('\n') }}</pre>
</details>
{% endif %}
</div>
{% endif %}
{# ---- Get a release ------------------------------------------------------- #}
<div class="section-card">
<div class="section-title"><i class="bi bi-upload"></i> Release files</div>
<form id="blanccoUpload" method="post" action="{{ url_for('blancco_update.upload') }}" enctype="multipart/form-data"
class="d-flex flex-wrap gap-2 align-items-end mb-2">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<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>
{# ---- Live release -------------------------------------------------------- #}
<section class="panel cur" aria-labelledby="live-h">
<div class="cur-top">
<div>
<h2 id="live-h" class="sec" style="font-size: 15px; color: var(--muted)">Live release</h2>
<div class="cur-ver mono">{{ current.version }}</div>
</div>
<button type="submit" class="pxe-btn pxe-btn-primary"><i class="bi bi-upload"></i> Upload</button>
</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>
<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>
</div>
<p class="small text-muted">
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>).
</p>
{% if sources %}
<div class="table-container">
<table class="data-table">
<thead><tr><th>File</th><th>Folder</th><th>Size</th><th>Modified</th><th class="text-end"></th></tr></thead>
<tbody>
{% for s in sources %}
<tr>
<td class="mono">{{ s.name }}</td>
<td class="small text-muted">{{ s.key }}</td>
<td class="text-nowrap">{{ mb(s.size) }}</td>
<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>
{% if current.missing %}<div class="notice bad">Missing from the live folder: <span class="mono">{{ current.missing | join(', ') }}</span></div>{% endif %}
{% if current.changed_by_hand %}<div class="notice">Files changed outside this page since the last activate or roll back.</div>{% endif %}
<dl class="facts">
<div><dt>Blancco kernel</dt><dd class="mono">{{ current.kernel or '-' }}</dd></div>
<div><dt>Preferences</dt><dd class="mono">{{ prefs(current) }}</dd></div>
<div><dt>Size</dt><dd>{{ mb(current.size) }}</dd></div>
<div><dt>Rootfs date</dt><dd class="mono">{{ current.rootfs_mtime | timestamp_fmt if current.rootfs_mtime else '-' }}</dd></div>
<div><dt>Installed</dt><dd>{% if current.activated %}{{ current.activated | local_dt }}
{% if current.restored_from %}<div class="sub">rolled back to {{ current.restored_from }}</div>
{% elif current.source %}<div class="sub">from {{ current.source }}</div>{% endif %}
{% else %}<span class="muted">by hand (before this page)</span>{% endif %}
{% if current.inferred %}<div class="sub">version read from the preferences in config.img</div>{% endif %}</dd></div>
</dl>
<div class="sub" style="overflow-wrap: anywhere">Served from <span class="mono">{{ current.path }}</span>.</div>
</section>
{# ---- Staged -------------------------------------------------------------- #}
<div class="section-card">
<div class="section-title"><i class="bi bi-box-seam"></i> Staged releases</div>
{% if staged %}
<div class="table-container">
<table class="data-table">
<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>
<tbody>
{% for s in staged %}
<tr>
<td class="mono">{{ s.name }}{% if s.in_menu %} <span class="badge badge-warning">in boot menu</span>{% endif %}</td>
{{ release_cells(s) }}
<td>
{% if s.status == 'ready' %}<span class="badge badge-success">Ready</span>
{% if s.merged_prefs %}<div class="small text-muted">merged preferences</div>{% endif %}
{% elif s.status == 'blocked' %}<span class="badge badge-danger">Needs preferences merge</span>
{% elif s.status == 'failed' %}<span class="badge badge-danger">Failed</span>
{% if s.error %}<div class="small text-danger wrap">{{ s.error }}</div>{% endif %}
{% else %}<span class="badge badge-secondary">{{ s.status }}</span>{% endif %}
</td>
<td class="small text-nowrap">{{ s.staged | local_dt }}<div class="text-muted">{{ s.source or '' }}</div></td>
<td class="text-end text-nowrap">
{% if s.status == 'ready' %}
{% if not s.in_menu %}
<form method="post" action="{{ url_for('blancco_update.test_add', version=s.name) }}" style="display:inline;"
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 }});">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="pxe-btn pxe-btn-secondary btn-row-action"><i class="bi bi-list-check"></i> Add test entry</button>
</form>
{% endif %}
<form method="post" action="{{ url_for('blancco_update.activate', version=s.name) }}" style="display:inline;"
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>
<section class="panel ui-list" aria-labelledby="stg-h">
<div class="sec-h"><div><h2 id="stg-h" class="sec">Staged releases</h2>
<div class="sub">Next step for each: add a test entry, test-boot it from the PXE menu, then activate.</div></div></div>
{% for s in staged %}
{% 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.') %}
{% 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.') %}
<div class="ui-row">
<details {% if s.status in ('blocked', 'failed') %}open{% endif %}>
<summary class="sgrid">
<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>
<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>
<span>
{% if s.status == 'ready' and s.in_menu %}{{ ui.chip('Ready: testing', 'ok') }}
{% elif s.status == 'ready' %}{{ ui.chip('Ready to test', 'ok') }}
{% elif s.status == 'blocked' %}{{ ui.chip('Needs preferences merge', 'act') }}
{% elif s.status == 'failed' %}{{ ui.chip('Failed', 'err') }}
{% else %}{{ ui.chip(s.status, 'neutral') }}{% endif %}
</span>
<span class="chev" aria-hidden="true">&#9662;</span>
</summary>
<div class="rbody">
{% if s.status == 'failed' and s.error %}<div class="errbox">{{ s.error }}</div>{% endif %}
<dl class="kv">
<dt>Blancco kernel</dt><dd class="mono">{{ s.kernel or '-' }}</dd>
<dt>Preferences</dt><dd class="mono">{{ prefs(s) }}{% if s.merged_prefs %} <span class="sub">(merged preferences)</span>{% endif %}</dd>
<dt>Size</dt><dd>{{ mb(s.size) }}</dd>
<dt>Staged</dt><dd>{{ s.staged | local_dt }}{% if s.source %} from <span class="mono">{{ s.source }}</span>{% endif %}</dd>
</dl>
{% if s.status == 'ready' %}
<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>
{% endif %}
{% endfor %}
</tbody>
</table>
{% if s.status == 'blocked' %}
<div class="notice">{{ s.blocked }}</div>
<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>
{% else %}
<p class="text-muted mb-0">Nothing staged.</p>
{% endif %}
</div>
<div class="hidden-bar">Nothing staged. Upload a release file below, then Stage it.</div>
{% endfor %}
</section>
{# ---- Test entry ---------------------------------------------------------- #}
<div class="section-card">
<div class="section-title"><i class="bi bi-list-check"></i> Test entry in the PXE boot menu</div>
{% if test %}
<p class="mb-2">
The menu has <span class="mono">{{ test.item or 'a blancco_staged entry' }}</span>, booting the staged
<strong class="mono">{{ test.version or '?' }}</strong> from <span class="mono">/blancco/staged/{{ test.version }}/</span>.
</p>
{% if test.efi is false %}
<div class="alert alert-warning">The entry's UEFI branch is off: when it was added, the UEFI initrd did not read
<section class="panel" aria-labelledby="test-h" style="padding: 18px 24px; display: flex; flex-direction: column; gap: 10px">
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 10px 16px">
<div style="flex: 1 1 300px; min-width: 0">
<h2 id="test-h" class="sec" style="font-size: 15px">Test entry in the PXE boot menu</h2>
{% if test %}
<div class="sub" style="overflow-wrap: anywhere">The menu has <span class="mono">{{ test.item or 'a blancco_staged entry' }}</span>, booting the staged
<strong class="mono">{{ test.version or '?' }}</strong> from <span class="mono">/blancco/staged/{{ test.version }}/</span>.</div>
{% else %}
<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
says so and returns to the menu. After rebuilding the initrd, add the test entry again.</div>
{% endif %}
<form method="post" action="{{ url_for('blancco_update.test_remove') }}"
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="pxe-btn pxe-btn-secondary"><i class="bi bi-x-circle"></i> Remove test entry</button>
</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>
{% if initrd.state == 'no' %}
<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
entry added now has its UEFI branch off (legacy BIOS still works). Rebuild the initrd with the current
<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>
{% endif %}
<ul class="small mt-3 mb-0">
<li><strong>Legacy BIOS:</strong> the entry boots Blancco's own kernel from the staged folder
(<span class="mono">archiso_http_srv</span> pointed at it). Works with any initrd.</li>
<li><strong>UEFI:</strong> the entry copies the live <span class="mono">:blancco_efi</span> kernel line (Ubuntu kernel,
Secure Boot as the normal entry) and adds <span class="mono">blancco.base=/blancco/staged/&lt;version&gt;</span>, which
the init in <span class="mono">{{ initrd.file or 'kexec-initrd' }}</span> must understand:
{% if initrd.state == 'yes' %}<span class="badge badge-success">supported</span>
{% elif initrd.state == 'no' %}<span class="badge badge-danger">not supported</span>
<div class="text-danger">This initrd predates <span class="mono">blancco.base=</span>: booted, it would run the LIVE release, so a test
entry added now has its UEFI branch off (legacy BIOS still works). Rebuild the initrd with the current
<span class="mono">playbook/blancco-init.sh</span>, then add the entry again (the boot screen shows "release under test"
when it works).</div>
{% else %}<span class="badge badge-secondary">unknown</span> <span class="text-muted">{{ initrd.detail }}</span>{% endif %}</li>
<li>Add, replace and remove edit only the test entry's lines; each edit saves
<span class="mono">GetPxeScript.aspx.bak-&lt;time&gt;-pre-blancco-staged*</span> next to the menu first. Activating the
version under test removes its entry.</li>
</ul>
</div>
<details>
<summary class="disc"><span class="chev" aria-hidden="true">&#9662;</span>How the test entry boots
{% 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>
<ul class="sub" style="margin: 4px 0 0; padding-left: 18px; line-height: 1.6; display: flex; flex-direction: column; gap: 4px">
<li><strong>Legacy BIOS:</strong> the entry boots Blancco's own kernel from the staged folder
(<span class="mono">archiso_http_srv</span> pointed at it). Works with any initrd.</li>
<li><strong>UEFI:</strong> the entry copies the live <span class="mono">:blancco_efi</span> kernel line (Ubuntu kernel,
Secure Boot as the normal entry) and adds <span class="mono">blancco.base=/blancco/staged/&lt;version&gt;</span>, which
the init in <span class="mono">{{ initrd.file or 'kexec-initrd' }}</span> must understand:
{% if initrd.state == 'yes' %}supported.{% elif initrd.state == 'no' %}not supported (see above).{% else %}unknown, {{ initrd.detail }}.{% endif %}</li>
<li>Add, replace and remove edit only the test entry's lines; each edit saves
<span class="mono">GetPxeScript.aspx.bak-&lt;time&gt;-pre-blancco-staged*</span> next to the menu first. Activating the
version under test removes its entry.</li>
</ul>
</details>
</section>
{# ---- Backups ------------------------------------------------------------- #}
<div class="section-card">
<div class="section-title"><i class="bi bi-clock-history"></i> Backups</div>
<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;
the restored backup's folder is then released, because its files are the live files.</p>
{% if backups %}
<div class="table-container">
<table class="data-table">
<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>
<tbody>
{% for b in backups %}
<tr>
<td class="mono">{{ b.name }}{% if b.reason %}<div class="small text-muted">{{ b.reason }}</div>{% endif %}</td>
<td class="mono">{{ b.version }}</td>
{{ release_cells(b) }}
<td class="mono text-nowrap">{{ b.created | local_dt if b.created is string else b.created | timestamp_fmt }}</td>
<td class="text-end text-nowrap">
{% if b.complete %}
<form method="post" action="{{ url_for('blancco_update.rollback', name=b.name) }}" style="display:inline;"
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="pxe-btn pxe-btn-secondary btn-row-action"><i class="bi bi-arrow-counterclockwise"></i> Roll back</button>
</form>
{% else %}
<span class="badge badge-secondary" title="Missing {{ b.missing | join(', ') }}">Incomplete</span>
{% endif %}
<form method="post" action="{{ url_for('blancco_update.delete_backup', name=b.name) }}" style="display:inline;"
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="pxe-btn pxe-btn-ghost btn-row-action" title="Delete"><i class="bi bi-trash"></i></button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{# ---- Release files and upload -------------------------------------------- #}
<section class="panel ui-list" id="upload" aria-labelledby="up-h">
<div class="sec-h"><div><h2 id="up-h" class="sec">Release files</h2>
<div class="sub">Upload the vendor ISO or USB image (about 1 GB), then Stage it.</div></div></div>
<form id="blanccoUpload" method="post" action="{{ url_for('blancco_update.upload') }}" enctype="multipart/form-data" class="upl">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="fld">
<label for="blanccoFile">Vendor ISO or USB image</label>
<input type="file" class="field" id="blanccoFile" name="release_file" accept=".iso,.img,.gz" required aria-describedby="blanccoFileHelp">
</div>
<button type="submit" class="ui-btn pri">Upload</button>
<span id="blanccoFileHelp" class="sub" style="flex-basis: 100%; overflow-wrap: anywhere">Streamed to <span class="mono">{{ upload_dir }}</span>.</span>
</form>
<div id="blanccoUploadProgress" class="prog" style="display: none;" role="status">
<span id="blanccoUploadText">Uploading...</span>
<div class="bar"><div id="blanccoUploadBar" style="width: 0%;"></div></div>
</div>
{% for s in sources %}
<div class="ui-row">
<div class="fgrid">
<span class="tip" data-tip="{{ s.name }}"><span class="trunc mono">{{ s.name }}</span></span>
<span class="sub c-folder">{{ s.key }}</span>
<span class="sub">{{ mb(s.size) }}</span>
<span class="sub mono c-mod">{{ s.modified | timestamp_fmt }}</span>
</div>
<div class="act" style="top: 6px">
<form method="post" action="{{ url_for('blancco_update.stage') }}" style="margin: 0">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="source" value="{{ s.key }}:{{ s.name }}">
<button type="submit" class="ui-btn" {% if job_running %}disabled{% endif %}>Stage</button>
</form>
{% if s.deletable %}
{% call ui.more(s.name) %}
<form method="post" action="{{ url_for('blancco_update.delete_upload', name=s.name) }}"
onsubmit="return confirm({{ ('Delete ' ~ s.name ~ '?') | tojson | forceescape }});">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="del">Delete file</button>
</form>
{% endcall %}
{% endif %}
</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 %}
<p class="text-muted mb-0">No backups yet. Activating a release saves the one it replaces here.</p>
{% endif %}
</div>
<div class="hidden-bar">No release files yet. Upload the vendor ISO or USB image above.</div>
{% endfor %}
<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 -------------------------------------------------------- #}
<div class="pxe-card">
<h3 class="pxe-card-title"><i class="bi bi-info-circle"></i> What a release changes</h3>
<p class="small mb-2">
Replaced in <span class="mono">{{ current.path }}</span>: <span class="mono">arch/x86_64/airootfs.sfs</span>,
<span class="mono">vmlinuz-bde-linux</span>, <span class="mono">initramfs-bde-linux.img</span> (copytoram network patch),
<span class="mono">intel-ucode.img</span>, <span class="mono">amd-ucode.img</span>, <span class="mono">config.img</span>
(vendor cpio with our preferences written in at the same size), <span class="mono">preferences.xml</span>,
<span class="mono">config-clean.xml</span> (the licence container from <span class="mono">config.img</span>) and, when the
ISO has one, <span class="mono">blancco-shim.efi</span>. Never touched: <span class="mono">vmlinuz-ubuntu*</span>,
<span class="mono">kexec-initrd*</span>, <span class="mono">kmod*.tar.gz</span>, <span class="mono">grubx64.efi</span>,
<span class="mono">shimx64.efi</span>, <span class="mono">blancco-progress.py</span>.
</p>
<p class="small mb-0">
Our preferences come from the live <span class="mono">preferences.xml</span> (the playbook deploys
<span class="mono">playbook/blancco-preferences.xml</span> there). They are a merge onto one vendor release's file,
so when a release's <span class="mono">&lt;version&gt;</span> differs, staging stops until a merged file is uploaded.
After activating such a release, commit the merged file to the repo, or the next playbook run puts the old one back.
</p>
<p class="small mb-0 mt-2">
A playbook run also copies <span class="mono">boot-tools/blancco/*</span> from the USB over the live files. After activating
a release here, refresh that copy (<span class="mono">scripts/prepare-boot-tools.sh</span> with the new ISO), or a playbook run
puts the old release back.
</p>
<details>
<summary class="disc"><span class="chev" aria-hidden="true">&#9662;</span>What a release changes</summary>
<div class="sub" style="max-width: 900px; line-height: 1.6; display: flex; flex-direction: column; gap: 8px; margin-top: 4px; overflow-wrap: anywhere">
<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>
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 Activate
changes what the normal Blancco entry boots, and the files it replaces are kept as a backup you can roll back to.</p>
<p style="margin: 0">Replaced in <span class="mono">{{ current.path }}</span>: <span class="mono">arch/x86_64/airootfs.sfs</span>,
<span class="mono">vmlinuz-bde-linux</span>, <span class="mono">initramfs-bde-linux.img</span> (copytoram network patch),
<span class="mono">intel-ucode.img</span>, <span class="mono">amd-ucode.img</span>, <span class="mono">config.img</span>
(vendor cpio with our preferences written in at the same size), <span class="mono">preferences.xml</span>,
<span class="mono">config-clean.xml</span> (the licence container from <span class="mono">config.img</span>) and, when the
ISO has one, <span class="mono">blancco-shim.efi</span>. Never touched: <span class="mono">vmlinuz-ubuntu*</span>,
<span class="mono">kexec-initrd*</span>, <span class="mono">kmod*.tar.gz</span>, <span class="mono">grubx64.efi</span>,
<span class="mono">shimx64.efi</span>, <span class="mono">blancco-progress.py</span>.</p>
<p style="margin: 0">Our preferences come from the live <span class="mono">preferences.xml</span> (the playbook deploys
<span class="mono">playbook/blancco-preferences.xml</span> there). They are a merge onto one vendor release's file,
so when a release's <span class="mono">&lt;version&gt;</span> differs, staging stops until a merged file is uploaded.
After activating such a release, commit the merged file to the repo, or the next playbook run puts the old one back.</p>
<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
a release here, refresh that copy (<span class="mono">scripts/prepare-boot-tools.sh</span> with the new ISO), or a playbook run
puts the old release back.</p>
</div>
</details>
</div>
{% endblock %}

View File

@@ -1,5 +1,6 @@
{% 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) -%}
{%- 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 -%}
{%- endmacro %}
{% macro val_badge(v, lg=false) -%}
{% set cls = 'badge badge-lg' if lg else 'badge' %}
{% if not v %}<span class="{{ cls }} badge-secondary">Not validated</span>
{% elif v.stale %}<span class="{{ cls }} badge-warning" title="{{ v.stale }}">Changed since validation</span>
{% elif v.state == 'valid' %}<span class="{{ cls }} badge-success">Valid</span>
{% else %}<span class="{{ cls }} badge-danger">{{ v.errors | length }} error{{ 's' if v.errors | length != 1 }}</span>{% endif %}
{% macro val_chip(v) -%}
{% if not v %}{{ ui.chip('Not validated', 'off') }}
{% elif v.stale %}{{ ui.chip('Changed since validation', 'warn') }}
{% elif v.state == 'valid' %}{{ ui.chip('Valid', 'ok') }}
{% else %}{{ ui.chip(v.errors | length ~ ' error' ~ ('s' if v.errors | length != 1), 'err') }}{% endif %}
{%- endmacro %}
{% macro job_panel(job) -%}
{% if job.running %}
<div class="section-card" id="bprtJob" data-running="1">
<div class="section-title"><span class="spinner-border spinner-border-sm"></span>
{{ {'manifest': 'Hashing the live BPRT', 'validate': 'Validating', 'activate': 'Activating', 'unzip': 'Unpacking the zip'}.get(job.kind, job.kind) }}
{% if job.arg %}<span class="mono small">{{ job.arg }}</span>{% endif %}</div>
<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>
<div class="progress" style="height: 0.5rem;"><div id="bprtJobBar" class="progress-bar" style="width: {{ job.percent or 0 }}%;"></div></div>
<div class="small text-muted mt-1" id="bprtJobPct">{% if job.percent is not none %}{{ job.percent }}%{% endif %}</div>
<div class="panel strip" id="bprtJob" data-running="1" role="status">
<span class="pulse" aria-hidden="true"></span>
<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">{{ job.arg }}</span>{% endif %}</span>
<span id="bprtJobStep">{{ job.step or 'Starting' }}</span>
<span id="bprtJobCurrent" class="mono sub" style="min-width: 0; overflow-wrap: anywhere">{{ job.current or '' }}</span>
<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>
{% 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>
{% 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>
{% 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 %}
{%- endmacro %}
{% macro imaging_alert(imaging) -%}
{% if imaging %}
<div class="alert alert-warning">
<strong><i class="bi bi-exclamation-triangle"></i> {{ imaging | length }} PC{{ 's are' if imaging | length != 1 else ' is' }} imaging now.</strong>
<div class="notice"><span>
<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.
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 %}.
<a href="{{ url_for('imaging_dashboard') }}">Imaging progress</a>
<a href="{{ url_for('imaging_dashboard') }}">Imaging progress</a></span>
</div>
{% endif %}
{%- endmacro %}
{% block extra_head %}
<style>
.bprt td.wrap { white-space: normal; word-break: break-word; min-width: 8rem; }
.bprt .file-list { columns: 2 22rem; column-gap: 2rem; margin: 0.25rem 0 0; padding-left: 1.1rem; }
.bprt .file-list li { break-inside: avoid; }
.bprt details > summary { cursor: pointer; }
.bprt .fname { word-break: normal; overflow-wrap: normal; }
.bprt .msg-list { margin: 0; padding-left: 1.1rem; }
.bprt .msg-list li { margin-bottom: 0.25rem; }
.bprt pre.cmd { white-space: pre-wrap; word-break: break-all; margin: 0.25rem 0 0.75rem; }
.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; }
.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 > 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>
{% endblock %}
{% block content %}
<div class="bprt">
{% if detail %}
{# ======================= one incoming batch ============================== #}
{% set b = detail %}
<div class="page-header">
<h1><i class="bi bi-inboxes"></i> Incoming batch <span class="mono">{{ b.name }}</span></h1>
<div class="header-actions">
{{ val_badge(res, lg=true) }}
<a href="{{ url_for('bprt_sync.page') }}" class="pxe-btn pxe-btn-ghost"><i class="bi bi-arrow-left"></i> BPRT Sync</a>
<header class="ui-head">
<div>
<div class="eyebrow-t"><a href="{{ url_for('bprt_sync.page') }}">BPRT sync</a></div>
<h1>Incoming batch <span class="mono" style="font-size: 22px; overflow-wrap: anywhere">{{ b.name }}</span></h1>
<div class="state-line">{{ val_chip(res) }}
{% if res %}<span class="sub">Validated {{ res.validated | local_dt }}</span>{% endif %}</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">
{% 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 %}.
{% if b.sync.source %}Source <span class="mono">{{ b.sync.source }}</span>.{% endif %}
{{ b.sync.listed }} vendor files listed, {{ b.files }} uploaded ({{ size(b.bytes) }}).{% endif %}
Folder <span class="mono">{{ incoming }}/{{ b.name }}</span>.
</p>
{% if b.sync_error %}<div class="alert alert-danger">{{ b.sync_error }}</div>{% endif %}
<div class="ui-stack">
<section class="panel" aria-labelledby="src-h" style="padding: 16px 24px; display: flex; flex-direction: column; gap: 10px">
<h2 id="src-h" class="vh">Where this batch came from</h2>
<dl class="facts">
{% if b.sync %}
<div><dt>From</dt><dd>{{ b.sync.computer or 'unknown PC' }}{% if b.sync.user %} ({{ b.sync.user }}){% endif %}</dd></div>
{% 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) }}
{% if not res %}
<div class="section-card">
<div class="section-title"><i class="bi bi-clipboard-check"></i> Validate</div>
<p>Validation hashes every uploaded file against <span class="mono">sync.json</span>, checks that every other listed file is
<section class="panel" aria-labelledby="val-h" style="padding: 18px 24px; display: flex; flex-direction: column; gap: 12px">
<h2 id="val-h" class="sec">Validate</h2>
<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.
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() }}">
<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>
</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 %}
{% if res.errors %}
<div class="section-card">
<div class="section-title text-danger"><i class="bi bi-x-octagon"></i> Errors <span class="badge badge-danger">{{ res.errors | length }}</span></div>
<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>
<ul class="msg-list">{% for e in res.errors %}<li>{{ e }}</li>{% endfor %}</ul>
</div>
<section class="panel" aria-labelledby="err-h" style="padding: 18px 24px; display: flex; flex-direction: column; gap: 10px">
<h2 id="err-h" class="sec" style="color: var(--fail-text)">{{ res.errors | length }} error{{ 's' if res.errors | length != 1 }}</h2>
<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>
<div class="errbox"><ul class="msg-list">{% for e in res.errors %}<li>{{ e }}</li>{% endfor %}</ul></div>
</section>
{% 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 %}
<div class="section-card">
<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">{% for w in res.warnings %}<li>{{ w }}</li>{% endfor %}</ul>
</div>
<div class="notice"><div><strong>{{ res.warnings | length }} warning{{ 's' if res.warnings | length != 1 }}</strong>
<ul class="msg-list" style="margin-top: 4px">{% for w in res.warnings %}<li>{{ w }}</li>{% endfor %}</ul></div></div>
{% endif %}
{% if res.counts %}
<div class="dashboard-grid mb-4">
<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="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="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="stat-card success"><div class="label"><i class="bi bi-check2"></i> Unchanged</div><div class="value">{{ res.counts.unchanged }}</div></div>
<div class="stats">
<div class="panel stat"><span class="sub">New files</span><span class="n">{{ res.counts.new }}</span></div>
<div class="panel stat"><span class="sub">Changed</span><span class="n">{{ res.counts.changed }}</span></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="panel stat"><span class="sub">Unchanged</span><span class="n">{{ res.counts.unchanged }}</span></div>
</div>
<div class="section-card">
<div class="section-title"><i class="bi bi-box-seam"></i> MCL packages</div>
{% if res.ppkg %}
<div class="table-container">
<table class="data-table">
<thead><tr><th>Tenant / purpose</th><th>Live now</th><th></th><th>After activation</th></tr></thead>
<tbody>
{% for p in res.ppkg %}
<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>
<section class="panel ui-list" aria-labelledby="pk-h">
<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>
{% for p in res.ppkg %}
<div class="ppk">
<span class="mono">{{ p.prefix }}</span>
<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>
<span class="sub to">to</span>
<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>
</div>
{% else %}<p class="text-muted mb-0">No package changes.</p>{% endif %}
</div>
{% else %}
<div class="hidden-bar">No package changes.</div>
{% endfor %}
</section>
{% if res.zips or res.bom_fix %}
<div class="section-card">
<div class="section-title"><i class="bi bi-file-earmark-zip"></i> Large files and fixes</div>
{% if res.zips %}
<div class="table-container mb-2">
<table class="data-table">
<thead><tr><th>Zip</th><th>Live size</th><th>New size</th></tr></thead>
<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>
</table>
<section class="panel ui-list" aria-labelledby="zp-h">
<div class="sec-h"><div><h2 id="zp-h" class="sec">Large files and fixes</h2></div></div>
{% for z in res.zips %}
<div class="pgrid" style="grid-template-columns: minmax(0, 1fr) 90px 90px">
<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>
</div>
{% endfor %}
{% 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>
{% endif %}
{% if res.bom_fix %}
<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>
</section>
{% endif %}
<div class="section-card">
<div class="section-title"><i class="bi bi-list-ul"></i> Files</div>
<section class="panel ui-list" aria-labelledby="fl-h">
<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')] %}
{% set rows = res.diff[key] %}
{% if rows %}
<details class="mb-2" {% if key != 'unchanged' and rows | length <= 40 %}open{% endif %}>
<summary><strong>{{ label }}</strong> <span class="badge badge-secondary">{{ rows | length }}</span></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>
<details style="border-top: 1px solid var(--line)" {% if key != 'unchanged' and rows | length <= 40 %}open{% endif %}>
<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 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>
{% endif %}
{% endfor %}
<p class="small text-muted mb-0">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>
</div>
<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 %}.</span></div>
</section>
{% endif %}
<div class="section-card">
<div class="section-title"><i class="bi bi-rocket-takeoff"></i> Activate</div>
<section class="panel" aria-labelledby="act-h" style="padding: 18px 24px; display: flex; flex-direction: column; gap: 12px">
<h2 id="act-h" class="sec">Activate</h2>
{{ imaging_alert(imaging) }}
<p class="small text-muted">
Builds <span class="mono">BPRT.new</span> beside the live folder (hard links: unchanged files take no space), puts the site
files back on top, checks it once more, then swaps it in with two renames: the live folder becomes
<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
<span class="mono">BPRT</span>. Every image links to that path, so they all switch at once.
</p>
<div class="d-flex flex-wrap gap-2">
<details>
<summary class="disc"><span class="chev" aria-hidden="true">&#9662;</span>What activating does</summary>
<p class="sub" style="margin: 4px 0 0; max-width: 820px; line-height: 1.6">
Builds <span class="mono">BPRT.new</span> beside the live folder (hard links: unchanged files take no space), puts the site
files back on top, checks it once more, then swaps it in with two renames: the live folder becomes
<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
<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) }}"
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="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 method="post" action="{{ url_for('bprt_sync.validate', name=b.name) }}">
<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>
<span class="small text-muted align-self-center">Validated {{ res.validated | local_dt }}.</span>
</div>
</div>
</section>
{% endif %}
<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>
</div>
{% else %}
{# ======================= overview ======================================= #}
<div class="page-header">
<h1><i class="bi bi-arrow-left-right"></i> BPRT Sync</h1>
<div class="header-actions">
{% 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>
{% elif live.exists %}<span class="badge badge-success badge-lg">{{ packages | selectattr('prefix') | list | length }} MCL packages live</span>{% endif %}
<form method="post" action="{{ url_for('bprt_sync.rebuild') }}" style="display:inline;">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="pxe-btn pxe-btn-ghost" {% if job.running %}disabled{% endif %}
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>
</form>
{% set n_pk = packages | selectattr('prefix') | list | length %}
<header class="ui-head">
<div>
<div class="eyebrow-t">Updates</div>
<h1>BPRT sync</h1>
<div class="state-line">
{% if not live.exists %}{{ ui.chip('No live BPRT folder', 'fail') }}
{% elif live.missing_required %}{{ ui.chip(live.missing_required | length ~ ' required file' ~ ('s' if live.missing_required | length != 1) ~ ' missing', 'fail') }}
{% else %}{{ ui.chip(n_pk ~ ' MCL packages live', 'ok') }}{% endif %}
{% 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>
<p class="text-muted">
<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; <strong>Sync-BPRT.ps1</strong> 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>
<div class="actions">
<form method="post" action="{{ url_for('bprt_sync.rebuild') }}" style="margin: 0">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="ui-btn" {% if job.running %}disabled{% endif %}
title="Hash any live file the cache does not know; Sync-BPRT.ps1 starts this itself when it needs it">Rehash live folder</button>
</form>
{% if script_available %}<a class="ui-btn pri" href="{{ url_for('bprt_sync.script') }}">Download Sync-BPRT.ps1</a>{% endif %}
</div>
</header>
<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) }}
{{ 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 --------------------------------------------------------- #}
<div class="section-card">
<div class="section-title"><i class="bi bi-folder-check"></i> Live BPRT</div>
<section class="panel ui-list" aria-labelledby="live-h">
<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 %}
<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 %}
<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>
{% 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 %}
{% for l in links if l.state in ('copy', 'elsewhere', 'none') %}
<div class="alert alert-warning py-2 mb-2">
<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 %}
<div class="pad">
<dl class="facts">
<div><dt>Files</dt><dd>{{ live.count }}</dd></div>
<div><dt>Size</dt><dd>{{ size(live.bytes) }}</dd></div>
{% if live.newest %}<div><dt>Newest file</dt><dd class="mono">{{ live.newest | timestamp_fmt }}</dd></div>{% endif %}
</dl>
<div class="sub" style="overflow-wrap: anywhere">Folder <span class="mono">{{ live.path }}</span></div>
{% 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>
{% endfor %}
{% if live.missing_required %}
<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>
<div class="hidden-bar"><span>
<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 %}
{% if live.bom_needed %}
<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>
</section>
{# ---- Incoming batches ---------------------------------------------------- #}
<div class="section-card">
<div class="section-title"><i class="bi bi-inboxes"></i> Incoming batches <span class="badge badge-secondary">{{ batches | length }}</span></div>
{% if batches %}
<div class="table-container">
<table class="data-table">
<thead><tr><th>Batch</th><th>From</th><th>Uploaded</th><th>Status</th><th class="text-end"></th></tr></thead>
<tbody>
{% for b in batches %}
<tr>
<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>
<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>
<td class="small text-nowrap">{{ b.files }} file{{ 's' if b.files != 1 }}, {{ size(b.bytes) }}</td>
<td class="small">
{% if b.uploading %}<span class="badge badge-info" title="Sync-BPRT.ps1 renames the folder when the upload is complete">Uploading</span>
{% elif b.sync_error %}<span class="badge badge-danger" title="{{ b.sync_error }}">Bad sync.json</span>
{% elif not b.sync %}<span class="badge badge-danger" title="sync.json is uploaded last; this batch is incomplete">No sync.json</span>
{% else %}{{ val_badge(b.validation) }}{% endif %}
</td>
<td class="text-end text-nowrap">
{% if not b.uploading and b.sync %}
<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>
{% if not b.validation %}
<form method="post" action="{{ url_for('bprt_sync.validate', name=b.name) }}" style="display:inline;">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<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>
</form>
{% endif %}
{% endif %}
<form method="post" action="{{ url_for('bprt_sync.delete', name=b.name) }}" style="display:inline;"
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="btn btn-sm btn-outline-danger btn-row-action" title="Delete batch"><i class="bi bi-trash"></i></button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<section class="panel ui-list" aria-labelledby="in-h">
<div class="sec-h"><div><h2 id="in-h" class="sec">Incoming batches</h2>
<div class="sub">Validate a batch, read its changes, then activate it from its page.</div></div>
<span class="sub">{{ batches | length }}</span></div>
{% for b in batches %}
<div class="ui-row">
<details {% if b.sync_error or (b.validation and b.validation.errors) %}open{% endif %}>
<summary class="bgrid">
<span class="tip" data-tip="{{ b.name }}"><span class="trunc mono">{{ b.name }}</span></span>
{% set frm = ((b.sync.computer or '-') ~ (', ' ~ (b.sync.created | local_dt) if b.sync.created else '')) if b.sync else '-' %}
<span class="tip c-from" data-tip="{{ frm }}"><span class="trunc sub">{{ frm }}</span></span>
<span class="sub c-size">{{ b.files }} file{{ 's' if b.files != 1 }}, {{ size(b.bytes) }}</span>
<span class="c-st">
{% if b.uploading %}{{ ui.chip('Uploading', 'run') }}
{% elif b.sync_error %}{{ ui.chip('Bad sync.json', 'err') }}
{% elif not b.sync %}{{ ui.chip('No sync.json', 'err') }}
{% else %}{{ val_chip(b.validation) }}{% endif %}
</span>
<span class="chev" aria-hidden="true">&#9662;</span>
</summary>
<div class="rbody">
{% if b.uploading %}<div class="sub">Sync-BPRT.ps1 renames the folder when the upload is complete.</div>{% endif %}
{% if b.sync_error %}<div class="errbox">{{ b.sync_error }}</div>
{% elif not b.uploading and not b.sync %}<div class="errbox">sync.json is uploaded last; this batch is incomplete.</div>{% endif %}
{% set v = b.validation %}
{% if v %}
{% if v.errors %}<div class="errbox"><ul class="msg-list">{% for e in v.errors %}<li>{{ e }}</li>{% endfor %}</ul></div>{% endif %}
{% if v.stale %}<div class="notice">{{ v.stale }}</div>{% endif %}
{% if v.warnings %}<div class="notice"><ul class="msg-list">{% for w in v.warnings %}<li>{{ w }}</li>{% endfor %}</ul></div>{% endif %}
{% if v.counts %}<div>{{ v.counts.new }} new, {{ v.counts.changed }} changed, {{ v.counts.removed }} removed, {{ v.counts.unchanged }} unchanged.</div>{% endif %}
{% 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 %}
<div class="sub">Validated {{ v.validated | local_dt }}.</div>
{% 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 %}
{% 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 %}
</div>
</details>
<div class="act">
{% 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>
{% else %}
<p class="text-muted mb-0">Nothing waiting. Run Sync-BPRT.ps1 on the work PC (below), or upload a zip.</p>
{% endif %}
</div>
<div class="hidden-bar">Nothing waiting. Run Sync-BPRT.ps1 on the work PC (below), or upload a zip.</div>
{% endfor %}
</section>
{# ---- How to sync --------------------------------------------------------- #}
<div class="section-card">
<div class="section-title"><i class="bi bi-terminal"></i> Sync from the work PC</div>
<ol class="mb-2">
<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 small">powershell -ExecutionPolicy Bypass -File .\Sync-BPRT.ps1 -DryRun
<section class="panel" aria-labelledby="how-h">
<details>
<summary class="sec-h">
<div><h2 id="how-h" class="sec" style="font-size: 15px">Sync from the work PC</h2>
<div class="sub">Run Sync-BPRT.ps1 on the GE work PC; it uploads only what changed.</div></div>
<span class="chev" aria-hidden="true">&#9662;</span>
</summary>
<div style="padding: 0 24px 20px; display: flex; flex-direction: column; gap: 12px">
<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>
It reads this server's manifest, hashes the local folder (<span class="mono">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
<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>
</ol>
<p class="small text-muted mb-0">
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).
Uploads land in <span class="mono">{{ incoming }}</span>.
</p>
</div>
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
<span class="mono">sftp</span> session: type the <span class="mono">pxe</span> password once.</li>
<li>Come back here, open the new batch, Validate, read the changes, Activate.</li>
</ol>
<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
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>.</p>
</div>
</details>
</section>
{# ---- Zip upload ---------------------------------------------------------- #}
<div class="section-card">
<div class="section-title"><i class="bi bi-file-earmark-zip"></i> Upload a zip instead</div>
<form id="bprtUpload" method="post" action="{{ url_for('bprt_sync.upload') }}" enctype="multipart/form-data"
class="d-flex flex-wrap gap-2 align-items-end">
<section class="panel ui-list" aria-labelledby="zip-h">
<div class="sec-h"><div><h2 id="zip-h" class="sec">Upload a zip instead</h2>
<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>
<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() }}">
<div class="form-field mb-0" style="min-width: min(22rem, 100%);">
<label for="bprtZip" class="form-label">Zip (.zip)</label>
<input type="file" class="form-control" id="bprtZip" name="zip_file" accept=".zip" required>
<div class="fld">
<label for="bprtZip">Zip (.zip)</label>
<input type="file" class="field" id="bprtZip" name="zip_file" accept=".zip" required>
</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>
<div id="bprtUploadProgress" class="mt-2" style="display: none;">
<div class="progress" style="height: 0.5rem;"><div id="bprtUploadBar" class="progress-bar" style="width: 0%;"></div></div>
<div id="bprtUploadText" class="small text-muted mt-1"></div>
<div id="bprtUploadProgress" class="prog" style="display: none;" role="status">
<div class="bar"><div id="bprtUploadBar" style="width: 0%;"></div></div>
<span id="bprtUploadText"></span>
</div>
{% if zips %}
<div class="table-container mt-3">
<table class="table table-sm mb-0">
<thead><tr><th>Uploaded zip, not unpacked</th><th>Size</th><th class="text-end"></th></tr></thead>
<tbody>
{% for z in zips %}
<tr>
<td class="mono small wrap">{{ z.name }}</td><td class="text-nowrap">{{ size(z.size) }}</td>
<td class="text-end text-nowrap">
<form method="post" action="{{ url_for('bprt_sync.unzip', name=z.name) }}" style="display:inline;">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="pxe-btn pxe-btn-ghost pxe-btn-sm" {% if job.running %}disabled{% endif %}>Unpack</button>
</form>
<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() }}">
<button type="submit" class="btn btn-sm btn-outline-danger btn-row-action" title="Delete"><i class="bi bi-trash"></i></button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% for z in zips %}
<div class="ui-row">
<div class="zgrid">
<span class="tip" data-tip="{{ z.name }}"><span class="trunc mono">{{ z.name }}</span></span>
<span class="sub">{{ size(z.size) }}</span>
</div>
<div class="act" style="top: 6px">
<form method="post" action="{{ url_for('bprt_sync.unzip', name=z.name) }}" style="margin: 0">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="ui-btn" {% if job.running %}disabled{% endif %}>Unpack</button>
</form>
{% call ui.more(z.name) %}
<form method="post" action="{{ url_for('bprt_sync.delete_zip', name=z.name) }}" onsubmit="return confirm('Delete {{ z.name }}?');">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="del">Delete zip</button>
</form>
{% endcall %}
</div>
</div>
{% endif %}
</div>
{% endfor %}
{% if zips %}<div class="hidden-bar">Uploaded zips, not unpacked yet.</div>{% endif %}
</section>
{# ---- Archives ------------------------------------------------------------ #}
<div class="section-card">
<div class="section-title"><i class="bi bi-archive"></i> Previous BPRT folders <span class="badge badge-secondary">{{ archives | length }}</span></div>
{% if archives %}
<p class="small text-muted">Each activation and rollback keeps the folder it replaced in <span class="mono">BPRT-archive</span>. Unchanged files are
hard links shared with the live folder, so an archive only costs the files that differ ("own size").</p>
<div class="table-container">
<table class="data-table">
<thead><tr><th>Archive</th><th>MCL packages</th><th>Files</th><th>Own size</th><th class="text-end"></th></tr></thead>
<tbody>
{% for a in archives %}
<tr>
<td class="mono small text-nowrap">{{ a.name }}<div class="text-muted">{{ a.modified | timestamp_fmt }}</div></td>
<td class="small wrap">{% for p in a.packages %}<div class="mono fname">{{ fname(p) }}</div>{% else %}<span class="text-danger">none</span>{% endfor %}
{% if a.missing_required %}<div class="text-danger">missing {{ a.missing_required | join(', ') }}</div>{% endif %}</td>
<td class="small text-nowrap">{{ a.count }}, {{ size(a.bytes) }}</td>
<td class="small text-nowrap">{{ size(a.own_bytes) }}</td>
<td class="text-end text-nowrap">
<form method="post" action="{{ url_for('bprt_sync.rollback') }}" style="display:inline;"
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="archive" value="{{ a.name }}">
<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>
</form>
<form method="post" action="{{ url_for('bprt_sync.delete_archive') }}" style="display:inline;"
onsubmit="return confirm('Delete archive {{ a.name }}? This frees {{ size(a.own_bytes) }}; it cannot be rolled back to afterwards.');">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<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>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted mb-0">None yet. The first activation archives the current folder here.</p>
{% endif %}
</div>
{# ---- Archives (folded) ---------------------------------------------------- #}
<section class="panel ui-list" aria-labelledby="ar-h">
<details>
<summary class="sec-h">
<div><h2 id="ar-h" class="sec" style="font-size: 15px">Previous BPRT folders</h2>
<div class="sub">Each activation and rollback keeps the folder it replaced in <span class="mono">BPRT-archive</span>.</div></div>
<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>
</summary>
{% for a in archives %}
<div class="ui-row">
<details>
<summary class="xgrid">
<span class="mono">{{ a.name }}</span>
<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>
<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>
<span class="chev" aria-hidden="true">&#9662;</span>
</summary>
<div class="rbody">
{% if a.missing_required %}<div class="notice bad">Missing {{ a.missing_required | join(', ') }}</div>{% endif %}
<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>
{% for p in a.packages %}<div class="mono fname">{{ fname(p) }}</div>{% else %}<div style="color: var(--fail-text)">No MCL packages.</div>{% endfor %}
</div>
</details>
<div class="act">
<form method="post" action="{{ url_for('bprt_sync.rollback') }}" style="margin: 0"
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="archive" value="{{ a.name }}">
<button type="submit" class="ui-btn" {% if job.running %}disabled{% endif %}>Roll back</button>
</form>
{% call ui.more('archive ' ~ a.name) %}
<form method="post" action="{{ url_for('bprt_sync.delete_archive') }}"
onsubmit="return confirm('Delete archive {{ a.name }}? This frees {{ size(a.own_bytes) }}; it cannot be rolled back to afterwards.');">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="archive" value="{{ a.name }}">
<button type="submit" class="del" {% if job.running %}disabled{% endif %}>Delete archive</button>
</form>
{% endcall %}
</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 %}
<div class="section-card">
<div class="section-title"><i class="bi bi-clock-history"></i> History</div>
<div class="table-container">
<table class="data-table">
<thead><tr><th>When</th><th>What</th><th>Previous folder</th></tr></thead>
<tbody>
{% for h in history %}
<tr>
<td class="small text-nowrap">{{ h.at | local_dt }}</td>
<td class="small wrap">
{% if h.kind == 'rollback' %}Rolled back to <span class="mono">{{ h.restored }}</span>
{% else %}Activated <span class="mono">{{ h.batch }}</span>{% if h.sync and h.sync.computer %} from {{ h.sync.computer }}{% endif %}:
{{ h.counts.new or 0 }} new, {{ h.counts.changed or 0 }} changed, {{ h.counts.removed or 0 }} removed
{% 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 %}
</td>
<td class="mono small">{{ h.archive }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
<section class="panel ui-list" aria-labelledby="hi-h">
<details>
<summary class="sec-h">
<div><h2 id="hi-h" class="sec" style="font-size: 15px">History</h2><div class="sub">Activations and rollbacks, newest first.</div></div>
<span style="display: flex; align-items: center; gap: 10px">{{ ui.chip(history | length, 'neutral') }}<span class="chev" aria-hidden="true">&#9662;</span></span>
</summary>
{% for h in history %}
<div class="ui-row">
<details>
<summary class="hgrid">
<span class="sub when" style="white-space: nowrap">{{ h.at | local_dt }}</span>
{% if h.kind == 'rollback' %}
<span class="tip" data-tip="Rolled back to {{ h.restored }}"><span class="trunc">Rolled back to <span class="mono">{{ h.restored }}</span></span></span>
{% else %}
<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>
{% endif %}
<span class="chev" aria-hidden="true">&#9662;</span>
</summary>
<div class="rbody">
{% if h.kind != 'rollback' %}
<div>Activated <span class="mono">{{ h.batch }}</span>{% if h.sync and h.sync.computer %} from {{ h.sync.computer }}{% endif %}:
{{ h.counts.new or 0 }} new, {{ h.counts.changed or 0 }} changed, {{ h.counts.removed or 0 }} removed.</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 %}
<div class="sub">Previous folder kept as <span class="mono">{{ h.archive }}</span>.</div>
</div>
</details>
</div>
{% endfor %}
</details>
</section>
{% endif %}
</div>
{% endif %}
{% endblock %}
{% block extra_scripts %}

View File

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

View File

@@ -1,322 +1,424 @@
{% 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) -%}
{% 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>
{% elif info.kind == 'microsoft-2023' %}<span class="badge badge-success" title="Signed under Microsoft UEFI CA 2023">Microsoft UEFI CA 2023</span>
{% elif info.kind == 'microsoft' %}<span class="badge badge-info">Microsoft (other CA)</span>
{% 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 == 'bios' %}<span class="badge badge-secondary" title="Legacy BIOS has no Secure Boot">Legacy BIOS, unsigned</span>
{% elif info.kind == 'unsigned' %}<span class="badge badge-danger" title="Secure Boot firmware will refuse it">Unsigned</span>
{% elif info.kind == 'error' %}<span class="badge badge-danger" title="{{ info.sig_error }}">Not checked</span>
{% else %}<span class="badge badge-warning">Other signer</span>{% endif %}
{% 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 %}
{# Signer as a short chip; the long explanation goes in the row body (it is
a safety warning, so never hover-only). #}
{% macro sig_chip(info) -%}
{% if info.kind == 'microsoft-2011' %}{{ ui.chip('MS UEFI CA 2011 only', 'warn') }}
{% elif info.kind == 'microsoft-2023' %}{{ ui.chip('MS UEFI CA 2023', 'ok') }}
{% elif info.kind == 'microsoft' %}{{ ui.chip('Microsoft, other CA', 'neutral') }}
{% elif info.kind == 'ipxe' %}{{ ui.chip('iPXE Secure Boot CA', 'ok') }}
{% elif info.kind == 'bios' %}{{ ui.chip('Legacy BIOS', 'neutral') }}
{% elif info.kind == 'unsigned' %}{{ ui.chip('Unsigned', 'err') }}
{% 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 %}
{% macro chain(info) -%}
{% if info.signers %}
<details class="mt-1">
<summary class="small text-muted" style="cursor:pointer;">Signer chain ({{ info.signers | length }})</summary>
<div><span class="sub">Signer chain ({{ info.signers | length }})</span>
{% 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 %}
</details>
</div>
{% endif %}
{% if info.trusts %}<div class="small text-muted">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.trusts %}<div class="sub">Starts second stages signed by <span class="mono">{{ info.trusts }}</span></div>{% endif %}
{% if info.sbat %}<div class="sub">SBAT: <span class="mono">{{ info.sbat }}</span></div>{% endif %}
{%- 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 %}
<div class="page-header">
<h1><i class="bi bi-shield-lock"></i> iPXE Boot Files</h1>
<div class="header-actions">
{% if status == 'update' %}<span class="badge badge-warning badge-lg">Update available: {{ latest.tag }}</span>
{% elif status == 'current' %}<span class="badge badge-success badge-lg">Up to date ({{ latest.tag }})</span>
{% elif status == 'newer' %}<span class="badge badge-info badge-lg">Ahead of {{ latest.tag }}</span>
{% elif latest %}<span class="badge badge-secondary badge-lg">Latest: {{ latest.tag }}</span>{% endif %}
<form method="post" action="{{ url_for('ipxe.check') }}" style="display:inline;">
<header class="ui-head">
<div>
<div class="eyebrow-t">Updates</div>
<h1>iPXE boot files</h1>
<div class="state-line">
{% if status == 'update' %}{{ ui.chip('Update available: ' ~ latest.tag, 'act') }}
{% elif status == 'current' %}{{ ui.chip('Up to date (' ~ latest.tag ~ ')', 'ok') }}
{% 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() }}">
<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>
</div>
</div>
</header>
<p class="text-muted">
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>
<div class="ui-stack">
{# ---- Upstream ------------------------------------------------------------ #}
<div class="section-card">
<div class="section-title"><i class="bi bi-cloud-arrow-down"></i> Upstream release</div>
<div class="row g-3 align-items-end">
<div class="col-lg-7">
{% if latest %}
<div>
<strong class="mono">{{ latest.tag }}</strong>
{% 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 %}
{# ---- Job ----------------------------------------------------------------- #}
{% if job.state %}
<div id="ipxeJob" data-state="{{ job.state }}">
{% if job.state in ('queued', 'running') %}
<div class="panel strip" role="status">
<span class="pulse" aria-hidden="true"></span>
<span id="ipxeJobText">{{ 'Starting' if job.state == 'queued' else (job.step | capitalize) }} {{ job.tag }}...</span>
<div class="bar" aria-hidden="true"><div id="ipxeJobBar" style="width: {{ job.percent or 0 }}%"></div></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 %}
</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 %}
{# ---- dnsmasq routing ----------------------------------------------------- #}
<div class="section-card">
<div class="section-title"><i class="bi bi-diagram-3"></i> Who gets which file (dnsmasq, read-only)</div>
{% if routing.boots %}
<div class="table-container">
<table class="data-table">
<thead><tr><th>Boot file</th><th>Requires tag</th><th>Unless tagged</th><th>Clients</th></tr></thead>
<tbody>
{% for b in routing.boots %}
<tr>
<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>
{% 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>
<td class="mono">{{ b.need | join(', ') or '-' }}</td>
<td class="mono">{{ b.exclude | join(', ') or '-' }}</td>
<td class="wrap">
{% if b.macs %}<span class="mono">{{ b.macs | join(', ') }}</span>
{% elif b.need %}<span class="text-muted">clients matching {{ b.need | join(' and ') }}</span>
{% else %}<span class="text-muted">every client not excluded</span>{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<details>
<summary class="disc"><span class="chev" aria-hidden="true">&#9662;</span>What this page manages</summary>
<p class="sub" style="margin: 4px 0 0; max-width: 900px; line-height: 1.6">
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>
</details>
{# ---- Installed ----------------------------------------------------------- #}
<section class="panel ui-list" aria-labelledby="inst-h">
<div class="sec-h"><div><h2 id="inst-h" class="sec">Installed on the TFTP root</h2>
<div class="sub">One line per boot file. Open a row for its signer chain, SBAT and hash.</div></div></div>
<div class="igrid ihead ui-rowhead" aria-hidden="true"><span>File</span><span>Version</span><span>Signature</span><span></span></div>
{% for r in rows %}
{% set i = r.info %}
{% set chain_ok = i.chain_ok if i and i.chain_ok is defined else none %}
<div class="ui-row i-row">
<details>
<summary class="igrid">
<span class="name"><span class="mono">{{ r.name }}</span>{% if not r.managed %}<span class="sub">read-only here</span>{% endif %}</span>
<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>
<details class="mt-2">
<summary class="eyebrow" style="cursor:pointer;">Relevant dnsmasq lines ({{ routing.lines | length }})</summary>
<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 }}
{% endfor %}
</section>
{# ---- 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>
</details>
{% 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 %}
</div>
</section>
{# ---- Backups ------------------------------------------------------------- #}
<div class="section-card">
<div class="section-title"><i class="bi bi-clock-history"></i> Backups</div>
{% if backups %}
<div class="table-container">
<table class="data-table">
<thead><tr><th>Backup</th><th>Reason</th><th>Files</th><th class="text-end"></th></tr></thead>
<tbody>
{% for b in backups %}
<tr>
<td class="mono text-nowrap">{{ b.id }}</td>
<td>{{ b.reason or '-' }}</td>
<td>
{% for f in b.files %}<div class="small"><span class="mono">{{ f }}</span> <span class="text-muted">{{ b.versions[f] or '' }}</span></div>{% endfor %}
{% if not b.files %}<span class="text-muted">empty</span>{% endif %}
</td>
<td class="text-end text-nowrap">
{% if b.files %}
{# The id is a folder name (hand-made backups too): keep it out of the JS source. #}
<form method="post" action="{{ url_for('ipxe.rollback') }}" style="display:inline;"
data-confirm="Put back {{ b.files | join(', ') }} from backup {{ b.id }}? The current files are backed up first."
onsubmit="return confirm(this.dataset.confirm);">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="backup" value="{{ b.id }}">
<button type="submit" class="pxe-btn pxe-btn-ghost btn-row-action"><i class="bi bi-arrow-counterclockwise"></i> Rollback</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<section class="panel ui-list" aria-labelledby="bk-h">
<div class="sec-h"><div><h2 id="bk-h" class="sec">Backups</h2>
<div class="sub">One is made automatically before every install and rollback. Restore is in each row's menu.</div></div></div>
{% for b in backups %}
<div class="ui-row">
<details>
<summary class="bgrid">
<span class="mono">{{ b.id }}</span>
<span class="tip" data-tip="{{ b.reason or '-' }}"><span class="trunc">{{ b.reason or '-' }}</span></span>
<span class="tip" data-tip="{{ b.files | join(', ') or 'empty' }}"><span class="trunc sub mono">{{ b.files | join(', ') or 'empty' }}</span></span>
<span class="chev" aria-hidden="true">&#9662;</span>
</summary>
<div class="ibody">
{% if b.reason %}<div>{{ b.reason }}</div>{% endif %}
{% 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 %}
</div>
</details>
{% if b.files %}
<div class="act">
{% call ui.more('backup ' ~ b.id) %}
{# The id is a folder name (hand-made backups too): keep it out of the JS source. #}
<form method="post" action="{{ url_for('ipxe.rollback') }}"
data-confirm="Put back {{ b.files | join(', ') }} from backup {{ b.id }}? The current files are backed up first."
onsubmit="return confirm(this.dataset.confirm);">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="backup" value="{{ b.id }}">
<button type="submit">Restore these files</button>
</form>
{% endcall %}
</div>
{% endif %}
</div>
{% else %}
<p class="text-muted mb-0">No backups yet. One is made automatically before every install and rollback.</p>
{% endif %}
</div>
<div class="hidden-bar">No backups yet. One is made automatically before every install and rollback.</div>
{% endfor %}
</section>
{# ---- How-to -------------------------------------------------------------- #}
<div class="section-card">
<div class="section-title"><i class="bi bi-journal-text"></i> How to find and install the latest signed build by hand</div>
<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>
<ol>
<li class="mb-3"><strong>Find the release.</strong> The latest release is at
<section class="panel" aria-labelledby="how-h">
<details>
<summary class="sec-h" style="align-items: center">
<div><h2 id="how-h" class="sec" style="font-size: 15px">Find and install the latest signed build by hand</h2>
<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
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>
<li class="mb-3"><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
<li><strong>Download and check the sha256.</strong> It must equal the digest on the release page.
<pre class="cmd mono">cd /tmp
curl -fLO {{ tarball_url }}
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>
<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">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>.
<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>
<li class="mb-3"><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
<li><strong>Check the signatures.</strong>
<pre class="cmd mono">sbverify --list ipxeboot/x86_64-sb/shimx64.efi
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,
<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">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>
<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.
<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)
<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="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>
<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
<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>
<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>
<li class="mb-3"><strong>Who gets it.</strong> dnsmasq decides, in <span class="mono">/etc/dnsmasq.conf</span>: clients listed as
<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><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-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
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
<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
above shows the live lines. Test on one tagged machine before tagging more.</li>
<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>
<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
dnsmasq log are expected and harmless.</li>
</ol>
</div>
</details>
</section>
</div>
{% if job.state in ('queued', 'running') %}