Webapp: Updates pages, image page tabs with review-before-save, BPRT sync

Updates group:
- Dell drivers and BIOS: check Dell's catalogs against all three driver maps
  and models.txt, download with a shared speed cap, repack, preview, apply
  with backups and rollback.
- iPXE boot files: check GitHub for the signed Secure Boot set, verify the
  sha256 and each file's signer, install atomically, roll back. Includes the
  by-hand procedure.
- Blancco: upload a release, stage it, test-boot it from the menu, activate,
  roll back.
- BPRT sync: validate a batch uploaded from the MediaCreator Lite cache
  (scripts/windows/Sync-BPRT.ps1 or a zip), show the diff, swap with the site
  overlay files kept.

Images: one sidebar entry per image with Overview, Drivers, Unattend and OS and
Packages tabs. Every save shows a diff first, keeps the file's format, makes a
backup and can be undone. Driver lint runs inline.

Enrollment: upload standard/engineer MCL packages, one per tenant/purpose,
with archive and restore. Hardware coverage counts engineer as Win11.
This commit is contained in:
cproudlock
2026-10-13 16:58:55 -04:00
parent ddf0a2fdf2
commit 397063f987
39 changed files with 15109 additions and 1106 deletions

View File

@@ -0,0 +1,150 @@
{#
_image_header.html - shared header + tabs for every GE Aerospace image page.
CONTRACT (image-shell; used by the Overview, Drivers, Unattend and OS and
Packages tabs):
Use it as the first thing inside {% block content %}:
{% set header_actions %}
<button type="button" class="btn btn-success btn-sm" id="...">Save</button>
{% endset %}
{% set header_subtitle = 'Deploy/FlatUnattendW10.xml' %}
{% include "_image_header.html" %}
Variables it reads from the template context:
image_type required. Image key, e.g. "gea-standard".
active_tab optional. One of "overview", "drivers", "unattend", "os".
When missing it is worked out from request.endpoint
(image_tab_for_endpoint), so a route need not pass it.
header_actions optional. HTML for the right side of the title row
(Save / Review / Undo buttons). Build it with a
{% set header_actions %}...{% endset %} block.
header_subtitle optional. Plain text appended to the meta line
(file path, last save time, ...).
Globals it uses (context processor in app.py):
image_tabs services/image_pages.IMAGE_TABS: id, label, icon,
endpoint for each tab, in display order.
image_tab_url(t, tab) URL of a tab; use it for links and redirects
(in Python: services.image_pages.tab_url).
image_header_info(t) friendly_name, os_label, driver_count,
package_count, has_unattend.
Tab endpoints (change a row of IMAGE_TABS if a page moves):
overview images.overview GET /images/<t> views/images.py
drivers images_drivers.page GET /images/<t>/drivers views/images_drivers.py
unattend unattend_editor GET /images/<t>/unattend app.py
os images.os_packages GET /images/<t>/os views/images.py
Old URLs: /images/<t>/config redirects to the Overview (or ?tab=<id>).
The sidebar marks an image active whenever active_tab is set (or the
endpoint maps to a tab) and image_type matches, so any page that includes
this partial highlights the right sidebar entry.
It also loads static/image-pages.js and the shared review modal
(#imageReviewModal). For a "Review changes" step call
PxeImage.reviewAndSave({previewUrl, saveUrl, body, title})
where previewUrl answers {"files": [control_files.public(plan), ...]}
and saveUrl takes the same body plus "expect": {name: sha_before} and
answers {"ok": true} (it flashes; the page reloads) or {"error": "..."}.
PxeImage.renderDiff(el, files) draws the same diff anywhere.
#}
{% set _tab = active_tab if active_tab is defined and active_tab else image_tab_for_endpoint(request.endpoint) %}
{% set _hdr = image_header_info(image_type) %}
<style>
.image-head { margin-bottom: 1.25rem; }
.image-head-top {
display: flex; flex-wrap: wrap; gap: 0.75rem 1rem;
justify-content: space-between; align-items: flex-end;
margin-bottom: 0.75rem;
}
.image-head-title { flex: 1 1 20rem; min-width: 0; }
.image-head h1 { font-size: 20px; font-weight: 500; line-height: 1.3; margin: 0.15rem 0 0.2rem; }
.image-head-meta { font-size: 13px; color: var(--text-light); display: flex; flex-wrap: wrap; gap: 0.25rem 0.6rem; align-items: center; }
.image-head-meta .sep { opacity: 0.5; }
.image-head .header-actions { flex-wrap: wrap; align-items: center; }
.image-tabs.nav-tabs { flex-wrap: wrap; }
.image-tabs .nav-link { font-size: 12px; padding: 0.65rem 1rem; display: flex; align-items: center; gap: 0.4rem; }
.image-tabs .nav-link .bi { font-size: 14px; }
.image-tabs .tab-count {
font-size: 10px; font-weight: 600; letter-spacing: 0; line-height: 1;
padding: 2px 7px; border-radius: 999px;
background: var(--bg); border: 1px solid var(--border); color: var(--text-light);
}
.image-tabs .nav-link.active .tab-count { color: var(--primary); border-color: var(--primary); }
/* Unified diff (review step of every save) */
.diff-file { border: 1px solid var(--border); border-radius: 0.25rem; margin-bottom: 1rem; overflow: hidden; }
.diff-file-head { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; padding: 0.5rem 0.75rem; border-bottom: 1px solid var(--border); font-weight: 600; font-size: 13px; }
.diff-view {
margin: 0; max-height: 55vh; overflow: auto; font-size: 12px; line-height: 1.45;
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace; background: var(--bg-card-solid);
}
.diff-view span { display: block; padding: 0 0.75rem; white-space: pre-wrap; word-break: break-all; }
.diff-view .d-add { background: rgba(10, 214, 79, 0.16); }
.diff-view .d-del { background: rgba(220, 53, 69, 0.14); }
.diff-view .d-hunk { color: var(--primary); background: rgba(65, 129, 255, 0.08); }
.diff-view .d-meta { color: var(--text-light); }
[data-theme="dark"] .diff-view .d-add { background: rgba(10, 214, 79, 0.18); }
[data-theme="dark"] .diff-view .d-del { background: rgba(245, 54, 92, 0.2); }
</style>
<div class="image-head">
<div class="image-head-top">
<div class="image-head-title">
<div class="eyebrow">GE Aerospace image</div>
<h1>{{ _hdr.friendly_name }}</h1>
<div class="image-head-meta">
<span class="mono">{{ image_type }}</span>
{% if _hdr.os_label %}<span class="sep">|</span><span><i class="bi bi-windows"></i> {{ _hdr.os_label }}</span>{% endif %}
{% if header_subtitle is defined and header_subtitle %}<span class="sep">|</span><span class="mono">{{ header_subtitle }}</span>{% endif %}
</div>
</div>
{% if header_actions is defined and header_actions %}
<div class="header-actions">{{ header_actions }}</div>
{% endif %}
</div>
<ul class="nav nav-tabs image-tabs" role="tablist">
{% for t in image_tabs %}
<li class="nav-item">
<a class="nav-link {% if t.id == _tab %}active{% endif %}"
{% if t.id == _tab %}aria-current="page"{% endif %}
href="{{ image_tab_url(image_type, t.id) }}">
<i class="bi {{ t.icon }}"></i> {{ t.label }}
{% if t.id == 'drivers' %}<span class="tab-count" title="Entries in HardwareDriver.json">{{ _hdr.driver_count }}</span>
{% elif t.id == 'os' and _hdr.package_count %}<span class="tab-count" title="Entries in packages.json">{{ _hdr.package_count }}</span>
{% elif t.id == 'unattend' and not _hdr.has_unattend %}<span class="tab-count" title="FlatUnattendW10.xml is missing">missing</span>{% endif %}
</a>
</li>
{% endfor %}
</ul>
</div>
{# Shared review step: filled by PxeImage.reviewAndSave (static/image-pages.js) #}
<div class="modal fade" id="imageReviewModal" tabindex="-1" aria-labelledby="imageReviewTitle">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title" id="imageReviewTitle">Review changes</h3>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p class="text-muted small mb-3" id="imageReviewIntro">
This is exactly what will be written. Each changed file is backed up next to itself
(<span class="mono">.bak-&lt;date&gt;-webui</span>) first, and the save can be undone from this page.
</p>
<div id="imageReviewBody"></div>
<div class="alert alert-danger d-none mb-0" id="imageReviewError"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-success" id="imageReviewConfirm" disabled>
<i class="bi bi-check-lg"></i> Save
</button>
</div>
</div>
</div>
</div>
<script src="{{ url_for('static', filename='image-pages.js') }}"></script>

View File

@@ -102,19 +102,45 @@
</li>
</ul>
<div class="nav-section">GE Aerospace Images</div>
<div class="nav-section">Updates</div>
<ul class="sidebar-nav nav flex-column">
{% for it in all_image_types if it.startswith('gea-') %}
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'unattend_editor' and image_type is defined and image_type == it %}active{% endif %}"
href="{{ url_for('unattend_editor', image_type=it) }}">
<i class="bi bi-windows"></i> {{ all_friendly_names[it] }}
<a class="nav-link {% if request.endpoint and request.endpoint.startswith('dell_updates.') %}active{% endif %}"
href="{{ url_for('dell_updates.page') }}">
<i class="bi bi-motherboard"></i> Dell Drivers and BIOS
</a>
</li>
<li class="nav-item">
<a class="nav-link nav-sublink {% if request.endpoint == 'image_config' and image_type is defined and image_type == it %}active{% endif %}"
href="{{ url_for('image_config', image_type=it) }}">
<i class="bi bi-gear"></i> Configuration
<a class="nav-link {% if request.endpoint and request.endpoint.startswith('ipxe.') %}active{% endif %}"
href="{{ url_for('ipxe.page') }}">
<i class="bi bi-shield-lock"></i> iPXE Boot Files
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint and request.endpoint.startswith('blancco_update.') %}active{% endif %}"
href="{{ url_for('blancco_update.page') }}">
<i class="bi bi-eraser"></i> Blancco Updates
</a>
</li>
<li class="nav-item">
{# Plain href: the page still renders if views.bprt_sync is not registered yet. #}
<a class="nav-link {% if request.endpoint and request.endpoint.startswith('bprt_sync.') %}active{% endif %}"
href="/updates/bprt/">
<i class="bi bi-arrow-left-right"></i> BPRT Sync
</a>
</li>
</ul>
<div class="nav-section">GE Aerospace Images</div>
<ul class="sidebar-nav nav flex-column">
{% for it in all_image_types if it.startswith('gea-') %}
{# One entry per image; active on any of its tabs (see _image_header.html). #}
{% set _on_image = image_type is defined and image_type == it
and ((active_tab is defined and active_tab) or image_tab_for_endpoint(request.endpoint)) %}
<li class="nav-item">
<a class="nav-link {% if _on_image %}active{% endif %}"
href="{{ image_tab_url(it, 'overview') }}">
<i class="bi bi-windows"></i> {{ all_friendly_names[it] }}
</a>
</li>
{% endfor %}

View File

@@ -119,9 +119,9 @@
</td>
<td><span class="mono">{{ img.deploy_path }}</span></td>
<td class="actions text-end">
<a href="{{ url_for('image_config', image_type=img.image_type) }}"
<a href="{{ image_tab_url(img.image_type, 'overview') }}"
class="btn btn-sm btn-outline-secondary btn-row-action">
<i class="bi bi-gear"></i> Config
<i class="bi bi-clipboard-data"></i> Open
</a>
<a href="{{ url_for('unattend_editor', image_type=img.image_type) }}"
class="btn btn-sm btn-outline-primary btn-row-action">

View File

@@ -22,7 +22,7 @@
<div class="card">
<div class="card-header">
<span><i class="bi bi-box-seam"></i> GCCH Provisioning Packages</span>
<span><i class="bi bi-box-seam"></i> Shop Floor (SFLD) Provisioning Packages</span>
<span class="badge badge-secondary">{{ packages|length }}</span>
</div>
<div class="card-body p-0">
@@ -82,6 +82,107 @@
</p>
</div>
<div class="card" id="mcl">
<div class="card-header">
<span><i class="bi bi-box-seam"></i> Standard / Engineer (MCL) Provisioning Packages</span>
<span class="badge badge-secondary">{{ mcl_packages|length }}</span>
</div>
<div 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">
<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 %}
</div>
</div>
<!-- Upload Modal -->
<div class="modal fade" id="uploadModal" tabindex="-1">
<div class="modal-dialog">

View File

@@ -15,6 +15,13 @@
{% else %}<span class="badge badge-danger">No BIOS entry</span>{% endif %}
{%- endmacro %}
{# A newer Dell pack/BIOS for the file this model uses, from the last Updates > Dell scan. #}
{% macro update_hint(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>
{% endif %}
{%- endmacro %}
{% block content %}
<div class="page-header">
<h1><i class="bi bi-cpu"></i> Hardware Coverage</h1>
@@ -53,8 +60,8 @@
</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]) }}</td>{% endfor %}
<td>{{ bios_badge(r.bios) }}</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 %}

View File

@@ -1,492 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ friendly_name }} - Configuration{% endblock %}
{% block extra_head %}
<style>
/* image_config-specific tweaks layered on top of pxe-theme.css tokens */
.config-table td, .config-table th { vertical-align: middle; }
.config-table .form-control-sm { min-width: 120px; }
.badge-disk { font-size: 11px; }
.text-truncate-cell {
max-width: 250px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Unregistered-drivers strip: warning-tinted footer that reads in both themes */
.orphan-section {
background: rgba(255, 149, 0, 0.08);
border-top: 1px solid var(--border);
}
[data-theme="dark"] .orphan-section { background: rgba(255, 149, 0, 0.10); }
.orphan-toggle { color: var(--text); text-decoration: none; }
.orphan-toggle:hover { color: var(--primary); }
.section-count { background: var(--secondary); }
.card-header .header-actions { flex: 0 0 auto; }
</style>
{% endblock %}
{% block content %}
<div class="page-header">
<div>
<h1>{{ friendly_name }}</h1>
<div class="eyebrow" style="margin-top:0.35rem;">
Image Configuration &mdash; OS Selection:
<strong>{{ config.os_selection or 'Not set' }}</strong>
</div>
</div>
<div class="header-actions">
<a href="{{ url_for('unattend_editor', image_type=image_type) }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-pencil-square"></i> Edit Unattend
</a>
</div>
</div>
{# ==================== SECTION 1: Hardware Models ==================== #}
<div class="card">
<div class="card-header">
<span class="d-flex align-items-center gap-2">
<i class="bi bi-cpu"></i> Hardware Models
<span class="badge section-count">{{ config.hardware_models|length }}</span>
</span>
<div class="header-actions">
<button type="button" class="btn btn-sm btn-outline-primary" id="addHwModel">
<i class="bi bi-plus-lg"></i> Add
</button>
<button type="button" class="btn btn-sm btn-success" id="saveHwModels">
<i class="bi bi-check-lg"></i> Save
</button>
</div>
</div>
<div class="card-body p-0">
<form method="POST" action="{{ url_for('image_config_save', image_type=image_type) }}" id="hwModelsForm">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="section" value="hardware_models">
<input type="hidden" name="payload" id="hwModelsData" value="[]">
</form>
<div class="table-container">
<table class="table table-hover mb-0 config-table" id="hwModelsTable">
<thead>
<tr>
<th style="width:40px">#</th>
<th>Model</th>
<th>Driver Family ID</th>
<th style="width:90px">On Disk</th>
<th style="width:60px"></th>
</tr>
</thead>
<tbody>
{% for hm in config.hardware_models %}
<tr>
<td class="order-num">{{ loop.index }}</td>
<td><input type="text" class="form-control form-control-sm" data-field="Model" value="{{ hm.Model }}"></td>
<td><input type="text" class="form-control form-control-sm" data-field="Id" value="{{ hm.Id }}"></td>
<td>
{% if hm._on_disk %}
<span class="badge badge-success badge-disk">Yes</span>
{% else %}
<span class="badge badge-danger badge-disk">No</span>
{% endif %}
</td>
<td class="actions">
<button type="button" class="btn btn-outline-danger btn-row-action remove-row">
<i class="bi bi-trash"></i> Remove
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if not config.hardware_models %}
<div class="text-center text-muted py-3 empty-message" id="hwModelsEmpty">
No hardware models configured.
</div>
{% endif %}
</div>
</div>
{# ==================== SECTION 2: Driver Packs ==================== #}
<div class="card">
<div class="card-header">
<span class="d-flex align-items-center gap-2">
<i class="bi bi-hdd-network"></i> Driver Packs
<span class="badge section-count">{{ config.drivers|length }}</span>
</span>
<div class="header-actions">
<button type="button" class="btn btn-sm btn-outline-primary"
data-bs-toggle="modal" data-bs-target="#driverUploadModal">
<i class="bi bi-upload"></i> Upload .zip
</button>
<button type="button" class="btn btn-sm btn-success" id="saveDrivers">
<i class="bi bi-check-lg"></i> Save
</button>
</div>
</div>
<div class="card-body p-0">
<form method="POST" action="{{ url_for('image_config_save', image_type=image_type) }}" id="driversForm">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="section" value="drivers">
<input type="hidden" name="payload" id="driversData" value="[]">
</form>
<div class="table-container">
<table class="table table-hover mb-0 config-table" id="driversTable">
<thead>
<tr>
<th style="width:40px">#</th>
<th>Family</th>
<th>Models</th>
<th>File Name</th>
<th style="width:70px">OS IDs</th>
<th style="width:90px">On Disk</th>
<th style="width:60px"></th>
</tr>
</thead>
<tbody>
{% for drv in config.drivers %}
<tr data-json='{{ drv | tojson }}'>
<td class="order-num">{{ loop.index }}</td>
<td class="text-truncate-cell" title="{{ drv.family }}">{{ drv.family }}</td>
<td class="text-truncate-cell" title="{{ drv.models }}">{{ drv.models }}</td>
<td class="text-truncate-cell mono" title="{{ drv.FileName or drv.get('fileName','') }}">
{{ drv.FileName or drv.get('fileName','') }}
</td>
<td class="mono">{{ drv.osId }}</td>
<td>
{% if drv._on_disk %}
<span class="badge badge-success badge-disk">Yes</span>
{% else %}
<span class="badge badge-danger badge-disk">No</span>
{% endif %}
</td>
<td class="actions">
<button type="button" class="btn btn-outline-danger btn-row-action remove-row">
<i class="bi bi-trash"></i> Remove
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if not config.drivers %}
<div class="text-center text-muted py-3 empty-message" id="driversEmpty">
No driver packs configured.
</div>
{% endif %}
</div>
{# Orphan drivers sub-section: zips on disk that aren't referenced by #}
{# HardwareDriver.json. Each row has an inline Adopt form (family + dest #}
{# inputs -> adds an entry to HardwareDriver.json). Bulk remove deletes #}
{# the selected .zip files from Out-of-box Drivers/. #}
{% if config.orphan_drivers %}
<div class="orphan-section">
<div class="px-3 py-2 d-flex justify-content-between align-items-center">
<a class="orphan-toggle d-flex align-items-center gap-2" data-bs-toggle="collapse" href="#orphanDrivers" role="button">
<i class="bi bi-exclamation-triangle"></i>
<strong>Unregistered Drivers ({{ config.orphan_drivers|length }})</strong>
<span class="text-muted" style="font-size:0.82rem;">.zip files on disk, no JSON entry</span>
</a>
</div>
<div class="collapse show" id="orphanDrivers">
<form method="POST" action="{{ url_for('images_drivers_orphans_delete', image_type=image_type) }}"
onsubmit="return confirm('Delete the selected unregistered driver .zip(s) from disk? Cannot be undone.');">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="table-container">
<table class="table mb-0 align-middle">
<thead>
<tr>
<th style="width:30px"><input type="checkbox" id="orphanSelectAll"></th>
<th>File Name</th>
<th>Relative Path</th>
<th style="width:300px">Adopt into HardwareDriver.json</th>
</tr>
</thead>
<tbody>
{% for orph in config.orphan_drivers %}
<tr>
<td><input type="checkbox" class="orphan-select" name="filename" value="{{ orph.fileName }}"></td>
<td class="mono">{{ orph.fileName }}</td>
<td class="mono text-muted">{{ orph.relPath }}</td>
<td>
{# adopt inputs target standalone adoptForm below - form-in-form is dropped by parser and hijacked the delete form #}
<div class="d-flex gap-1">
<input type="text" class="form-control form-control-sm" name="family"
form="adoptForm{{ loop.index }}"
placeholder="family id (e.g. Optiplex_7060)" required style="width:11rem;">
<input type="text" class="form-control form-control-sm" name="destination_dir"
form="adoptForm{{ loop.index }}"
placeholder="destinationDir" required style="width:11rem;">
<button type="submit" class="btn btn-sm btn-success" form="adoptForm{{ loop.index }}">Adopt</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="px-3 py-2 text-end">
<button type="submit" class="btn btn-sm btn-outline-danger">
<i class="bi bi-trash"></i> Delete selected
</button>
</div>
</form>
{# one adopt form per orphan row, kept OUTSIDE the delete form #}
{% for orph in config.orphan_drivers %}
<form method="POST" id="adoptForm{{ loop.index }}"
action="{{ url_for('images_drivers_adopt', image_type=image_type) }}">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="filename" value="{{ orph.fileName }}">
</form>
{% endfor %}
</div>
</div>
{% endif %}
</div>
<!-- Driver upload modal -->
<div class="modal fade" id="driverUploadModal" tabindex="-1">
<div class="modal-dialog">
<form method="POST" action="{{ url_for('images_drivers_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 driver .zip</h3>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="form-field">
<label class="form-label">Driver .zip file</label>
<input type="file" class="form-control" name="driver_file" accept=".zip" required>
<div class="form-text">Lands in <code>{{ image_type }}/Deploy/Out-of-box Drivers/</code>.</div>
</div>
<div class="form-field">
<label class="form-label">Family ID (optional)</label>
<input type="text" class="form-control mono" name="family"
placeholder="Optiplex_7060">
<div class="form-text">Matches a HardwareModelSelection.Id. Leave blank to land as orphan + adopt later.</div>
</div>
<div class="form-field">
<label class="form-label">Destination directory (optional)</label>
<input type="text" class="form-control mono" name="destination_dir"
placeholder="*destinationdir*\Drivers\Optiplex">
<div class="form-text">Where the .zip extracts at deploy time. Required if Family ID is set.</div>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="overwrite" value="1" id="driverOverwriteCheck">
<label class="form-check-label" for="driverOverwriteCheck">Overwrite if same filename exists</label>
</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>
</div>
</div>
</form>
</div>
</div>
{# ==================== SECTION 3: Operating Systems ==================== #}
<div class="card">
<div class="card-header">
<span class="d-flex align-items-center gap-2">
<i class="bi bi-windows"></i> Operating Systems
<span class="badge section-count">{{ config.operating_systems|length }}</span>
</span>
<div class="header-actions">
<button type="button" class="btn btn-sm btn-success" id="saveOs">
<i class="bi bi-check-lg"></i> Save
</button>
</div>
</div>
<div class="card-body p-0">
<form method="POST" action="{{ url_for('image_config_save', image_type=image_type) }}" id="osForm">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="section" value="operating_systems">
<input type="hidden" name="payload" id="osData" value="[]">
</form>
<div class="table-container">
<table class="table table-hover mb-0 config-table" id="osTable">
<thead>
<tr>
<th style="width:40px">#</th>
<th>Product Name</th>
<th>Version</th>
<th>Build</th>
<th style="width:60px">ID</th>
<th style="width:70px">Active</th>
<th style="width:90px">WIM On Disk</th>
<th style="width:60px"></th>
</tr>
</thead>
<tbody>
{% for entry in config.operating_systems %}
{% set osv = entry.operatingSystemVersion %}
<tr data-json='{{ entry | tojson }}'>
<td class="order-num">{{ loop.index }}</td>
<td>{{ osv.productName }}</td>
<td>{{ osv.versionNumber }}</td>
<td>{{ osv.buildNumber }}</td>
<td class="mono">{{ osv.id }}</td>
<td>
{% if osv.isActive %}
<span class="badge badge-success badge-disk">Active</span>
{% else %}
<span class="badge badge-secondary badge-disk">Inactive</span>
{% endif %}
</td>
<td>
{% if entry._on_disk %}
<span class="badge badge-success badge-disk">Yes</span>
{% else %}
<span class="badge badge-danger badge-disk">No</span>
{% endif %}
</td>
<td class="actions">
<button type="button" class="btn btn-outline-danger btn-row-action remove-row">
<i class="bi bi-trash"></i> Remove
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if not config.operating_systems %}
<div class="text-center text-muted py-3 empty-message" id="osEmpty">
No operating systems configured.
</div>
{% endif %}
</div>
</div>
{# ==================== SECTION 4: Packages ==================== #}
<div class="card">
<div class="card-header">
<span class="d-flex align-items-center gap-2">
<i class="bi bi-box-seam"></i> Packages
<span class="badge section-count">{{ config.packages|length }}</span>
</span>
<div class="header-actions">
<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>
<button type="button" class="btn btn-sm btn-success" id="savePackages">
<i class="bi bi-check-lg"></i> Save
</button>
</div>
</div>
<div class="card-body p-0">
<form method="POST" action="{{ url_for('image_config_save', image_type=image_type) }}" id="packagesForm">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="section" value="packages">
<input type="hidden" name="payload" id="packagesData" value="[]">
</form>
<div class="table-container">
<table class="table table-hover mb-0 config-table" id="packagesTable">
<thead>
<tr>
<th style="width:40px">#</th>
<th>Name</th>
<th>Comment</th>
<th>File</th>
<th style="width:70px">OS IDs</th>
<th style="width:80px">Enabled</th>
<th style="width:90px">On Disk</th>
<th style="width:60px"></th>
</tr>
</thead>
<tbody>
{% for pkg in config.packages %}
<tr data-json='{{ pkg | tojson }}'>
<td class="order-num">{{ loop.index }}</td>
<td class="text-truncate-cell" title="{{ pkg.name }}">{{ pkg.name }}</td>
<td class="text-truncate-cell" title="{{ pkg.comment }}">{{ pkg.comment }}</td>
<td class="text-truncate-cell mono" title="{{ pkg.fileName or pkg.get('FileName','') }}">
{{ pkg.fileName or pkg.get('FileName','') }}
</td>
<td class="mono">{{ pkg.osId }}</td>
<td>
{% if pkg.enabled %}
<span class="badge badge-success badge-disk">Yes</span>
{% else %}
<span class="badge badge-secondary badge-disk">No</span>
{% endif %}
</td>
<td>
{% if pkg._on_disk %}
<span class="badge badge-success badge-disk">Yes</span>
{% else %}
<span class="badge badge-danger badge-disk">No</span>
{% endif %}
</td>
<td class="actions">
<button type="button" class="btn btn-outline-danger btn-row-action remove-row">
<i class="bi bi-trash"></i> Remove
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if not config.packages %}
<div class="text-center text-muted py-3 empty-message" id="packagesEmpty">
No packages configured.
</div>
{% endif %}
</div>
</div>
<!-- Package upload modal -->
<div class="modal fade" id="packageUploadModal" tabindex="-1">
<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>
</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>
</div>
<div class="form-field">
<label class="form-label">Destination directory (optional)</label>
<input type="text" class="form-control mono" name="destination_dir"
placeholder="*destinationdir*\Packages">
<div class="form-text">Setting this also appends an entry to <code>packages.json</code>.</div>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="overwrite" value="1" id="pkgOverwriteCheck">
<label class="form-check-label" for="pkgOverwriteCheck">Overwrite if same filename exists</label>
</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>
</div>
</div>
</form>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
// Orphan drivers: select-all checkbox toggles every row's selector.
document.addEventListener('click', function(e) {
if (e.target && e.target.id === 'orphanSelectAll') {
document.querySelectorAll('.orphan-select').forEach(function(cb) {
cb.checked = e.target.checked;
});
}
});
</script>
{% endblock %}

View File

@@ -0,0 +1,893 @@
{% extends "base.html" %}
{% block title %}{{ friendly_name }} - Drivers{% endblock %}
{# Drivers tab: the whole HardwareDriver.json staged in the browser, saved in
one go through a review step (views/images_drivers.py). The table is drawn
by the script at the bottom from #drvData; the server re-lints the staged
map after every edit (/drivers/check). #}
{% block extra_head %}
<style>
.drv-explain { font-size: 13.5px; }
.drv-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; }
.drv-toolbar { display: flex; flex-wrap: wrap; gap: 0.5rem 0.75rem; align-items: center; padding: 0.75rem 1.25rem; border-bottom: 1px solid var(--border); }
.drv-toolbar .drv-search { flex: 1 1 16rem; max-width: 26rem; position: relative; }
.drv-toolbar .drv-search .bi { position: absolute; left: 0.65rem; top: 50%; transform: translateY(-50%); color: var(--text-light); font-size: 13px; }
.drv-toolbar .drv-search input { padding-left: 2rem; }
.drv-seg { display: inline-flex; border: 1px solid var(--border); border-radius: 6px; overflow: hidden; }
.drv-seg button { background: transparent; border: 0; color: var(--text); font-size: 12px; padding: 0.35rem 0.75rem; }
.drv-seg button + button { border-left: 1px solid var(--border); }
.drv-seg button.active { background: var(--primary); color: #fff; }
.drv-chip-toggle { display: inline-flex; align-items: center; gap: 0.4rem; font-size: 12.5px; border: 1px solid var(--border); border-radius: 999px; padding: 0.25rem 0.7rem; cursor: pointer; user-select: none; margin: 0; }
.drv-chip-toggle input { width: 0.95rem; height: 0.95rem; margin: 0; }
.drv-chip-toggle.on { border-color: var(--primary); color: var(--primary); }
.drv-showing { font-size: 12.5px; color: var(--text-light); margin-left: auto; }
.drv-table { table-layout: auto; }
.drv-table td { white-space: normal; vertical-align: top; padding-top: 0.6rem; padding-bottom: 0.6rem; }
.drv-table td.pos { width: 1%; white-space: nowrap; text-align: center; color: var(--text-light); font-weight: 600; }
.drv-table td.pos .num { display: block; font-size: 13px; margin-bottom: 0.3rem; color: var(--text); }
.drv-table td.pos .btn { padding: 2px 6px; font-size: 11px; display: block; margin: 0 auto 2px; }
.drv-table td.os { width: 1%; white-space: nowrap; }
.drv-table td.acts { width: 1%; white-space: nowrap; text-align: right; }
.drv-table td.acts .btn + .btn { margin-left: 0.25rem; }
.drv-table .zipcell { min-width: 15rem; width: 40%; }
.drv-table .models { min-width: 12rem; width: 46%; }
.drv-table .sub { font-size: 12px; color: var(--text-light); margin-top: 0.2rem; overflow-wrap: anywhere; }
.drv-table .fname { font-family: 'SF Mono', 'Monaco', 'Consolas', monospace; font-size: 12.5px; overflow-wrap: anywhere; }
.drv-table .copy-btn { border: 0; background: transparent; color: var(--text-light); padding: 0 0.25rem; font-size: 12px; vertical-align: baseline; }
.drv-table .copy-btn:hover { color: var(--primary); }
.tok { display: inline-block; font-family: 'SF Mono', 'Monaco', 'Consolas', monospace; font-size: 11.5px; border: 1px solid var(--border); border-radius: 4px; padding: 1px 5px; margin: 0 3px 4px 0; background: var(--bg); overflow-wrap: anywhere; white-space: pre-wrap; }
.tok.bad { border-color: var(--danger); color: var(--danger); }
.drv-table tr.st-new > td, .drv-table tr.st-edited > td { background: rgba(65, 129, 255, 0.06); }
.drv-table tr.st-new > td:first-child, .drv-table tr.st-edited > td:first-child, .drv-table tr.st-moved > td:first-child { box-shadow: inset 3px 0 0 var(--primary); }
.drv-table tr.drv-find > td { border-top: 0; padding-top: 0; }
.drv-find ul { list-style: none; margin: 0; padding: 0; }
.drv-find li { display: flex; gap: 0.5rem; align-items: baseline; font-size: 12.5px; padding: 0.15rem 0; overflow-wrap: anywhere; }
.drv-find li .badge { flex: 0 0 auto; }
.lvl-CRITICAL, .lvl-ERROR { background: var(--danger); color: #fff; }
.lvl-WARN { background: var(--warning); color: #00003d; }
.lvl-INFO { background: var(--secondary); color: #fff; }
.drv-empty td { text-align: center; color: var(--text-light); padding: 1.5rem; }
.drv-lint-badges { display: inline-flex; gap: 0.35rem; }
.drv-lint-badges .badge { cursor: pointer; }
.orphan-table td { white-space: normal; vertical-align: top; }
.orphan-table .sub { font-size: 12px; color: var(--text-light); overflow-wrap: anywhere; }
#drvEditModal .tok-preview { min-height: 1.6rem; margin-top: 0.4rem; }
#drvEditModal .zip-check { font-size: 12.5px; margin-top: 0.35rem; }
#drvEditModal textarea { min-height: 5.5rem; font-family: 'SF Mono', 'Monaco', 'Consolas', monospace; font-size: 13px; }
.rv-summary { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-bottom: 0.75rem; }
.rv-moves { list-style: none; padding: 0; margin: 0 0 1rem; font-size: 13px; }
.rv-moves li { padding: 0.2rem 0; }
.rv-lint { font-size: 13px; margin-bottom: 1rem; }
.rv-lint ul { margin: 0.35rem 0 0; padding-left: 1.1rem; }
details.rv-full summary { cursor: pointer; font-size: 13px; font-weight: 600; margin-bottom: 0.5rem; }
</style>
{% endblock %}
{% block content %}
{% set header_actions %}
<span class="dirty-flag"><i class="bi bi-circle-fill" style="font-size:8px;"></i> Unsaved changes</span>
{% if last_save %}
<form method="POST" action="{{ url_for('images_drivers.undo', image_type=image_type) }}" class="d-inline"
onsubmit="return confirm('Put HardwareDriver.json 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 }}">
<i class="bi bi-arrow-counterclockwise"></i> Undo last save
</button>
</form>
{% endif %}
<button type="button" class="btn btn-success btn-sm" id="drvReview" disabled>
<i class="bi bi-file-earmark-diff"></i> Review and save
</button>
{% endset %}
{% set header_subtitle = 'Deploy/Control/HardwareDriver.json' %}
{% include "_image_header.html" %}
<div class="text-muted drv-explain mb-3">
<p class="mb-2">
At imaging PESetup reads this map and takes the <strong>first</strong> entry, from the top,
that has a model token contained in the PC's WMI model (case-insensitive). Latitude, OptiPlex and
Precision PCs only look at entries whose family contains that word. <strong>There is no OS filter</strong>,
so a Windows 11 entry must sit above any Windows 10 entry for the same model.
A PC that matches nothing still images, with no drivers.
</p>
<p>
Edits, moves and removals are staged until <strong>Review and save</strong>, which shows every changed
entry first. The
<a href="{{ image_tab_url(image_type, 'os') }}">Hardware Models</a> list (user_selections.json) is the
Media Creator Lite build selection; PESetup does not read it.
</p>
</div>
{% if load_error %}
<div class="alert alert-danger"><i class="bi bi-x-octagon"></i> {{ load_error }}. The table is empty and saving is off until the file is fixed.</div>
{% endif %}
{% if hw_drivers_extra %}
<div class="alert alert-info"><i class="bi bi-info-circle"></i>
This image also has <span class="mono">Deploy/Control/hw_drivers.json</span>. PESetup never reads it; only the map below counts.
</div>
{% endif %}
{# ==================== Driver map ==================== #}
<div class="card" id="drvCard">
<div class="card-header">
<span class="d-flex align-items-center gap-2 flex-wrap">
<i class="bi bi-hdd-network"></i> Driver map
<span class="badge badge-secondary" id="drvCount">{{ entry_count }}</span>
<span class="drv-lint-badges" id="drvLintBadges"></span>
</span>
<div class="header-actions">
<button type="button" class="btn btn-sm btn-outline-primary" id="drvAdd" {% if load_error %}disabled{% endif %}>
<i class="bi bi-plus-lg"></i> Add entry
</button>
<button type="button" class="btn btn-sm btn-outline-primary"
data-bs-toggle="modal" data-bs-target="#driverUploadModal">
<i class="bi bi-upload"></i> Upload .zip
</button>
</div>
</div>
<div class="drv-toolbar">
<div class="drv-search">
<i class="bi bi-search"></i>
<input type="search" class="form-control form-control-sm" id="drvSearch"
placeholder="Search models, family, file, folder" aria-label="Search the driver map">
</div>
<div class="drv-seg" role="group" aria-label="Windows version" id="drvOsSeg">
<button type="button" data-os="" class="active">All</button>
<button type="button" data-os="Win11">Win11</button>
<button type="button" data-os="Win10">Win10</button>
</div>
<label class="drv-chip-toggle" id="drvMissingLbl"><input type="checkbox" id="drvMissing"> Zip missing <span id="drvMissingN"></span></label>
<label class="drv-chip-toggle" id="drvFindLbl"><input type="checkbox" id="drvFind"> Has findings <span id="drvFindN"></span></label>
<span class="drv-showing" id="drvShowing"></span>
</div>
<div class="table-container">
<table class="table mb-0 drv-table" id="drvTable">
<thead>
<tr>
<th title="Position: the first matching entry wins">#</th>
<th>Models and family</th>
<th>OS</th>
<th>Driver zip</th>
<th></th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
{# ==================== Unregistered zips ==================== #}
{# Zips no image uses come first and can be deleted. Zips another image's
map uses (the driver tree is shared) are folded away: they can be added
to this map but never deleted from here. #}
{% set free = orphans | selectattr('used_by', 'equalto', []) | list %}
{% set shared = orphans | rejectattr('used_by', 'equalto', []) | list %}
{% macro orphan_row(o, hidden=False) %}
<tr class="{{ 'orphan-shared' if o.used_by }}" {% if hidden %}style="display:none"{% endif %}>
<td>
{% if o.used_by %}
<input type="checkbox" disabled title="Used by {{ o.used_by | join(', ') }}: cannot be deleted from here" aria-label="In use">
{% else %}
<input type="checkbox" class="orphan-select" name="rel" value="{{ o.rel }}" aria-label="Select {{ o.name }}">
{% endif %}
</td>
<td>
<div class="mono" style="overflow-wrap:anywhere;">{{ o.name }}</div>
<div class="sub mono">{{ o.folder or '(top folder)' }}</div>
</td>
<td class="text-nowrap">
{{ (o.size or 0) | filesizeformat }}
<div class="sub">{{ o.mtime | local_dt if o.mtime else '' }}</div>
</td>
<td>
{% if o.used_by %}{% for u in o.used_by %}<span class="badge badge-info">{{ u }}</span> {% endfor %}
{% else %}<span class="text-muted small">No image</span>{% endif %}
</td>
<td class="text-end text-nowrap">
<button type="button" class="btn btn-sm btn-outline-primary orphan-add" {% if load_error %}disabled{% endif %}
data-name="{{ o.name }}" data-dest="{{ o.dest }}" data-size="{{ o.size or 0 }}">
<i class="bi bi-plus-lg"></i> Add to map
</button>
</td>
</tr>
{% endmacro %}
<div class="card" id="orphanCard">
<div class="card-header">
<span class="d-flex align-items-center gap-2">
<i class="bi bi-file-earmark-zip"></i> Unregistered zips
<span class="badge {{ 'badge-warning' if free else 'badge-secondary' }}" title="Zips no image uses">{{ free | length }}</span>
</span>
<span class="small text-muted fw-normal">Zips under Out-of-box Drivers that this map does not use</span>
</div>
{% if orphans %}
<form method="POST" action="{{ url_for('images_drivers.orphans_remove', image_type=image_type) }}"
onsubmit="return confirm('Delete the selected zip files from the share? This cannot be undone.');">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="table-container">
<table class="table mb-0 orphan-table">
<thead>
<tr>
<th style="width:30px"><input type="checkbox" id="orphanSelectAll" aria-label="Select all" {% if not free %}disabled{% endif %}></th>
<th>File</th>
<th>Size and date</th>
<th>Used by</th>
<th></th>
</tr>
</thead>
<tbody>
{% for o in free %}{{ orphan_row(o) }}{% endfor %}
{% if not free %}
<tr><td colspan="5" class="text-muted small">No zip here is unused: every one is in this map or in another image's map.</td></tr>
{% endif %}
{% if shared %}
<tr class="orphan-toggle-row">
<td colspan="5">
<button type="button" class="btn btn-link p-0" id="orphanSharedToggle" aria-expanded="false" style="font-size:13px;">
<i class="bi bi-chevron-right"></i> Show {{ shared | length }} zip{{ 's' if shared | length != 1 }} that other images (or an undo or rollback) use
</button>
</td>
</tr>
{% for o in shared %}{{ orphan_row(o, hidden=True) }}{% endfor %}
{% endif %}
</tbody>
</table>
</div>
<div class="px-3 py-2 d-flex flex-wrap gap-2 justify-content-between align-items-center">
<span class="small text-muted">Only zips no image uses can be deleted. Add to map stages a new entry; it is written with the next save.</span>
<button type="submit" class="btn btn-sm btn-outline-danger" {% if not free %}disabled title="No zip here is unused"{% endif %}>
<i class="bi bi-trash"></i> Delete selected
</button>
</div>
</form>
{% else %}
<div class="card-body text-muted small">None: every zip under Out-of-box Drivers is in this map.</div>
{% endif %}
</div>
{# ==================== Edit entry modal ==================== #}
<div class="modal fade" id="drvEditModal" tabindex="-1" aria-labelledby="drvEditTitle">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title" id="drvEditTitle">Edit entry</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" for="drvEdTokens">Model tokens (modelswminame), one per line</label>
<textarea class="form-control" id="drvEdTokens" rows="3" spellcheck="false"></textarea>
<div class="tok-preview" id="drvEdTokPreview"></div>
<div class="form-text">The entry matches when any token appears, as written, inside the PC's WMI model (case is ignored):
<span class="mono">OptiPlex 7020</span> matches the model <span class="mono">OptiPlex 7020</span> but not
<span class="mono">OptiPlex Micro 7020</span>. Saved comma-separated with no spaces, because PESetup does not trim.</div>
</div>
<div class="row g-3">
<div class="col-md-6 form-field mb-0">
<label class="form-label" for="drvEdFamily">Family</label>
<input type="text" class="form-control" id="drvEdFamily" list="drvFamilies">
<div class="form-text">For Latitude, OptiPlex and Precision PCs this must contain that word, or the entry is skipped.</div>
</div>
<div class="col-md-6 form-field mb-0">
<label class="form-label" for="drvEdFriendly">Friendly name (modelsfriendlyname)</label>
<input type="text" class="form-control" id="drvEdFriendly">
<div class="form-text">Shown in logs and on this page only.</div>
</div>
<div class="col-md-6 form-field mb-0">
<label class="form-label" for="drvEdOsId">osId</label>
<input type="text" class="form-control mono" id="drvEdOsId" placeholder="20,21">
<div class="form-text">18 = Windows 10; 20 = Win11 LTSC 24H2; 21 = Win11 Pro 24H2. Informational: not part of the match.</div>
</div>
<div class="col-md-6 form-field mb-0">
<label class="form-label" for="drvEdPos">Position</label>
<input type="number" class="form-control" id="drvEdPos" min="1">
<div class="form-text" id="drvEdPosHelp">1 is the top. The first match wins.</div>
</div>
</div>
<div class="form-field mt-3">
<label class="form-label" for="drvEdFile">Zip file name (fileName)</label>
<input type="text" class="form-control mono" id="drvEdFile" spellcheck="false">
</div>
<div class="form-field">
<label class="form-label" for="drvEdDir">Folder (destinationDir)</label>
<input type="text" class="form-control mono" id="drvEdDir" list="drvFolders" spellcheck="false">
<div class="zip-check" id="drvEdZip"></div>
</div>
<p class="small text-muted mb-0" id="drvEdNote">
url, hash and size describe the pack download (Updates, Dell drivers sets them) and are not changed here.
FileName, DestinationDir, models and aOsIds are kept in step with the fields above.
</p>
<div class="alert alert-danger d-none mt-3 mb-0" id="drvEdError"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="drvEdApply"><i class="bi bi-check-lg"></i> Apply</button>
</div>
</div>
</div>
</div>
<datalist id="drvFamilies"></datalist>
<datalist id="drvFolders"></datalist>
<!-- Driver upload modal -->
<div class="modal fade" id="driverUploadModal" tabindex="-1">
<div class="modal-dialog">
<form method="POST" action="{{ url_for('images_drivers_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 driver .zip</h3>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="form-field">
<label class="form-label">Driver .zip file</label>
<input type="file" class="form-control" name="driver_file" accept=".zip" required>
<div class="form-text">Lands in the top of <span class="mono">Out-of-box Drivers/</span> and shows under
Unregistered zips. Use Add to map there to give it an entry (reviewed with the next save).</div>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="overwrite" value="1" id="driverOverwriteCheck">
<label class="form-check-label" for="driverOverwriteCheck">Overwrite if same filename exists</label>
</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>
</div>
</div>
</form>
</div>
</div>
<script type="application/json" id="drvData">{{ page_json }}</script>
{% endblock %}
{% block extra_scripts %}
<script>
// Drivers tab. state.items holds the staged map in order; each item is
// {orig: position in the file or null, moved, data: the entry, row: server facts}.
(function () {
'use strict';
var DATA = JSON.parse(document.getElementById('drvData').textContent);
var URLS = DATA.urls;
// Key order of a new entry: the shape most entries in the GE maps have.
var NEW_KEYS = ['manufacturer', 'product', 'family', 'modelswminame', 'modelsfriendlyname', 'fileName',
'destinationDir', 'url', 'hash', 'size', 'modifiedDate', 'osId', 'imagedisk', 'models', 'FileName',
'DestinationDir', 'aOsIds'];
var state = {
items: DATA.entries.map(function (e, i) {
return { orig: i, moved: false, data: e, row: DATA.rows[i], status: null };
}),
counts: DATA.counts,
dirty: false
};
var filt = { q: '', os: '', missing: false, find: false };
var tbody = document.querySelector('#drvTable tbody');
var reviewBtn = document.getElementById('drvReview');
var head = document.querySelector('.image-head');
// ---- small helpers ----
function h(tag, attrs, kids) {
var e = document.createElement(tag);
Object.keys(attrs || {}).forEach(function (k) {
if (k === 'text') e.textContent = attrs[k];
else if (k === 'cls') e.className = attrs[k];
else e.setAttribute(k, attrs[k]);
});
(kids || []).forEach(function (c) {
if (c === null || c === undefined) return;
e.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
});
return e;
}
function fileOf(d) { return String(d.fileName || d.FileName || ''); }
function dirOf(d) { return String(d.destinationDir || d.DestinationDir || ''); }
function tokensOf(d) { return d.modelswminame === undefined || d.modelswminame === null ? [] : String(d.modelswminame).split(','); }
function shortDir(dir) { return dir.replace(/^\*destinationdir\*\\?/i, '').replace(/^Deploy\\Out-of-box Drivers\\?/i, ''); }
function fmtSize(n) {
if (n === null || n === undefined) return '';
if (n >= 1073741824) return (n / 1073741824).toFixed(2) + ' GB';
if (n >= 1048576) return (n / 1048576).toFixed(1) + ' MB';
return Math.round(n / 1024) + ' KB';
}
function fmtDate(ts) {
if (!ts) return '';
var d = new Date(ts * 1000);
function p(n) { return (n < 10 ? '0' : '') + n; }
return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + p(d.getHours()) + ':' + p(d.getMinutes());
}
// Same rule as services/image_pages.driver_os.
function osOf(d) {
var name = fileOf(d).toLowerCase(), dir = dirOf(d).toLowerCase();
if (name.indexOf('win10') === 0) return 'Win10';
if (name.indexOf('win11') === 0) return 'Win11';
if (dir.indexOf('\\dell_10\\') >= 0 || dir.indexOf('/dell_10/') >= 0) return 'Win10';
if (dir.indexOf('\\dell_11\\') >= 0 || dir.indexOf('/dell_11/') >= 0) return 'Win11';
var ids = String(d.osId || '').split(',').map(function (s) { return s.trim(); }).filter(Boolean);
if (!ids.length) return 'Other';
return ids.every(function (i) { return i === '18'; }) ? 'Win10' : 'Win11';
}
function zipBad(row) { return row && ['missing', 'trap', 'none'].indexOf(row.zip.status) >= 0; }
function copyText(text, btn) {
function done() { var i = btn.querySelector('i'); i.className = 'bi bi-check2'; setTimeout(function () { i.className = 'bi bi-clipboard'; }, 1200); }
// navigator.clipboard needs a secure context; the server is plain http.
if (navigator.clipboard && window.isSecureContext) { navigator.clipboard.writeText(text).then(done); return; }
var ta = h('textarea', { style: 'position:fixed;top:-100px;opacity:0' });
ta.value = text; document.body.appendChild(ta); ta.select();
try { document.execCommand('copy'); done(); } catch (e) { window.prompt('Copy:', text); }
document.body.removeChild(ta);
}
// ---- filters ----
function filtering() { return !!(filt.q || filt.os || filt.missing || filt.find); }
function visible(it) {
var d = it.data, r = it.row;
if (filt.os && osOf(d) !== filt.os) return false;
if (filt.missing && !zipBad(r)) return false;
if (filt.find && !(r && r.findings.length)) return false;
if (filt.q) {
var hay = [d.modelswminame, d.modelsfriendlyname, d.family, fileOf(d), dirOf(d), d.osId, d.product]
.join(' ').toLowerCase();
var words = filt.q.toLowerCase().split(/\s+/).filter(Boolean);
for (var i = 0; i < words.length; i++) if (hay.indexOf(words[i]) < 0) return false;
}
return true;
}
// ---- table ----
function render() {
tbody.innerHTML = '';
var n = state.items.length, shown = 0, f = filtering();
state.items.forEach(function (it, idx) {
if (!visible(it)) return;
shown++;
var d = it.data, r = it.row;
var wasMoved = it.moved && it.orig !== null && idx !== it.orig;
var cls = it.orig === null ? 'st-new' : (it.status === 'edited' ? 'st-edited' : (wasMoved ? 'st-moved' : ''));
var tr = h('tr', { cls: cls, 'data-idx': idx });
// position + move
var up = h('button', { type: 'button', cls: 'btn btn-outline-secondary act-up', title: f ? 'Clear the search and filters to reorder' : 'Move up', 'aria-label': 'Move up' }, [h('i', { cls: 'bi bi-arrow-up' })]);
var dn = h('button', { type: 'button', cls: 'btn btn-outline-secondary act-down', title: f ? 'Clear the search and filters to reorder' : 'Move down', 'aria-label': 'Move down' }, [h('i', { cls: 'bi bi-arrow-down' })]);
up.disabled = f || idx === 0; dn.disabled = f || idx === n - 1;
tr.appendChild(h('td', { cls: 'pos' }, [h('span', { cls: 'num', text: String(idx + 1) }), up, dn]));
// models + family
var toks = tokensOf(d).map(function (t) {
var bad = t === '' || t !== t.trim();
return h('span', { cls: 'tok' + (bad ? ' bad' : ''), title: bad ? 'Empty or untrimmed token' : 'Matches a WMI model containing this', text: t === '' ? '(empty)' : t });
});
var models = h('td', { cls: 'models' }, [h('div', {}, toks.length ? toks : [h('span', { cls: 'badge badge-danger', text: 'no modelswminame' })])]);
var famLine = [d.modelsfriendlyname ? h('strong', { text: d.modelsfriendlyname }) : null,
h('span', { text: (d.modelsfriendlyname ? ' | ' : '') + 'family: ' }), h('span', { text: d.family === undefined || d.family === null ? '(none)' : String(d.family) })];
models.appendChild(h('div', { cls: 'sub' }, famLine));
if (it.orig === null) models.appendChild(h('span', { cls: 'badge badge-info', text: 'New' }));
else if (it.status === 'edited') models.appendChild(h('span', { cls: 'badge badge-info', text: 'Edited' }));
if (wasMoved) models.appendChild(h('span', { cls: 'badge badge-secondary ms-1', text: 'Moved from #' + (it.orig + 1) }));
tr.appendChild(models);
// OS
var os = osOf(d);
tr.appendChild(h('td', { cls: 'os' }, [
h('span', { cls: 'badge ' + (os === 'Win11' ? 'badge-primary' : (os === 'Win10' ? 'badge-secondary' : 'badge-warning')), text: os }),
h('div', { cls: 'sub mono', title: 'osId', text: d.osId === undefined ? '' : String(d.osId) })
]));
// zip
var name = fileOf(d);
var copy = h('button', { type: 'button', cls: 'copy-btn act-copy', title: 'Copy file name', 'aria-label': 'Copy file name' }, [h('i', { cls: 'bi bi-clipboard' })]);
var zipTd = h('td', { cls: 'zipcell' }, [
h('div', {}, [h('span', { cls: 'fname', title: name, text: name || '(no fileName)' }), name ? copy : null]),
h('div', { cls: 'sub mono', title: dirOf(d), text: shortDir(dirOf(d)) || '(no destinationDir)' })
]);
var zline = h('div', { cls: 'sub' });
if (!r) zline.appendChild(h('span', { cls: 'text-muted', text: 'checking...' }));
else if (r.zip.status === 'ok' || r.zip.status === 'case') {
zline.appendChild(h('span', { text: fmtSize(r.zip.size) + (r.zip.mtime ? ', ' + fmtDate(r.zip.mtime) : '') }));
if (r.zip.status === 'case') zline.appendChild(h('span', { cls: 'badge badge-warning ms-1', title: 'On the share as ' + r.zip.actual, text: 'case differs' }));
} else if (r.zip.status === 'trap') {
zline.appendChild(h('span', { cls: 'badge badge-danger', title: 'On the share as ' + r.zip.actual, text: 'not found by WinPE' }));
} else {
zline.appendChild(h('span', { cls: 'badge badge-danger', text: r.zip.status === 'none' ? 'no file name' : 'zip missing' }));
}
if (r && r.dell) zline.appendChild(h('span', { cls: 'badge badge-info' + (zline.childNodes.length ? ' ms-1' : ''), title: 'Newer Dell pack in the last Updates scan', text: 'Dell ' + r.dell + ' available' }));
zipTd.appendChild(zline);
tr.appendChild(zipTd);
// actions
tr.appendChild(h('td', { cls: 'acts' }, [
h('button', { type: 'button', cls: 'btn btn-outline-primary btn-row-action act-edit', title: 'Edit this entry' }, [h('i', { cls: 'bi bi-pencil' }), ' Edit']),
h('button', { type: 'button', cls: 'btn btn-outline-danger btn-row-action act-remove', title: 'Remove this entry', 'aria-label': 'Remove' }, [h('i', { cls: 'bi bi-trash' })])
]));
tbody.appendChild(tr);
if (r && r.findings.length) {
var ul = h('ul');
r.findings.forEach(function (x) {
ul.appendChild(h('li', {}, [h('span', { cls: 'badge lvl-' + x.level, text: x.level + ': ' + x.label }), h('span', { text: x.message })]));
});
tbody.appendChild(h('tr', { cls: 'drv-find ' + cls, 'data-idx': idx }, [h('td'), h('td', { colspan: '4' }, [ul])]));
}
});
if (!shown) {
tbody.appendChild(h('tr', { cls: 'drv-empty' }, [h('td', { colspan: '5', text: n ? 'No entry matches the search and filters.' : 'The map is empty: no PC gets drivers.' })]));
}
document.getElementById('drvShowing').textContent = f ? ('Showing ' + shown + ' of ' + n) : (n + ' entries');
document.getElementById('drvCount').textContent = n;
var miss = state.items.filter(function (it) { return zipBad(it.row); }).length;
var withF = state.items.filter(function (it) { return it.row && it.row.findings.length; }).length;
document.getElementById('drvMissingN').textContent = '(' + miss + ')';
document.getElementById('drvFindN').textContent = '(' + withF + ')';
renderLint();
}
function renderLint() {
var box = document.getElementById('drvLintBadges');
box.innerHTML = '';
var c = state.counts || {};
var bad = (c.CRITICAL || 0) + (c.ERROR || 0);
if (c.CRITICAL) box.appendChild(h('span', { cls: 'badge lvl-CRITICAL', title: 'Show entries with findings', text: c.CRITICAL + ' critical' }));
if (c.ERROR) box.appendChild(h('span', { cls: 'badge lvl-ERROR', title: 'Show entries with findings', text: c.ERROR + ' error' + (c.ERROR === 1 ? '' : 's') }));
if (c.WARN) box.appendChild(h('span', { cls: 'badge lvl-WARN', title: 'Show entries with findings', text: c.WARN + ' warning' + (c.WARN === 1 ? '' : 's') }));
if (!bad && !c.WARN) box.appendChild(h('span', { cls: 'badge badge-success', text: 'lint clean' }));
}
// ---- staging ----
var checkTimer = null, checkSeq = 0;
function payload() {
return state.items.map(function (it) {
var o = {};
Object.keys(it.data).forEach(function (k) { o[k] = it.data[k]; });
if (it.orig !== null) o._orig = it.orig;
if (it.moved) o._moved = true;
return o;
});
}
function changed() {
// Rows from a check still in flight describe the old order: drop them.
checkSeq++;
state.dirty = true;
reviewBtn.disabled = false;
if (head) head.classList.add('dirty');
render();
clearTimeout(checkTimer);
checkTimer = setTimeout(recheck, 250);
}
function recheck() {
var seq = ++checkSeq;
PxeImage.postJSON(URLS.check, { entries: payload() }).then(function (r) {
if (seq !== checkSeq || !r.ok) return;
r.data.rows.forEach(function (row, i) { if (state.items[i]) state.items[i].row = row; });
state.counts = r.data.counts;
render();
});
}
function move(idx, to) {
if (to < 0 || to >= state.items.length || to === idx) return;
var it = state.items.splice(idx, 1)[0];
it.moved = true;
state.items.splice(to, 0, it);
changed();
}
// ---- table events ----
tbody.addEventListener('click', function (e) {
var btn = e.target.closest('button');
if (!btn) return;
var tr = btn.closest('tr');
var idx = parseInt(tr.getAttribute('data-idx'), 10);
var it = state.items[idx];
if (btn.classList.contains('act-up')) move(idx, idx - 1);
else if (btn.classList.contains('act-down')) move(idx, idx + 1);
else if (btn.classList.contains('act-copy')) copyText(fileOf(it.data), btn);
else if (btn.classList.contains('act-edit')) openEditor(idx);
else if (btn.classList.contains('act-remove')) {
var lbl = '#' + (idx + 1) + ' ' + (it.data.modelsfriendlyname || it.data.modelswminame || fileOf(it.data));
if (confirm('Remove ' + lbl + ' from the map? The zip stays on disk. Nothing is written until Review and save.')) {
state.items.splice(idx, 1);
changed();
}
}
});
// ---- filter events ----
var search = document.getElementById('drvSearch');
search.addEventListener('input', function () { filt.q = search.value.trim(); render(); });
document.querySelectorAll('#drvOsSeg button').forEach(function (b) {
b.addEventListener('click', function () {
document.querySelectorAll('#drvOsSeg button').forEach(function (x) { x.classList.toggle('active', x === b); });
filt.os = b.getAttribute('data-os');
render();
});
});
function bindToggle(id, key) {
var cb = document.getElementById(id);
cb.addEventListener('change', function () {
filt[key] = cb.checked;
cb.parentNode.classList.toggle('on', cb.checked);
render();
});
return cb;
}
var missCb = bindToggle('drvMissing', 'missing');
var findCb = bindToggle('drvFind', 'find');
document.getElementById('drvLintBadges').addEventListener('click', function (e) {
if (e.target.classList.contains('badge') && !findCb.checked) { findCb.checked = true; findCb.dispatchEvent(new Event('change')); }
});
// Deep links from the Overview: ?q=, ?os=Win10, ?missing=1, ?findings=1
(function () {
var p = new URLSearchParams(window.location.search);
if (p.get('q')) { search.value = p.get('q'); filt.q = search.value.trim(); }
// Only the two known values: anything else in a selector would throw and stop the page.
var osq = p.get('os');
if (osq === 'Win10' || osq === 'Win11') document.querySelector('#drvOsSeg button[data-os="' + osq + '"]').click();
if (p.get('missing') === '1') { missCb.checked = true; missCb.dispatchEvent(new Event('change')); }
if (p.get('findings') === '1') { findCb.checked = true; findCb.dispatchEvent(new Event('change')); }
})();
// ---- edit modal ----
var ed = {
modal: document.getElementById('drvEditModal'),
title: document.getElementById('drvEditTitle'),
tokens: document.getElementById('drvEdTokens'),
preview: document.getElementById('drvEdTokPreview'),
family: document.getElementById('drvEdFamily'),
friendly: document.getElementById('drvEdFriendly'),
osId: document.getElementById('drvEdOsId'),
pos: document.getElementById('drvEdPos'),
posHelp: document.getElementById('drvEdPosHelp'),
file: document.getElementById('drvEdFile'),
dir: document.getElementById('drvEdDir'),
zip: document.getElementById('drvEdZip'),
err: document.getElementById('drvEdError'),
apply: document.getElementById('drvEdApply'),
idx: null, fresh: null
};
DATA.families.forEach(function (f) { document.getElementById('drvFamilies').appendChild(h('option', { value: f })); });
DATA.folders.forEach(function (f) { document.getElementById('drvFolders').appendChild(h('option', { value: f })); });
function edTokens() {
return ed.tokens.value.split(/\r?\n|,/).map(function (t) { return t.trim(); }).filter(Boolean);
}
function edPreview() {
var t = edTokens();
ed.preview.innerHTML = '';
if (!t.length) ed.preview.appendChild(h('span', { cls: 'text-muted small', text: 'No tokens: an empty modelswminame matches EVERY model.' }));
t.forEach(function (x) { ed.preview.appendChild(h('span', { cls: 'tok', text: x })); });
}
var zipTimer = null;
function edZip() {
clearTimeout(zipTimer);
zipTimer = setTimeout(function () {
var probe = { fileName: ed.file.value.trim(), destinationDir: ed.dir.value.trim(), modelswminame: 'x', family: 'x' };
ed.zip.textContent = 'Looking for the zip...';
ed.zip.className = 'zip-check text-muted';
PxeImage.postJSON(URLS.check, { entries: [probe] }).then(function (r) {
if (!r.ok) return;
var z = r.data.rows[0].zip;
if (z.status === 'ok') { ed.zip.className = 'zip-check text-success'; ed.zip.textContent = 'On the share: ' + fmtSize(z.size) + ', ' + fmtDate(z.mtime); }
else if (z.status === 'case') { ed.zip.className = 'zip-check text-warning'; ed.zip.textContent = 'On the share with other letter case (' + z.actual + '); WinPE finds it.'; }
else if (z.status === 'trap') { ed.zip.className = 'zip-check text-danger'; ed.zip.textContent = 'Only at ' + z.actual + ': WinPE will not find it at this path.'; }
else if (z.status === 'none') { ed.zip.className = 'zip-check text-danger'; ed.zip.textContent = 'No file name.'; }
else { ed.zip.className = 'zip-check text-danger'; ed.zip.textContent = 'Not on the share: ' + z.rel; }
});
}, 250);
}
ed.tokens.addEventListener('input', edPreview);
ed.file.addEventListener('input', edZip);
ed.dir.addEventListener('input', edZip);
// Where a new entry goes by default: a Win11 entry above the first Win10
// one (first match wins), anything else at the end.
function defaultPos(d) {
if (osOf(d) === 'Win11') {
for (var i = 0; i < state.items.length; i++) if (osOf(state.items[i].data) === 'Win10') return i + 1;
}
return state.items.length + 1;
}
function openEditor(idx, fresh) {
ed.idx = idx; ed.fresh = fresh || null;
var d = fresh || state.items[idx].data;
ed.title.textContent = fresh ? 'New entry' : ('Edit entry #' + (idx + 1));
ed.tokens.value = tokensOf(d).join('\n');
ed.family.value = d.family || '';
ed.friendly.value = d.modelsfriendlyname || '';
ed.osId.value = d.osId === undefined ? '' : String(d.osId);
ed.file.value = fileOf(d);
ed.dir.value = dirOf(d);
var max = state.items.length + (fresh ? 1 : 0);
ed.pos.max = max;
ed.pos.value = fresh ? defaultPos(d) : idx + 1;
ed.posHelp.textContent = '1 is the top, ' + max + ' the bottom. The first match wins' + (fresh ? '; a Windows 11 entry defaults to just above the first Windows 10 entry.' : '.');
ed.err.classList.add('d-none');
edPreview(); edZip();
bootstrap.Modal.getOrCreateInstance(ed.modal).show();
}
function setKey(d, key, value) { d[key] = value; }
ed.apply.addEventListener('click', function () {
var toks = edTokens();
var fail = function (m) { ed.err.textContent = m; ed.err.classList.remove('d-none'); };
if (!toks.length) return fail('Give at least one model token. An empty modelswminame matches every PC.');
if (!ed.family.value.trim()) return fail('Give a family.');
if (!ed.file.value.trim()) return fail('Give the zip file name.');
if (!ed.dir.value.trim()) return fail('Give the folder (destinationDir).');
var max = parseInt(ed.pos.max, 10);
var pos = parseInt(ed.pos.value, 10);
if (!(pos >= 1 && pos <= max)) return fail('Position must be between 1 and ' + max + '.');
var src = ed.fresh || state.items[ed.idx].data;
var d = {};
Object.keys(src).forEach(function (k) { d[k] = src[k]; });
var osIds = ed.osId.value.split(',').map(function (s) { return s.trim(); }).filter(Boolean);
setKey(d, 'family', ed.family.value.trim());
setKey(d, 'modelswminame', toks.join(','));
if ('modelsfriendlyname' in d || ed.friendly.value.trim()) setKey(d, 'modelsfriendlyname', ed.friendly.value.trim());
if ('osId' in d || osIds.length) setKey(d, 'osId', osIds.join(','));
// Mirrored keys: both spellings are in the GE maps; keep them equal.
if ('fileName' in d || !('FileName' in d)) setKey(d, 'fileName', ed.file.value.trim());
if ('FileName' in d) setKey(d, 'FileName', ed.file.value.trim());
if ('destinationDir' in d || !('DestinationDir' in d)) setKey(d, 'destinationDir', ed.dir.value.trim());
if ('DestinationDir' in d) setKey(d, 'DestinationDir', ed.dir.value.trim());
if ('models' in d) setKey(d, 'models', d.modelswminame);
if ('aOsIds' in d) setKey(d, 'aOsIds', osIds);
if (ed.fresh) {
// product holds the Dell line in the GE maps (Latitude, OptiPlex, Precision).
var line = /precision/i.test(toks[0]) ? 'Precision' : /optiplex/i.test(toks[0]) ? 'OptiPlex'
: /latitude/i.test(toks[0]) ? 'Latitude' : '';
if (line && d.product === 'Dell') setKey(d, 'product', line);
state.items.splice(pos - 1, 0, { orig: null, moved: false, data: d, row: null, status: 'new' });
} else {
var it = state.items[ed.idx];
if (JSON.stringify(d) !== JSON.stringify(it.data)) {
it.data = d;
it.status = (it.orig !== null && JSON.stringify(d) === JSON.stringify(DATA.entries[it.orig])) ? null : 'edited';
}
if (pos - 1 !== ed.idx) {
state.items.splice(ed.idx, 1);
it.moved = true;
state.items.splice(pos - 1, 0, it);
}
}
bootstrap.Modal.getOrCreateInstance(ed.modal).hide();
changed();
});
function newEntry(fileName, dest, size) {
var d = {};
var os = /\\dell_10\\/i.test(dest + '\\') ? '18' : '20,21';
var defaults = { manufacturer: 'Dell', product: 'Dell', family: '', modelswminame: '', modelsfriendlyname: '',
fileName: fileName || '', destinationDir: dest || '', url: '', hash: '', size: size || 0,
modifiedDate: '0001-01-01T00:00:00', osId: os, imagedisk: 0, models: '', FileName: fileName || '',
DestinationDir: dest || '', aOsIds: os.split(',') };
NEW_KEYS.forEach(function (k) { d[k] = defaults[k]; });
return d;
}
document.getElementById('drvAdd').addEventListener('click', function () {
openEditor(null, newEntry('', '', 0));
});
document.querySelectorAll('.orphan-add').forEach(function (b) {
b.addEventListener('click', function () {
openEditor(null, newEntry(b.getAttribute('data-name'), b.getAttribute('data-dest'), parseInt(b.getAttribute('data-size'), 10) || 0));
});
});
var shTog = document.getElementById('orphanSharedToggle');
if (shTog) shTog.addEventListener('click', function () {
var open = shTog.getAttribute('aria-expanded') !== 'true';
shTog.setAttribute('aria-expanded', open ? 'true' : 'false');
shTog.querySelector('i').className = 'bi ' + (open ? 'bi-chevron-down' : 'bi-chevron-right');
document.querySelectorAll('.orphan-shared').forEach(function (tr) { tr.style.display = open ? '' : 'none'; });
});
var selAll = document.getElementById('orphanSelectAll');
if (selAll) selAll.addEventListener('change', function () {
document.querySelectorAll('.orphan-select').forEach(function (cb) { cb.checked = selAll.checked; });
});
// ---- review and save (shared modal from _image_header.html) ----
function review() {
var modalEl = document.getElementById('imageReviewModal');
var body = document.getElementById('imageReviewBody');
var err = document.getElementById('imageReviewError');
var ok = document.getElementById('imageReviewConfirm');
document.getElementById('imageReviewTitle').textContent = 'Review changes: HardwareDriver.json';
body.innerHTML = '<div class="text-muted py-3">Building the preview...</div>';
err.classList.add('d-none');
ok.disabled = true;
bootstrap.Modal.getOrCreateInstance(modalEl).show();
function fail(m) { err.textContent = m; err.classList.remove('d-none'); }
var req = { entries: payload(), base: DATA.base };
PxeImage.postJSON(URLS.preview, req).then(function (r) {
if (!r.ok) { body.innerHTML = ''; fail(r.data.error || 'Preview failed.'); return; }
var p = r.data;
body.innerHTML = '';
var s = p.summary;
var sum = h('div', { cls: 'rv-summary' });
[['edited', 'badge-info'], ['moved', 'badge-secondary'], ['added', 'badge-success'], ['removed', 'badge-danger']].forEach(function (k) {
if (s[k[0]]) sum.appendChild(h('span', { cls: 'badge badge-lg ' + k[1], text: s[k[0]] + ' ' + k[0] }));
});
var fileChanged = p.files[0] && p.files[0].changed;
if (!fileChanged) {
body.appendChild(h('div', { cls: 'alert alert-info mb-0', text: 'No changes: the file already holds exactly this.' }));
return;
}
body.appendChild(sum);
// lint before and after
var lb = p.lint.before, la = p.lint.after;
function lc(c) { return c.CRITICAL + ' critical, ' + c.ERROR + ' errors, ' + c.WARN + ' warnings'; }
var lint = h('div', { cls: 'rv-lint' }, [h('strong', { text: 'Lint: ' }), h('span', { text: 'now ' + lc(lb) + '; after this save ' + lc(la) + '.' })]);
if (la.CRITICAL + la.ERROR > lb.CRITICAL + lb.ERROR) {
var ul = h('ul');
p.lint.after_errors.slice(0, 8).forEach(function (m) { ul.appendChild(h('li', { text: m })); });
lint.appendChild(ul);
}
body.appendChild(lint);
if (p.blocked.length) {
body.appendChild(h('div', { cls: 'alert alert-danger' }, [h('strong', { text: 'Cannot save: ' }),
h('span', { text: 'this adds a CRITICAL finding, which breaks imaging for many or all PCs. ' + p.blocked[0] })]));
}
// moved-only entries as one list, the rest as JSON diffs
var moves = p.changes.filter(function (c) { return c.kind === 'moved'; });
if (moves.length) {
body.appendChild(h('div', { cls: 'fw-semibold small mb-1', text: 'Moved (content unchanged)' }));
var ml = h('ul', { cls: 'rv-moves' });
moves.forEach(function (c) {
ml.appendChild(h('li', {}, [h('span', { cls: 'mono', text: '#' + c.old_pos + ' to #' + c.new_pos }),
' ' + c.label.replace(/^#\d+ /, ''), h('span', { cls: 'text-muted mono', text: c.file ? ' ' + c.file : '' })]));
});
body.appendChild(ml);
}
var diffs = p.changes.filter(function (c) { return c.kind !== 'moved'; }).map(function (c) {
var what = c.kind === 'added' ? 'new at #' + c.new_pos
: c.kind === 'removed' ? 'removed from #' + c.old_pos
: c.kind === 'edited+moved' ? 'edited, moved #' + c.old_pos + ' to #' + c.new_pos
: 'edited (' + c.fields.join(', ') + ')';
return { name: c.label.replace(/^#\d+ /, '') + ': ' + what, changed: true, existed: true, diff: c.diff, reformat: false };
});
if (diffs.length) {
var dbox = h('div');
body.appendChild(dbox);
PxeImage.renderDiff(dbox, diffs);
}
var full = h('details', { cls: 'rv-full' }, [h('summary', { text: 'Whole-file diff of HardwareDriver.json' })]);
var fbox = h('div');
full.appendChild(fbox);
body.appendChild(full);
PxeImage.renderDiff(fbox, p.files);
var expect = {};
p.files.forEach(function (f) { expect[f.name] = f.sha_before; });
ok.disabled = p.blocked.length > 0;
ok.onclick = function () {
ok.disabled = true;
PxeImage.postJSON(URLS.save, { entries: req.entries, base: req.base, expect: expect }).then(function (sres) {
if (!sres.ok) { fail(sres.data.error || 'Save failed.'); ok.disabled = !!sres.data.stale; return; }
state.dirty = false;
window.location.reload();
}).catch(function (e) { fail('Save failed: ' + e); ok.disabled = false; });
};
}).catch(function (e) { body.innerHTML = ''; fail('Preview failed: ' + e); });
}
reviewBtn.addEventListener('click', review);
window.addEventListener('beforeunload', function (e) {
if (state.dirty) { e.preventDefault(); e.returnValue = ''; }
});
render();
})();
</script>
{% endblock %}

View File

@@ -0,0 +1,399 @@
{% extends "base.html" %}
{% block title %}{{ friendly_name }} - OS and Packages{% endblock %}
{% 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; }
</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>
</form>
{% 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 %}
{% include "_image_header.html" %}
<p class="text-muted osp-explain">
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.
</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>
</div>
<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>
{# ==================== 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>
</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="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>
{# ==================== 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>
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">
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>.
</p>
</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">
{% 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>
{% else %}
<span class="badge badge-secondary">None</span>
{% 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>
</div>
</div>
<!-- Package upload modal (unchanged from the old Configuration page) -->
<div class="modal fade" id="packageUploadModal" tabindex="-1">
<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>
</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>
</div>
<div class="form-field">
<label class="form-label">Destination directory (optional)</label>
<input type="text" class="form-control mono" name="destination_dir"
placeholder="*destinationdir*\Packages">
<div class="form-text">Setting this also appends an entry to <code>packages.json</code>.</div>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="overwrite" value="1" id="pkgOverwriteCheck">
<label class="form-check-label" for="pkgOverwriteCheck">Overwrite if same filename exists</label>
</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>
</div>
</div>
</form>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
// OS and Packages: stage edits in the tables, then Review and save posts
// only the sections that changed (views/images.py os_preview / os_save).
(function () {
var dirty = {};
var reviewBtn = document.getElementById('ospReview');
var head = document.querySelector('.image-head');
function markDirty(section) {
dirty[section] = true;
reviewBtn.disabled = false;
if (head) head.classList.add('dirty');
}
function rowsOf(tableId) {
return Array.prototype.slice.call(
document.querySelectorAll('#' + tableId + ' tbody tr:not(.empty-row)'));
}
function baseObject(row) {
try { return JSON.parse(row.getAttribute('data-json') || '{}'); } catch (e) { return {}; }
}
function refreshEmpty(table) {
var empty = table.querySelector('.empty-row');
if (!empty) return;
empty.style.display = table.querySelectorAll('tbody tr:not(.empty-row)').length ? 'none' : '';
}
// Each card carries data-section; any edit inside it marks that section.
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('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.
if (e.target.closest('.remove-row')) {
markDirty(section);
var table = e.target.closest('table');
setTimeout(function () { refreshEmpty(table); }, 0);
}
});
});
var addBtn = document.getElementById('ospAddModel');
if (addBtn) {
addBtn.addEventListener('click', function () {
var tbody = document.querySelector('#ospModelTable tbody');
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>';
tbody.insertBefore(tr, tbody.querySelector('.empty-row'));
rowsOf('ospModelTable').forEach(function (r, i) { r.querySelector('.order-num').textContent = i + 1; });
refreshEmpty(tbody.closest('table'));
markDirty('hardware_models');
tr.querySelector('input').focus();
});
}
function collect() {
var body = {};
if (dirty.operating_systems) {
body.operating_systems = rowsOf('ospOsTable').map(baseObject);
}
if (dirty.packages) {
// "enabled" keeps the file's own type: the MCL packages.json uses 1/0
// (PESetup reads it as a number), ours true/false. A row whose switch
// was not touched is sent exactly as it came.
var rows = rowsOf('ospPkgTable');
var numeric = rows.some(function (row) { return typeof baseObject(row).enabled === 'number'; });
body.packages = rows.map(function (row) {
var obj = baseObject(row);
var sw = row.querySelector('.pkg-enabled');
if (sw && sw.checked !== sw.defaultChecked) {
var asNumber = typeof obj.enabled === 'number' || (obj.enabled === undefined && numeric);
obj.enabled = asNumber ? (sw.checked ? 1 : 0) : sw.checked;
}
return obj;
});
}
if (dirty.hardware_models) {
// Keep each row's other keys and key order; only Model/Id change.
body.hardware_models = rowsOf('ospModelTable').map(function (row) {
var obj = row.getAttribute('data-json') ? baseObject(row) : { Id: '', Model: '' };
row.querySelectorAll('[data-field]').forEach(function (inp) {
obj[inp.getAttribute('data-field')] = inp.value.trim();
});
return obj;
});
}
return body;
}
reviewBtn.addEventListener('click', function () {
PxeImage.reviewAndSave({
title: 'Review changes: OS and Packages',
previewUrl: '{{ url_for("images.os_preview", image_type=image_type) }}',
saveUrl: '{{ url_for("images.os_save", image_type=image_type) }}',
body: collect(),
onSaved: function () { dirty = {}; window.location.reload(); }
});
});
window.addEventListener('beforeunload', function (e) {
if (Object.keys(dirty).length) { e.preventDefault(); e.returnValue = ''; }
});
})();
</script>
{% endblock %}

View File

@@ -0,0 +1,261 @@
{% extends "base.html" %}
{% block title %}{{ friendly_name }} - Overview{% endblock %}
{% 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;
}
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>
{% 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 #}
{% set bad_lint = lint.map_errors if lint.available else 0 %}
{% set os_row = ov.os.current %}
{# -------- 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 -------- #}
{% 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 %}
<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>
{% 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>
{% 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>
{% endif %}
</div>
{# -------- 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>
</div>
{% 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>
{% 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 %}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,398 @@
{% extends "base.html" %}
{% block title %}Blancco Updates - PXE Server 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 %}
{% 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 %}
</div>
</div>
<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>
{# ---- 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>
{% 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>
<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>
{% elif job.state == 'blocked' %}
<div class="alert alert-warning mb-2"><strong>Staging stopped: preferences need merging.</strong> {{ job.message }}</div>
{% elif job.state == 'cancelled' %}
<div class="alert alert-warning mb-2">Staging was cancelled.</div>
{% else %}
<div class="alert alert-danger mb-2">{{ 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>
</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>
</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>
</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>
{# ---- 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>
{% endif %}
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted mb-0">Nothing staged.</p>
{% endif %}
</div>
{# ---- 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
<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>
{% 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>
{# ---- 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>
</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>
{# ---- 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>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
(function () {
var csrf = document.querySelector('meta[name="csrf-token"]').content;
// Upload: stream the raw file (no multipart parse, no copy in /tmp) with a progress bar.
var form = document.getElementById('blanccoUpload');
form.addEventListener('submit', function (ev) {
var input = document.getElementById('blanccoFile');
if (!input.files.length || !window.XMLHttpRequest) { return; }
ev.preventDefault();
var file = input.files[0];
var box = document.getElementById('blanccoUploadProgress');
var bar = document.getElementById('blanccoUploadBar');
var text = document.getElementById('blanccoUploadText');
var xhr = new XMLHttpRequest();
xhr.open('POST', form.action + '?name=' + encodeURIComponent(file.name));
xhr.setRequestHeader('X-CSRF-Token', csrf);
xhr.setRequestHeader('Content-Type', 'application/octet-stream');
xhr.upload.onprogress = function (e) {
if (!e.lengthComputable) { return; }
var pct = 100 * e.loaded / e.total;
bar.style.width = pct.toFixed(1) + '%';
text.textContent = 'Uploading ' + file.name + ': ' + Math.round(e.loaded / 1048576) + ' of ' +
Math.round(e.total / 1048576) + ' MB';
};
xhr.onload = function () {
var r = {};
try { r = JSON.parse(xhr.responseText); } catch (e) {}
if (xhr.status === 200 && r.ok) { location.reload(); return; }
text.textContent = 'Upload failed: ' + (r.error || ('HTTP ' + xhr.status));
bar.classList.add('bg-danger');
};
xhr.onerror = function () { text.textContent = 'Upload failed: connection lost.'; bar.classList.add('bg-danger'); };
box.style.display = '';
form.querySelector('button[type=submit]').disabled = true;
xhr.send(file);
});
// Follow a running staging job; reload when it ends.
var job = document.getElementById('blanccoJob');
if (!job || job.dataset.state !== 'running') { return; }
var URL = '{{ url_for("blancco_update.status") }}';
function tick() {
fetch(URL, { credentials: 'same-origin' })
.then(function (r) { return r.json(); })
.then(function (d) {
if (d.state !== 'running') { location.reload(); return; }
document.getElementById('blanccoJobStep').textContent = d.step || '';
document.getElementById('blanccoJobMsg').textContent = d.message || '';
document.getElementById('blanccoJobBar').style.width = (d.percent || 0) + '%';
var log = document.getElementById('blanccoJobLog');
if (log && d.log) { log.textContent = d.log.join('\n'); }
setTimeout(tick, 2000);
})
.catch(function () { setTimeout(tick, 5000); });
}
setTimeout(tick, 1500);
})();
</script>
{% endblock %}

View File

@@ -0,0 +1,556 @@
{% extends "base.html" %}
{% block title %}BPRT Sync - PXE Server 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 -%}
{%- endmacro %}
{# Long package names: allow a line break after each underscore, nowhere else. #}
{% macro fname(n) -%}
{%- 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 %}
{%- 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>
{% elif job.state in ('error', 'interrupted') %}
<div class="alert alert-danger">
<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 %}
</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>
{% 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>
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>
</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; }
</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>
</div>
</div>
<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 %}
{{ 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
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) }}">
<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>
</form>
</div>
{% 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>
{% endif %}
{% if res.stale %}<div class="alert alert-warning">{{ 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>
{% 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>
<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>
</div>
{% else %}<p class="text-muted mb-0">No package changes.</p>{% endif %}
</div>
{% 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>
</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>
{% endif %}
<div class="section-card">
<div class="section-title"><i class="bi bi-list-ul"></i> Files</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>
{% 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>
{% endif %}
<div class="section-card">
<div class="section-title"><i class="bi bi-rocket-takeoff"></i> Activate</div>
{{ 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">
<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.');">
<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>
</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>
</form>
<span class="small text-muted align-self-center">Validated {{ res.validated | local_dt }}.</span>
</div>
</div>
{% 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>
{% 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>
</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>
{{ imaging_alert(imaging) }}
{{ job_panel(job) }}
{# ---- Live folder --------------------------------------------------------- #}
<div class="section-card">
<div class="section-title"><i class="bi bi-folder-check"></i> Live BPRT</div>
{% if not live.exists %}
<div class="alert alert-danger mb-0"><span class="mono">{{ live.path }}</span> does not exist.</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>
{% 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>
{% 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>
{# ---- 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>
</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>
{# ---- 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>, or from the pxe repo, <span class="mono">scripts/windows/</span>)
{% else %}from the pxe repo, <span class="mono">scripts/windows/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
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>
{# ---- 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">
<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>
<button type="submit" class="pxe-btn pxe-btn-primary" {% if job.running %}disabled{% endif %}><i class="bi bi-upload"></i> 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>
{% 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>
</div>
{% endif %}
</div>
{# ---- 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>
{% 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 %}
{% endif %}
</div>
{% endblock %}
{% block extra_scripts %}
<script>
(function () {
var csrf = document.querySelector('meta[name="csrf-token"]').content;
// Zip upload: stream the raw file (no multipart parse, no copy in /tmp) with a progress bar.
var form = document.getElementById('bprtUpload');
if (form) {
form.addEventListener('submit', function (ev) {
var input = document.getElementById('bprtZip');
if (!input.files.length || !window.XMLHttpRequest) { return; }
ev.preventDefault();
var file = input.files[0];
var box = document.getElementById('bprtUploadProgress');
var bar = document.getElementById('bprtUploadBar');
var text = document.getElementById('bprtUploadText');
var xhr = new XMLHttpRequest();
xhr.open('POST', form.action + '?name=' + encodeURIComponent(file.name));
xhr.setRequestHeader('X-CSRF-Token', csrf);
xhr.setRequestHeader('Content-Type', 'application/octet-stream');
xhr.upload.onprogress = function (e) {
if (!e.lengthComputable) { return; }
bar.style.width = (100 * e.loaded / e.total).toFixed(1) + '%';
text.textContent = 'Uploading ' + file.name + ': ' + Math.round(e.loaded / 1048576) + ' of ' +
Math.round(e.total / 1048576) + ' MB';
};
xhr.onload = function () {
var r = {};
try { r = JSON.parse(xhr.responseText); } catch (e) {}
if (xhr.status === 200 && r.ok) { location.reload(); return; }
text.textContent = 'Upload failed: ' + (r.error || ('HTTP ' + xhr.status));
bar.classList.add('bg-danger');
form.querySelector('button[type=submit]').disabled = false;
};
xhr.onerror = function () { text.textContent = 'Upload failed: connection lost.'; bar.classList.add('bg-danger'); };
box.style.display = '';
form.querySelector('button[type=submit]').disabled = true;
xhr.send(file);
});
}
// Follow a running job; reload when it ends.
var job = document.getElementById('bprtJob');
if (!job) { return; }
var URL = '{{ url_for("bprt_sync.status") }}';
function tick() {
fetch(URL, { credentials: 'same-origin' })
.then(function (r) { return r.json(); })
.then(function (d) {
if (!d.running) { location.reload(); return; }
document.getElementById('bprtJobStep').textContent = d.step || '';
document.getElementById('bprtJobCurrent').textContent = d.current || '';
if (d.percent != null) {
document.getElementById('bprtJobBar').style.width = d.percent + '%';
document.getElementById('bprtJobPct').textContent = d.percent + '%';
}
setTimeout(tick, 2000);
})
.catch(function () { setTimeout(tick, 5000); });
}
setTimeout(tick, 1500);
})();
</script>
{% endblock %}

View File

@@ -0,0 +1,441 @@
{% extends "base.html" %}
{% block title %}Dell Drivers and BIOS - PXE Server 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 %}
{%- 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 %}
{%- 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 %}
{%- 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; }
</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 %}
{% if prog.check_running %}
<form method="post" action="{{ url_for('dell_updates.cancel') }}" style="display:inline;">
<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>
</form>
{% else %}
<form method="post" action="{{ url_for('dell_updates.check') }}" style="display:inline;">
<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>
</form>
{% endif %}
</div>
</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>
<div class="small text-muted">
{% set cats = scan.catalogs or {} %}
Driver pack catalog dated <span class="mono">{{ cats.drivers | local_dt }}</span>,
catalog index dated <span class="mono">{{ cats.index | local_dt }}</span>.
Maps read: {{ (scan.images or []) | join(', ') or 'none' }}.
{% if scan.cab_errors %}<span class="text-danger">{{ scan.cab_errors | length }} per-model catalog(s) failed to load.</span>{% endif %}
</div>
{% for e in scan.map_errors %}<div class="small text-danger"><i class="bi bi-exclamation-triangle"></i> {{ e }}</div>{% endfor %}
{% elif not prog.check_running %}
<p class="text-muted mb-0">Not checked yet. <strong>Check for updates</strong> fetches Dell's two catalog files (under 1 MB)
and the small per-model BIOS catalogs it needs, then compares.</p>
{% endif %}
</div>
<div class="col-lg-4 d-flex justify-content-lg-end">
<form method="post" action="{{ url_for('dell_updates.save_settings') }}" class="d-flex gap-2 align-items-end">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="form-field mb-0">
<label for="maxMbps" class="form-label">Speed cap (MB/s)</label>
<input type="number" class="form-control form-control-sm" id="maxMbps" name="max_mbps"
min="0" max="1000" step="0.5" value="{{ '%g'|format(settings.max_mbps) }}" style="width: 7rem;">
</div>
<button type="submit" class="pxe-btn pxe-btn-ghost pxe-btn-sm" title="Shared by every download on the Updates pages; 0 = no cap. The site link is about 400 Mbps (50 MB/s).">Save</button>
</form>
</div>
</div>
</div>
{% if scan %}
<form method="post" action="{{ url_for('dell_updates.download') }}" id="dlForm"
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>
</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>
{# ---- 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>
</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>
</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>
</form>
{# ---- 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>
{% 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 %}
</div>
{% 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>
</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') }}"
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>
</div>
</form>
{% else %}
<p class="text-muted mb-0">Nothing staged. Tick rows above and use <strong>Download selected</strong>.</p>
{% endif %}
</div>
{# ---- 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>
{% 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>
{% 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>
{% 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"
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>
</form>
</div>
{% 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>
</div>
</div>
{% 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 %}
<script>
// Poll the background jobs; update progress in place, reload when they end.
(function () {
var URL = "{{ url_for('dell_updates.status') }}";
// A preview is a POST response: reloading it would resubmit the form,
// so there the poller only stops when the jobs end.
var RELOAD = {{ 'false' if preview else 'true' }};
function tick() {
fetch(URL, { credentials: 'same-origin' })
.then(function (r) { return r.json(); })
.then(function (j) {
if (!j.check_running && !j.download_running) {
if (RELOAD) location.replace("{{ url_for('dell_updates.page') }}");
return;
}
var step = document.getElementById('checkStep');
if (step && j.check.step) step.textContent = j.check.step + '...';
Object.keys(j.download.items || {}).forEach(function (k) {
var it = j.download.items[k], cell = document.getElementById('st-' + k);
if (!cell) return;
var 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');
if (bar && it.percent != null) bar.style.width = it.percent + '%';
if (pct && it.percent != null) pct.textContent = it.percent + '%';
});
setTimeout(tick, 3000);
})
.catch(function () { setTimeout(tick, 6000); });
}
setTimeout(tick, 2000);
})();
</script>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,345 @@
{% extends "base.html" %}
{% block title %}iPXE Boot Files - PXE Server 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 %}
{%- 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>
{% 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>
{% endfor %}
</details>
{% 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 %}
{%- endmacro %}
{% 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;">
<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>
</form>
</div>
</div>
<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>
{# ---- 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 %}
</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>
</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 %}</pre>
</details>
{% else %}
<p class="text-muted mb-0">No dhcp-boot lines found in the dnsmasq configuration.</p>
{% endif %}
</div>
{# ---- 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>
</div>
{% else %}
<p class="text-muted mb-0">No backups yet. One is made automatically before every install and rollback.</p>
{% endif %}
</div>
{# ---- 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
<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
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="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>:
<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
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
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)
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
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
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="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">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>
{% if job.state in ('queued', 'running') %}
<script>
// Poll the background download; reload once it finishes to show the result.
(function () {
var URL = "{{ url_for('ipxe.status') }}";
var text = document.getElementById('ipxeJobText');
var bar = document.getElementById('ipxeJobBar');
function tick() {
fetch(URL, { credentials: 'same-origin' })
.then(function (r) { return r.json(); })
.then(function (j) {
if (j.state !== 'queued' && j.state !== 'running') { location.reload(); return; }
var step = j.step === 'download' ? 'Downloading' : j.step === 'verify' ? 'Verifying' : 'Starting';
if (text) text.textContent = step + ' ' + (j.tag || '') + (j.step === 'download' && j.percent != null ? ' (' + j.percent + '%)' : '') + '...';
if (bar) bar.style.width = (j.step === 'verify' ? 100 : (j.percent || 0)) + '%';
setTimeout(tick, 2000);
})
.catch(function () { setTimeout(tick, 5000); });
}
setTimeout(tick, 1500);
})();
</script>
{% endif %}
{% endblock %}