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

557 lines
33 KiB
HTML

{% extends "base.html" %}
{% block title %}BPRT Sync - PXE Server Manager{% endblock %}
{% macro size(n) -%}
{%- if n is none -%}-{%- elif n >= 1000000000 -%}{{ '%.2f' | format(n / 1000000000) }} GB{%- elif n >= 1000000 -%}{{ '%.0f' | format(n / 1000000) }} MB{%- elif n >= 1000 -%}{{ '%.0f' | format(n / 1000) }} KB{%- else -%}{{ n }} B{%- endif -%}
{%- endmacro %}
{# Long package names: allow a line break after each underscore, nowhere else. #}
{% macro fname(n) -%}
{%- for part in n.split('_') %}{{ part }}{% if not loop.last %}_<wbr>{% endif %}{% endfor -%}
{%- endmacro %}
{% macro val_badge(v, lg=false) -%}
{% set cls = 'badge badge-lg' if lg else 'badge' %}
{% if not v %}<span class="{{ cls }} badge-secondary">Not validated</span>
{% elif v.stale %}<span class="{{ cls }} badge-warning" title="{{ v.stale }}">Changed since validation</span>
{% elif v.state == 'valid' %}<span class="{{ cls }} badge-success">Valid</span>
{% else %}<span class="{{ cls }} badge-danger">{{ v.errors | length }} error{{ 's' if v.errors | length != 1 }}</span>{% endif %}
{%- endmacro %}
{% macro job_panel(job) -%}
{% if job.running %}
<div class="section-card" id="bprtJob" data-running="1">
<div class="section-title"><span class="spinner-border spinner-border-sm"></span>
{{ {'manifest': 'Hashing the live BPRT', 'validate': 'Validating', 'activate': 'Activating', 'unzip': 'Unpacking the zip'}.get(job.kind, job.kind) }}
{% if job.arg %}<span class="mono small">{{ job.arg }}</span>{% endif %}</div>
<div class="small mb-1"><span id="bprtJobStep">{{ job.step or 'Starting' }}</span> <span id="bprtJobCurrent" class="mono text-muted">{{ job.current or '' }}</span></div>
<div class="progress" style="height: 0.5rem;"><div id="bprtJobBar" class="progress-bar" style="width: {{ job.percent or 0 }}%;"></div></div>
<div class="small text-muted mt-1" id="bprtJobPct">{% if job.percent is not none %}{{ job.percent }}%{% endif %}</div>
</div>
{% elif job.state in ('error', 'interrupted') %}
<div class="alert alert-danger">
<strong>The last BPRT {{ job.kind }} job {{ 'failed' if job.state == 'error' else 'stopped before it finished' }}</strong>
{% if job.arg %}(<span class="mono">{{ job.arg }}</span>){% endif %}{% if job.finished %}, {{ job.finished | local_dt }}{% endif %}{% if job.error %}: {{ job.error }}{% endif %}
{% if job.kind == 'activate' %}<div class="small mt-1">The live BPRT is only replaced by the last step (two renames), so it is either the old folder or the new one, never a half-built one.</div>{% endif %}
</div>
{% elif job.state == 'done' and job.result %}
<div class="small text-muted mb-3"><i class="bi bi-check2-circle"></i> Last job: {{ job.kind }}{% if job.arg %} <span class="mono">{{ job.arg }}</span>{% endif %}, {{ job.result }}{% if job.finished %} ({{ job.finished | local_dt }}){% endif %}.</div>
{% endif %}
{%- endmacro %}
{% macro imaging_alert(imaging) -%}
{% if imaging %}
<div class="alert alert-warning">
<strong><i class="bi bi-exclamation-triangle"></i> {{ imaging | length }} PC{{ 's are' if imaging | length != 1 else ' is' }} imaging now.</strong>
PESetup copies BPRT to each PC while it images; a PC copying during the swap can end up with a mix of old and new files.
Activate or roll back when these have finished:
{% for s in imaging[:8] %}<span class="mono">{{ s.hostname or s.id }}</span>{% if s.stage %} ({{ s.stage }}){% endif %}{% if not loop.last %}, {% endif %}{% endfor %}{% if imaging | length > 8 %} and {{ imaging | length - 8 }} more{% endif %}.
<a href="{{ url_for('imaging_dashboard') }}">Imaging progress</a>
</div>
{% endif %}
{%- endmacro %}
{% block extra_head %}
<style>
.bprt td.wrap { white-space: normal; word-break: break-word; min-width: 8rem; }
.bprt .file-list { columns: 2 22rem; column-gap: 2rem; margin: 0.25rem 0 0; padding-left: 1.1rem; }
.bprt .file-list li { break-inside: avoid; }
.bprt details > summary { cursor: pointer; }
.bprt .fname { word-break: normal; overflow-wrap: normal; }
.bprt .msg-list { margin: 0; padding-left: 1.1rem; }
.bprt .msg-list li { margin-bottom: 0.25rem; }
.bprt pre.cmd { white-space: pre-wrap; word-break: break-all; margin: 0.25rem 0 0.75rem; }
</style>
{% endblock %}
{% block content %}
<div class="bprt">
{% if detail %}
{# ======================= one incoming batch ============================== #}
{% set b = detail %}
<div class="page-header">
<h1><i class="bi bi-inboxes"></i> Incoming batch <span class="mono">{{ b.name }}</span></h1>
<div class="header-actions">
{{ val_badge(res, lg=true) }}
<a href="{{ url_for('bprt_sync.page') }}" class="pxe-btn pxe-btn-ghost"><i class="bi bi-arrow-left"></i> BPRT Sync</a>
</div>
</div>
<p class="text-muted">
{% if b.sync %}From <strong>{{ b.sync.computer or 'unknown PC' }}</strong>{% if b.sync.user %} ({{ b.sync.user }}){% endif %}{% if b.sync.created %}, {{ b.sync.created | local_dt }}{% endif %}.
{% if b.sync.source %}Source <span class="mono">{{ b.sync.source }}</span>.{% endif %}
{{ b.sync.listed }} vendor files listed, {{ b.files }} uploaded ({{ size(b.bytes) }}).{% endif %}
Folder <span class="mono">{{ incoming }}/{{ b.name }}</span>.
</p>
{% if b.sync_error %}<div class="alert alert-danger">{{ b.sync_error }}</div>{% endif %}
{{ job_panel(job) }}
{% if not res %}
<div class="section-card">
<div class="section-title"><i class="bi bi-clipboard-check"></i> Validate</div>
<p>Validation hashes every uploaded file against <span class="mono">sync.json</span>, checks that every other listed file is
unchanged in the live BPRT, applies the vendor's package rules and works out what activating the batch would change.
Nothing on the share changes.</p>
<form method="post" action="{{ url_for('bprt_sync.validate', name=b.name) }}">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="pxe-btn pxe-btn-primary" {% if job.running %}disabled{% endif %}><i class="bi bi-clipboard-check"></i> Validate</button>
</form>
</div>
{% else %}
{% if res.errors %}
<div class="section-card">
<div class="section-title text-danger"><i class="bi bi-x-octagon"></i> Errors <span class="badge badge-danger">{{ res.errors | length }}</span></div>
<p class="small text-muted">This batch cannot be activated. Fix the cause on the work PC and run Sync-BPRT.ps1 again (it makes a new batch), then delete this one.</p>
<ul class="msg-list">{% for e in res.errors %}<li>{{ e }}</li>{% endfor %}</ul>
</div>
{% endif %}
{% if res.stale %}<div class="alert alert-warning">{{ res.stale }}: validate again before activating.</div>{% endif %}
{% if res.warnings %}
<div class="section-card">
<div class="section-title text-warning"><i class="bi bi-exclamation-triangle"></i> Warnings <span class="badge badge-warning">{{ res.warnings | length }}</span></div>
<ul class="msg-list">{% for w in res.warnings %}<li>{{ w }}</li>{% endfor %}</ul>
</div>
{% endif %}
{% if res.counts %}
<div class="dashboard-grid mb-4">
<div class="stat-card {{ 'info' if res.counts.new else '' }}"><div class="label"><i class="bi bi-plus-circle"></i> New files</div><div class="value">{{ res.counts.new }}</div></div>
<div class="stat-card {{ 'warning' if res.counts.changed else '' }}"><div class="label"><i class="bi bi-arrow-repeat"></i> Changed</div><div class="value">{{ res.counts.changed }}</div></div>
<div class="stat-card {{ 'danger' if res.counts.removed else '' }}"><div class="label"><i class="bi bi-dash-circle"></i> Removed</div><div class="value">{{ res.counts.removed }}</div></div>
<div class="stat-card success"><div class="label"><i class="bi bi-check2"></i> Unchanged</div><div class="value">{{ res.counts.unchanged }}</div></div>
</div>
<div class="section-card">
<div class="section-title"><i class="bi bi-box-seam"></i> MCL packages</div>
{% if res.ppkg %}
<div class="table-container">
<table class="data-table">
<thead><tr><th>Tenant / purpose</th><th>Live now</th><th></th><th>After activation</th></tr></thead>
<tbody>
{% for p in res.ppkg %}
<tr>
<td class="mono">{{ p.prefix }}</td>
<td class="wrap">{% for o in p.old %}<div><span class="badge badge-secondary">{{ o.version }}</span> <span class="mono small fname">{{ fname(o.name) }}</span></div>{% else %}<span class="text-muted">none</span>{% endfor %}</td>
<td class="text-muted">to</td>
<td class="wrap">{% for n in p.new %}<div><span class="badge badge-info">{{ n.version }}</span> <span class="mono small fname">{{ fname(n.name) }}</span></div>{% else %}<span class="text-danger">none (removed)</span>{% endfor %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}<p class="text-muted mb-0">No package changes.</p>{% endif %}
</div>
{% if res.zips or res.bom_fix %}
<div class="section-card">
<div class="section-title"><i class="bi bi-file-earmark-zip"></i> Large files and fixes</div>
{% if res.zips %}
<div class="table-container mb-2">
<table class="data-table">
<thead><tr><th>Zip</th><th>Live size</th><th>New size</th></tr></thead>
<tbody>{% for z in res.zips %}<tr><td class="mono wrap">{{ z.path }}</td><td>{{ size(z.old_size) if z.old_size is not none else 'new' }}</td><td>{{ size(z.new_size) }}</td></tr>{% endfor %}</tbody>
</table>
</div>
{% endif %}
{% if res.bom_fix %}
<p class="small mb-1">Activation adds a UTF-8 byte order mark to {{ res.bom_fix | length }} script{{ 's' if res.bom_fix | length != 1 }}
that hold{{ 's' if res.bom_fix | length == 1 }} non-ASCII text without one (Windows PowerShell 5.1 would read them as ANSI):</p>
<ul class="file-list small mono">{% for f in res.bom_fix %}<li>{{ f }}</li>{% endfor %}</ul>
{% endif %}
</div>
{% endif %}
<div class="section-card">
<div class="section-title"><i class="bi bi-list-ul"></i> Files</div>
{% for key, label in [('new', 'New'), ('changed', 'Changed'), ('removed', 'Removed (not in the vendor folder any more; kept in the archive)'), ('unchanged', 'Unchanged')] %}
{% set rows = res.diff[key] %}
{% if rows %}
<details class="mb-2" {% if key != 'unchanged' and rows | length <= 40 %}open{% endif %}>
<summary><strong>{{ label }}</strong> <span class="badge badge-secondary">{{ rows | length }}</span></summary>
<ul class="file-list small mono">{% for f in rows %}<li>{{ f }}{% if f in renamed %} <span class="text-muted">(kept as {{ renamed[f] }})</span>{% endif %}</li>{% endfor %}</ul>
</details>
{% endif %}
{% endfor %}
<p class="small text-muted mb-0">Site files carried over from the live BPRT as they are:
{% for o in overlay %}<span class="mono">{{ o }}</span>{% if not loop.last %}, {% endif %}{% endfor %}.</p>
</div>
{% endif %}
<div class="section-card">
<div class="section-title"><i class="bi bi-rocket-takeoff"></i> Activate</div>
{{ imaging_alert(imaging) }}
<p class="small text-muted">
Builds <span class="mono">BPRT.new</span> beside the live folder (hard links: unchanged files take no space), puts the site
files back on top, checks it once more, then swaps it in with two renames: the live folder becomes
<span class="mono">BPRT-archive/full-&lt;time&gt;</span> (roll back to it at any time) and <span class="mono">BPRT.new</span> becomes
<span class="mono">BPRT</span>. Every image links to that path, so they all switch at once.
</p>
<div class="d-flex flex-wrap gap-2">
<form method="post" action="{{ url_for('bprt_sync.activate', name=b.name) }}"
onsubmit="return confirm('Replace the live BPRT with batch {{ b.name }}?{% if imaging %} {{ imaging | length }} PC(s) are imaging right now.{% endif %} The current folder is archived and can be rolled back.');">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="confirm" value="{{ b.name }}">
<button type="submit" class="pxe-btn pxe-btn-success" {% if res.state != 'valid' or res.stale or job.running %}disabled{% endif %}><i class="bi bi-check2-circle"></i> Activate</button>
</form>
<form method="post" action="{{ url_for('bprt_sync.validate', name=b.name) }}">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="pxe-btn pxe-btn-ghost" {% if job.running %}disabled{% endif %}><i class="bi bi-clipboard-check"></i> Validate again</button>
</form>
<span class="small text-muted align-self-center">Validated {{ res.validated | local_dt }}.</span>
</div>
</div>
{% endif %}
<form method="post" action="{{ url_for('bprt_sync.delete', name=b.name) }}" class="mb-4"
onsubmit="return confirm('Delete incoming batch {{ b.name }} and its uploaded files? The live BPRT is not touched.');">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="pxe-btn pxe-btn-ghost" {% if job.running and job.arg == b.name %}disabled{% endif %}><i class="bi bi-trash"></i> Delete this batch</button>
</form>
{% else %}
{# ======================= overview ======================================= #}
<div class="page-header">
<h1><i class="bi bi-arrow-left-right"></i> BPRT Sync</h1>
<div class="header-actions">
{% if live.missing_required %}<span class="badge badge-danger badge-lg">{{ live.missing_required | length }} required file{{ 's' if live.missing_required | length != 1 }} missing</span>
{% elif live.exists %}<span class="badge badge-success badge-lg">{{ packages | selectattr('prefix') | list | length }} MCL packages live</span>{% endif %}
<form method="post" action="{{ url_for('bprt_sync.rebuild') }}" style="display:inline;">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="pxe-btn pxe-btn-ghost" {% if job.running %}disabled{% endif %}
title="Hash any live file the cache does not know; Sync-BPRT.ps1 starts this itself when it needs it"><i class="bi bi-fingerprint"></i> Rehash live folder</button>
</form>
</div>
</div>
<p class="text-muted">
<span class="mono">_shared/Applications/BPRT</span> is the vendor's bulk-enroll folder (MCL packages, Office zips, orchestrator
scripts) that every image links to and PESetup copies to every PC. The vendor keeps it current on the GE work PC with
MediaCreator Lite; <strong>Sync-BPRT.ps1</strong> on that PC compares it with this server's copy and uploads only what changed,
over SSH, into <span class="mono">BPRT-incoming</span>. Here a batch is validated, its changes shown, and activated with the
old folder archived. The site's own files ({% for o in overlay %}<span class="mono">{{ o }}</span>{% if not loop.last %}, {% endif %}{% endfor %})
are never touched by a sync.
</p>
{{ imaging_alert(imaging) }}
{{ job_panel(job) }}
{# ---- Live folder --------------------------------------------------------- #}
<div class="section-card">
<div class="section-title"><i class="bi bi-folder-check"></i> Live BPRT</div>
{% if not live.exists %}
<div class="alert alert-danger mb-0"><span class="mono">{{ live.path }}</span> does not exist.</div>
{% else %}
<p class="small text-muted"><span class="mono">{{ live.path }}</span>: {{ live.count }} files, {{ size(live.bytes) }}{% if live.newest %}, newest file {{ live.newest | timestamp_fmt }}{% endif %}.</p>
{% if live.is_link %}<div class="alert alert-danger"><span class="mono">{{ live.path }}</span> is a symlink; activation needs it to be a real folder and refuses to run.</div>{% endif %}
{% for l in links if l.state in ('copy', 'elsewhere', 'none') %}
<div class="alert alert-warning py-2 mb-2">
<strong>{{ l.image }}</strong>
{% if l.state == 'copy' %}has its own copy of BPRT (<span class="mono">{{ l.path }}</span> is a real folder; an image import does this), so a sync here does not reach its PCs.
{% elif l.state == 'elsewhere' %}links BPRT to <span class="mono">{{ l.target or '?' }}</span>, not to the shared folder, so a sync here does not reach its PCs.
{% else %}has no <span class="mono">Deploy/Applications/BPRT</span>.{% endif %}
</div>
{% endfor %}
{% if live.missing_required %}
<div class="alert alert-danger py-2">Missing files the bulk-enroll flow calls by path: {% for r in live.missing_required %}<span class="mono">{{ r }}</span>{% if not loop.last %}, {% endif %}{% endfor %}.</div>
{% endif %}
{% if live.bom_needed %}
<div class="alert alert-warning py-2">{{ live.bom_needed | length }} script{{ 's' if live.bom_needed | length != 1 }} hold{{ 's' if live.bom_needed | length == 1 }} non-ASCII text without a UTF-8 byte order mark, which Windows PowerShell 5.1 misreads:
{% for r in live.bom_needed %}<span class="mono">{{ r }}</span>{% if not loop.last %}, {% endif %}{% endfor %}. The next activation fixes the copies it brings in.</div>
{% endif %}
<div class="row g-3">
<div class="col-xl-7">
<h6>MCL packages</h6>
{% if packages %}
<div class="table-container">
<table class="data-table">
<thead><tr><th>Tenant / purpose</th><th>File</th><th>Version</th><th>Size</th></tr></thead>
<tbody>
{% for p in packages %}
<tr>
<td class="mono text-nowrap">{{ p.prefix or '-' }}{% if p.conflict %} <span class="badge badge-danger" title="Also matches {{ p.conflict_with | join(', ') }}">Duplicate</span>{% endif %}</td>
<td class="mono small fname">{{ fname(p.filename) }}</td>
<td><span class="badge badge-info">{{ p.version or '?' }}</span></td>
<td class="text-nowrap">{{ size(p.size) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}<p class="text-muted">No packages.</p>{% endif %}
<div class="small text-muted mt-1">A single package can also be replaced on <a href="{{ url_for('enrollment') }}#mcl">Enrollment Packages</a>.</div>
</div>
<div class="col-xl-5">
<h6>Site files (kept on every sync)</h6>
<table class="table table-sm mb-0">
<tbody>
{% for o in live.overlay %}
<tr>
<td class="mono small wrap">{{ o.name }}</td>
<td class="text-nowrap">
{% if o.ok %}<span class="badge badge-success">Present</span>
{% elif o.present %}<span class="badge badge-danger" title="This is the vendor's dialog, not our shim">Vendor copy</span>
{% else %}<span class="badge badge-{{ 'danger' if o.name == 'Register-BulkEnrollTask-Standard.ps1' or o.name in renamed else 'secondary' }}">Missing</span>{% endif %}
{% if o.vendor_copy is defined %}{% if o.vendor_copy %}<span class="badge badge-secondary" title="{{ renamed[o.name] }}">vendor dialog kept</span>{% else %}<span class="badge badge-warning" title="{{ renamed[o.name] }} is missing: the shim cannot fall back to the vendor dialog">no vendor dialog</span>{% endif %}{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="small text-muted mt-1">
<span class="mono">Select-PCConfig.ps1</span> is our shim (repo <span class="mono">playbook/shopfloor-setup/BPRT</span>); the vendor's
dialog is kept as <span class="mono">Select-PCConfig-vendor.ps1</span>. The standard and engineer unattends run
<span class="mono">Register-BulkEnrollTask-Standard.ps1</span> (specialize Order 39).
</div>
</div>
</div>
{% endif %}
</div>
{# ---- Incoming batches ---------------------------------------------------- #}
<div class="section-card">
<div class="section-title"><i class="bi bi-inboxes"></i> Incoming batches <span class="badge badge-secondary">{{ batches | length }}</span></div>
{% if batches %}
<div class="table-container">
<table class="data-table">
<thead><tr><th>Batch</th><th>From</th><th>Uploaded</th><th>Status</th><th class="text-end"></th></tr></thead>
<tbody>
{% for b in batches %}
<tr>
<td class="mono small wrap">{% if b.uploading %}{{ b.name }}{% else %}<a href="{{ url_for('bprt_sync.batch', name=b.name) }}">{{ b.name }}</a>{% endif %}</td>
<td class="small wrap">{% if b.sync %}{{ b.sync.computer or '-' }}{% if b.sync.created %}<div class="text-muted">{{ b.sync.created | local_dt }}</div>{% endif %}{% else %}-{% endif %}</td>
<td class="small text-nowrap">{{ b.files }} file{{ 's' if b.files != 1 }}, {{ size(b.bytes) }}</td>
<td class="small">
{% if b.uploading %}<span class="badge badge-info" title="Sync-BPRT.ps1 renames the folder when the upload is complete">Uploading</span>
{% elif b.sync_error %}<span class="badge badge-danger" title="{{ b.sync_error }}">Bad sync.json</span>
{% elif not b.sync %}<span class="badge badge-danger" title="sync.json is uploaded last; this batch is incomplete">No sync.json</span>
{% else %}{{ val_badge(b.validation) }}{% endif %}
</td>
<td class="text-end text-nowrap">
{% if not b.uploading and b.sync %}
<a class="pxe-btn pxe-btn-ghost pxe-btn-sm" href="{{ url_for('bprt_sync.batch', name=b.name) }}"><i class="bi bi-eye"></i> {{ 'Review' if b.validation else 'Open' }}</a>
{% if not b.validation %}
<form method="post" action="{{ url_for('bprt_sync.validate', name=b.name) }}" style="display:inline;">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="pxe-btn pxe-btn-primary pxe-btn-sm" {% if job.running %}disabled{% endif %}><i class="bi bi-clipboard-check"></i> Validate</button>
</form>
{% endif %}
{% endif %}
<form method="post" action="{{ url_for('bprt_sync.delete', name=b.name) }}" style="display:inline;"
onsubmit="return confirm('Delete incoming batch {{ b.name }}?{% if b.uploading %} If Sync-BPRT.ps1 is still uploading it, its upload fails.{% endif %} The live BPRT is not touched.');">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-danger btn-row-action" title="Delete batch"><i class="bi bi-trash"></i></button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted mb-0">Nothing waiting. Run Sync-BPRT.ps1 on the work PC (below), or upload a zip.</p>
{% endif %}
</div>
{# ---- How to sync --------------------------------------------------------- #}
<div class="section-card">
<div class="section-title"><i class="bi bi-terminal"></i> Sync from the work PC</div>
<ol class="mb-2">
<li>Let MediaCreator Lite finish updating its cache on the GE work PC.</li>
<li>Get <span class="mono">Sync-BPRT.ps1</span>
{% if script_available %}(<a href="{{ url_for('bprt_sync.script') }}">download it from here</a>, or from the pxe repo, <span class="mono">scripts/windows/</span>)
{% else %}from the pxe repo, <span class="mono">scripts/windows/Sync-BPRT.ps1</span>{% endif %},
and run it in Windows PowerShell. Look first with <span class="mono">-DryRun</span> (nothing is uploaded):
<pre class="cmd mono small">powershell -ExecutionPolicy Bypass -File .\Sync-BPRT.ps1 -DryRun
powershell -ExecutionPolicy Bypass -File .\Sync-BPRT.ps1</pre>
It reads this server's manifest, hashes the local folder (<span class="mono">C:\ProgramData\GEAerospace\MediaCenter\Cache\Deploy\Applications\BPRT</span>
unless <span class="mono">-Source</span> says otherwise), parse-checks the vendor scripts, and uploads the new and changed files with one
<span class="mono">sftp</span> session: type the <span class="mono">pxe</span> password once.</li>
<li>Come back here, open the new batch, <strong>Validate</strong>, read the changes, <strong>Activate</strong>.</li>
</ol>
<p class="small text-muted mb-0">
No SSH from that PC? Run it with <span class="mono">-ZipOut C:\Temp\bprt.zip</span> and upload the zip below. A zip of the whole
vendor folder works too, but is taken as the complete new folder: anything not in it is removed from BPRT (and kept in the archive).
Uploads land in <span class="mono">{{ incoming }}</span>.
</p>
</div>
{# ---- Zip upload ---------------------------------------------------------- #}
<div class="section-card">
<div class="section-title"><i class="bi bi-file-earmark-zip"></i> Upload a zip instead</div>
<form id="bprtUpload" method="post" action="{{ url_for('bprt_sync.upload') }}" enctype="multipart/form-data"
class="d-flex flex-wrap gap-2 align-items-end">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="form-field mb-0" style="min-width: min(22rem, 100%);">
<label for="bprtZip" class="form-label">Zip (.zip)</label>
<input type="file" class="form-control" id="bprtZip" name="zip_file" accept=".zip" required>
</div>
<button type="submit" class="pxe-btn pxe-btn-primary" {% if job.running %}disabled{% endif %}><i class="bi bi-upload"></i> Upload and validate</button>
</form>
<div id="bprtUploadProgress" class="mt-2" style="display: none;">
<div class="progress" style="height: 0.5rem;"><div id="bprtUploadBar" class="progress-bar" style="width: 0%;"></div></div>
<div id="bprtUploadText" class="small text-muted mt-1"></div>
</div>
{% if zips %}
<div class="table-container mt-3">
<table class="table table-sm mb-0">
<thead><tr><th>Uploaded zip, not unpacked</th><th>Size</th><th class="text-end"></th></tr></thead>
<tbody>
{% for z in zips %}
<tr>
<td class="mono small wrap">{{ z.name }}</td><td class="text-nowrap">{{ size(z.size) }}</td>
<td class="text-end text-nowrap">
<form method="post" action="{{ url_for('bprt_sync.unzip', name=z.name) }}" style="display:inline;">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="pxe-btn pxe-btn-ghost pxe-btn-sm" {% if job.running %}disabled{% endif %}>Unpack</button>
</form>
<form method="post" action="{{ url_for('bprt_sync.delete_zip', name=z.name) }}" style="display:inline;" onsubmit="return confirm('Delete {{ z.name }}?');">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-danger btn-row-action" title="Delete"><i class="bi bi-trash"></i></button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div>
{# ---- Archives ------------------------------------------------------------ #}
<div class="section-card">
<div class="section-title"><i class="bi bi-archive"></i> Previous BPRT folders <span class="badge badge-secondary">{{ archives | length }}</span></div>
{% if archives %}
<p class="small text-muted">Each activation and rollback keeps the folder it replaced in <span class="mono">BPRT-archive</span>. Unchanged files are
hard links shared with the live folder, so an archive only costs the files that differ ("own size").</p>
<div class="table-container">
<table class="data-table">
<thead><tr><th>Archive</th><th>MCL packages</th><th>Files</th><th>Own size</th><th class="text-end"></th></tr></thead>
<tbody>
{% for a in archives %}
<tr>
<td class="mono small text-nowrap">{{ a.name }}<div class="text-muted">{{ a.modified | timestamp_fmt }}</div></td>
<td class="small wrap">{% for p in a.packages %}<div class="mono fname">{{ fname(p) }}</div>{% else %}<span class="text-danger">none</span>{% endfor %}
{% if a.missing_required %}<div class="text-danger">missing {{ a.missing_required | join(', ') }}</div>{% endif %}</td>
<td class="small text-nowrap">{{ a.count }}, {{ size(a.bytes) }}</td>
<td class="small text-nowrap">{{ size(a.own_bytes) }}</td>
<td class="text-end text-nowrap">
<form method="post" action="{{ url_for('bprt_sync.rollback') }}" style="display:inline;"
onsubmit="return confirm('Make {{ a.name }} the live BPRT again?{% if imaging %} {{ imaging | length }} PC(s) are imaging right now.{% endif %} The current folder is archived first.');">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="archive" value="{{ a.name }}">
<button type="submit" class="pxe-btn pxe-btn-ghost pxe-btn-sm" {% if job.running %}disabled{% endif %}><i class="bi bi-arrow-counterclockwise"></i> Roll back</button>
</form>
<form method="post" action="{{ url_for('bprt_sync.delete_archive') }}" style="display:inline;"
onsubmit="return confirm('Delete archive {{ a.name }}? This frees {{ size(a.own_bytes) }}; it cannot be rolled back to afterwards.');">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="archive" value="{{ a.name }}">
<button type="submit" class="btn btn-sm btn-outline-danger btn-row-action" title="Delete archive" {% if job.running %}disabled{% endif %}><i class="bi bi-trash"></i></button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted mb-0">None yet. The first activation archives the current folder here.</p>
{% endif %}
</div>
{% if history %}
<div class="section-card">
<div class="section-title"><i class="bi bi-clock-history"></i> History</div>
<div class="table-container">
<table class="data-table">
<thead><tr><th>When</th><th>What</th><th>Previous folder</th></tr></thead>
<tbody>
{% for h in history %}
<tr>
<td class="small text-nowrap">{{ h.at | local_dt }}</td>
<td class="small wrap">
{% if h.kind == 'rollback' %}Rolled back to <span class="mono">{{ h.restored }}</span>
{% else %}Activated <span class="mono">{{ h.batch }}</span>{% if h.sync and h.sync.computer %} from {{ h.sync.computer }}{% endif %}:
{{ h.counts.new or 0 }} new, {{ h.counts.changed or 0 }} changed, {{ h.counts.removed or 0 }} removed
{% for p in h.ppkg %}<div class="mono">{{ p.prefix }}: {{ p.old | map(attribute='version') | join(', ') or 'none' }} to {{ p.new | map(attribute='version') | join(', ') or 'none' }}</div>{% endfor %}
{% endif %}
</td>
<td class="mono small">{{ h.archive }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{% endif %}
</div>
{% endblock %}
{% block extra_scripts %}
<script>
(function () {
var csrf = document.querySelector('meta[name="csrf-token"]').content;
// Zip upload: stream the raw file (no multipart parse, no copy in /tmp) with a progress bar.
var form = document.getElementById('bprtUpload');
if (form) {
form.addEventListener('submit', function (ev) {
var input = document.getElementById('bprtZip');
if (!input.files.length || !window.XMLHttpRequest) { return; }
ev.preventDefault();
var file = input.files[0];
var box = document.getElementById('bprtUploadProgress');
var bar = document.getElementById('bprtUploadBar');
var text = document.getElementById('bprtUploadText');
var xhr = new XMLHttpRequest();
xhr.open('POST', form.action + '?name=' + encodeURIComponent(file.name));
xhr.setRequestHeader('X-CSRF-Token', csrf);
xhr.setRequestHeader('Content-Type', 'application/octet-stream');
xhr.upload.onprogress = function (e) {
if (!e.lengthComputable) { return; }
bar.style.width = (100 * e.loaded / e.total).toFixed(1) + '%';
text.textContent = 'Uploading ' + file.name + ': ' + Math.round(e.loaded / 1048576) + ' of ' +
Math.round(e.total / 1048576) + ' MB';
};
xhr.onload = function () {
var r = {};
try { r = JSON.parse(xhr.responseText); } catch (e) {}
if (xhr.status === 200 && r.ok) { location.reload(); return; }
text.textContent = 'Upload failed: ' + (r.error || ('HTTP ' + xhr.status));
bar.classList.add('bg-danger');
form.querySelector('button[type=submit]').disabled = false;
};
xhr.onerror = function () { text.textContent = 'Upload failed: connection lost.'; bar.classList.add('bg-danger'); };
box.style.display = '';
form.querySelector('button[type=submit]').disabled = true;
xhr.send(file);
});
}
// Follow a running job; reload when it ends.
var job = document.getElementById('bprtJob');
if (!job) { return; }
var URL = '{{ url_for("bprt_sync.status") }}';
function tick() {
fetch(URL, { credentials: 'same-origin' })
.then(function (r) { return r.json(); })
.then(function (d) {
if (!d.running) { location.reload(); return; }
document.getElementById('bprtJobStep').textContent = d.step || '';
document.getElementById('bprtJobCurrent').textContent = d.current || '';
if (d.percent != null) {
document.getElementById('bprtJobBar').style.width = d.percent + '%';
document.getElementById('bprtJobPct').textContent = d.percent + '%';
}
setTimeout(tick, 2000);
})
.catch(function () { setTimeout(tick, 5000); });
}
setTimeout(tick, 1500);
})();
</script>
{% endblock %}