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).
667 lines
43 KiB
HTML
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">▾</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">▾</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-<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>
|
|
</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">▾</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">▾</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">▾</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">▾</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">▾</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">▾</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">▾</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 %}
|