557 lines
33 KiB
HTML
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-<time></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>)
|
|
{% 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 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 %}
|