Files
pxe-server/webapp/templates/image_drivers.html
cproudlock 397063f987 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.
2026-10-13 16:58:55 -04:00

894 lines
47 KiB
HTML

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