Files
pxe-server/webapp/templates/updates_bprt.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

667 lines
43 KiB
HTML

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