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

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

365 lines
20 KiB
HTML

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