Webapp redesign phase 6: the remaining pages

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

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

View File

@@ -1,337 +1,413 @@
{% extends "base.html" %}
{% block title %}Blancco Updates - PXE Server Manager{% endblock %}
{% import '_ui.html' as ui %}
{% block title %}Blancco releases - PXE Manager{% endblock %}
{% macro mb(n) -%}{{ "%.1f"|format((n or 0) / 1048576) }} MB{%- endmacro %}
{% macro release_cells(r) -%}
<td class="mono">{{ r.kernel or '-' }}</td>
<td class="mono">{{ r.prefs_version or '-' }}{% if r.vendor_prefs_version and r.vendor_prefs_version != r.prefs_version %}<div class="small text-muted">vendor {{ r.vendor_prefs_version }}</div>{% endif %}</td>
<td class="text-nowrap">{{ mb(r.size) }}</td>
{%- endmacro %}
{% macro prefs(r) -%}{{ r.prefs_version or '-' }}{% if r.vendor_prefs_version and r.vendor_prefs_version != r.prefs_version %} (vendor {{ r.vendor_prefs_version }}){% 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; }
.strip form { margin: 0 0 0 auto; }
.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.bad > div, .bar > div.bg-danger { background: var(--fail); }
.cur { padding: 20px 24px; display: flex; flex-direction: column; gap: 14px; }
.cur-top { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; }
.cur-ver { font-size: 24px; font-weight: 600; }
.facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(170px, 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; }
.sgrid { display: grid; grid-template-columns: 190px minmax(0, 1fr) 190px 14px; align-items: center; gap: 0 16px;
padding: 12px 240px 12px 24px; font-size: 14px; min-height: 24px; }
.fgrid { display: grid; grid-template-columns: minmax(0, 1fr) 110px 80px 170px; align-items: center; gap: 0 16px;
padding: 12px 170px 12px 24px; font-size: 14px; min-height: 36px; }
.bgrid { display: grid; grid-template-columns: minmax(0, 1fr) 90px 170px 14px; align-items: center; gap: 0 16px;
padding: 12px 170px 12px 24px; font-size: 14px; min-height: 24px; }
.rbody { padding: 2px 24px 16px; font-size: 13px; display: flex; flex-direction: column; gap: 8px; line-height: 1.55; }
.kv { display: grid; grid-template-columns: 130px minmax(0, 1fr); gap: 4px 16px; margin: 0; }
.kv dt { font-weight: 400; color: var(--muted); }
.kv dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
.vname { display: flex; align-items: center; gap: 8px; min-width: 0; white-space: nowrap; }
.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; }
.steps { margin: 0; padding-left: 20px; display: flex; flex-direction: column; gap: 4px; }
.joblog { margin: 6px 0 0; max-height: 16rem; overflow: auto; padding: 8px 10px; background: var(--surface-2); border: 1px solid var(--line); border-radius: 6px; font-size: 12px; white-space: pre-wrap; color: var(--text); }
@container (max-width: 900px) {
.sgrid { grid-template-columns: minmax(0, 1fr) 180px 14px; }
.sgrid .c-meta { display: none; }
.fgrid { grid-template-columns: minmax(0, 1fr) 80px; }
.fgrid .c-folder, .fgrid .c-mod { display: none; }
.bgrid { grid-template-columns: minmax(0, 1fr) 80px 14px; }
.bgrid .c-saved { display: none; }
}
@container (max-width: 560px) {
.sgrid .c-meta, .bgrid .c-saved { display: block; }
.sgrid, .bgrid { grid-template-columns: minmax(0, 1fr) auto 14px; padding: 12px 16px; gap: 4px 10px; }
.sgrid .c-meta, .bgrid .c-saved { grid-column: 1 / -1; grid-row: 2; }
.bgrid .c-ver { grid-column: 2; grid-row: 1; }
.fgrid { grid-template-columns: minmax(0, 1fr) auto; padding: 12px 16px; gap: 2px 10px; }
.fgrid .c-folder, .fgrid .c-mod { display: none; }
.ui-row > .act { position: static; padding: 0 16px 10px; display: flex; justify-content: flex-end; }
.rbody { padding: 2px 16px 14px; }
.kv { grid-template-columns: minmax(0, 1fr); }
.kv dd { margin-bottom: 6px; }
.sec-h, .upl, .prog { padding-left: 16px; padding-right: 16px; }
.cur { padding: 18px 16px; }
}
</style>
{% endblock %}
{% block content %}
<div class="page-header">
<h1><i class="bi bi-eraser"></i> Blancco Updates</h1>
<div class="header-actions">
<span class="badge badge-info badge-lg">Live: {{ current.version }}{% if current.inferred %} (installed by hand){% endif %}</span>
{% if test %}<span class="badge badge-warning badge-lg">Test entry: {{ test.version or '?' }}</span>{% endif %}
<header class="ui-head">
<div>
<div class="eyebrow-t">Updates</div>
<h1>Blancco releases</h1>
<div class="state-line">
{{ ui.chip('Live: ' ~ current.version ~ (' (installed by hand)' if current.inferred else ''), 'fail' if current.missing else 'ok') }}
{% if test %}{{ ui.chip('Test entry: ' ~ (test.version or '?'), 'warn') }}{% endif %}
{% set n_ready = staged | selectattr('status', 'equalto', 'ready') | list | length %}
{% if staged %}<span class="sub">{{ staged | length }} staged, {{ n_ready }} ready</span>{% endif %}
</div>
</div>
</div>
<div class="actions"><a class="ui-btn pri" href="#upload">Upload release</a></div>
</header>
<p class="text-muted">
Installs a new Blancco Drive Eraser release from the vendor ISO (or the vendor's USB image, <span class="mono">.img</span>
or <span class="mono">.img.gz</span>). Staging extracts the release next to the live one, applies the same changes as
<span class="mono">scripts/prepare-boot-tools.sh</span> (our preferences written into <span class="mono">config.img</span>,
the initramfs network patch), and a test entry in the PXE boot menu boots the staged copy. Only <strong>Activate</strong>
changes what the normal Blancco entry boots, and the files it replaces are kept as a backup you can roll back to.
The Ubuntu kernel, its initrd and the kmod tarball the UEFI path uses are not part of a release and are never touched.
</p>
{# ---- Live release -------------------------------------------------------- #}
<div class="section-card">
<div class="section-title"><i class="bi bi-hdd-network"></i> Live release</div>
<p class="small text-muted mb-2">Served from <span class="mono">{{ current.path }}</span>.</p>
<div class="table-container">
<table class="data-table">
<thead><tr><th>Version</th><th>Blancco kernel</th><th>Preferences</th><th>Size</th><th>Rootfs date</th><th>Installed</th></tr></thead>
<tbody>
<tr>
<td class="mono">{{ current.version }}
{% if current.inferred %}<div class="small text-muted">from the preferences in config.img</div>{% endif %}
{% if current.changed_by_hand %}<div class="small text-warning">files changed outside this page since the last activate or roll back</div>{% endif %}</td>
{{ release_cells(current) }}
<td class="mono text-nowrap">{{ current.rootfs_mtime | timestamp_fmt if current.rootfs_mtime else '-' }}</td>
<td class="small">
{% if current.activated %}{{ current.activated | local_dt }}
{% if current.restored_from %}<div class="text-muted">rolled back to {{ current.restored_from }}</div>
{% elif current.source %}<div class="text-muted">from {{ current.source }}</div>{% endif %}
{% else %}<span class="text-muted">by hand (before this page)</span>{% endif %}
</td>
</tr>
</tbody>
</table>
</div>
{% if current.missing %}
<div class="alert alert-warning mt-2 mb-0">Missing from the live folder: <span class="mono">{{ current.missing | join(', ') }}</span></div>
{% endif %}
</div>
<div class="ui-stack">
{# ---- Job ----------------------------------------------------------------- #}
{% if job.state %}
<div class="section-card" id="blanccoJob" data-state="{{ job.state }}">
<div class="section-title"><i class="bi bi-gear"></i> Staging {{ job.source or '' }}</div>
<div id="blanccoJob" data-state="{{ job.state }}" class="ui-stack" style="gap: 8px">
{% if job_running %}
<div class="d-flex align-items-center gap-2 mb-1">
<span class="spinner-border spinner-border-sm" role="status"></span>
<span id="blanccoJobStep">{{ job.step or 'Starting' }}</span>
<span id="blanccoJobMsg" class="small text-muted">{{ job.message or '' }}</span>
<div class="panel strip" role="status">
<span class="pulse" aria-hidden="true"></span>
<span>Staging {{ job.source or '' }}: <span id="blanccoJobStep">{{ job.step or 'Starting' }}</span></span>
<span id="blanccoJobMsg" class="sub">{{ job.message or '' }}</span>
<div class="bar" aria-hidden="true"><div id="blanccoJobBar" style="width: {{ job.percent or 0 }}%"></div></div>
<form method="post" action="{{ url_for('blancco_update.cancel') }}"
onsubmit="return confirm('Stop staging and remove the partial files?');">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="ui-btn sm">Cancel</button>
</form>
</div>
<div class="progress mb-2" style="height: 0.5rem;">
<div id="blanccoJobBar" class="progress-bar" style="width: {{ job.percent or 0 }}%;"></div>
</div>
<form method="post" action="{{ url_for('blancco_update.cancel') }}" class="mb-2"
onsubmit="return confirm('Stop staging and remove the partial files?');">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="pxe-btn pxe-btn-ghost pxe-btn-sm"><i class="bi bi-x-circle"></i> Cancel</button>
</form>
{% elif job.state == 'done' %}
<div class="alert alert-success mb-2">{{ job.message }} <span class="small text-muted">({{ job.finished | local_dt }})</span></div>
<div class="notice info">Staging {{ job.source or '' }}: {{ job.message }} <span class="sub">({{ job.finished | local_dt }})</span></div>
{% elif job.state == 'blocked' %}
<div class="alert alert-warning mb-2"><strong>Staging stopped: preferences need merging.</strong> {{ job.message }}</div>
<div class="notice"><span><strong>Staging {{ job.source or '' }} stopped: preferences need merging.</strong> {{ job.message }} See the staged release below.</span></div>
{% elif job.state == 'cancelled' %}
<div class="alert alert-warning mb-2">Staging was cancelled.</div>
<div class="notice">Staging {{ job.source or '' }} was cancelled.</div>
{% else %}
<div class="alert alert-danger mb-2">{{ job.message or 'Staging failed.' }}</div>
<div class="errbox">Staging {{ job.source or '' }}: {{ job.message or 'Staging failed.' }}</div>
{% endif %}
{% if job.log %}
<details {% if job_running or job.state in ('failed', 'blocked') %}open{% endif %}>
<summary class="small text-muted" style="cursor:pointer;">Job log</summary>
<pre id="blanccoJobLog" class="mono small mb-0" style="white-space: pre-wrap; max-height: 16rem; overflow:auto;">{{ job.log | join('\n') }}</pre>
<summary class="disc"><span class="chev" aria-hidden="true">&#9662;</span>Job log</summary>
<pre id="blanccoJobLog" class="mono joblog">{{ job.log | join('\n') }}</pre>
</details>
{% endif %}
</div>
{% endif %}
{# ---- Get a release ------------------------------------------------------- #}
<div class="section-card">
<div class="section-title"><i class="bi bi-upload"></i> Release files</div>
<form id="blanccoUpload" method="post" action="{{ url_for('blancco_update.upload') }}" enctype="multipart/form-data"
class="d-flex flex-wrap gap-2 align-items-end mb-2">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="form-field mb-0" style="min-width: min(22rem, 100%);">
<label for="blanccoFile" class="form-label">Vendor ISO or USB image</label>
<input type="file" class="form-control" id="blanccoFile" name="release_file" accept=".iso,.img,.gz" required>
<div class="form-text">About 1 GB; streamed to <span class="mono">{{ upload_dir }}</span>.</div>
{# ---- Live release -------------------------------------------------------- #}
<section class="panel cur" aria-labelledby="live-h">
<div class="cur-top">
<div>
<h2 id="live-h" class="sec" style="font-size: 15px; color: var(--muted)">Live release</h2>
<div class="cur-ver mono">{{ current.version }}</div>
</div>
<button type="submit" class="pxe-btn pxe-btn-primary"><i class="bi bi-upload"></i> Upload</button>
</form>
<div id="blanccoUploadProgress" class="mb-3" style="display:none;">
<div class="small mb-1" id="blanccoUploadText">Uploading...</div>
<div class="progress" style="height: 0.5rem;"><div id="blanccoUploadBar" class="progress-bar" style="width: 0%;"></div></div>
<span style="margin-left: auto">{% if current.missing %}{{ ui.chip('Files missing', 'fail') }}{% elif current.changed_by_hand %}{{ ui.chip('Changed by hand', 'warn') }}{% else %}{{ ui.chip('Serving', 'ok') }}{% endif %}</span>
</div>
<p class="small text-muted">
Files copied in by hand are listed too, from
{% for key, folder in source_dirs %}<span class="mono">{{ folder }}</span>{{ ', ' if not loop.last }}{% endfor %}
(<span class="mono">.iso</span>, <span class="mono">.img</span>, <span class="mono">.img.gz</span>, <span class="mono">.iso.gz</span>).
</p>
{% if sources %}
<div class="table-container">
<table class="data-table">
<thead><tr><th>File</th><th>Folder</th><th>Size</th><th>Modified</th><th class="text-end"></th></tr></thead>
<tbody>
{% for s in sources %}
<tr>
<td class="mono">{{ s.name }}</td>
<td class="small text-muted">{{ s.key }}</td>
<td class="text-nowrap">{{ mb(s.size) }}</td>
<td class="mono text-nowrap">{{ s.modified | timestamp_fmt }}</td>
<td class="text-end text-nowrap">
<form method="post" action="{{ url_for('blancco_update.stage') }}" style="display:inline;">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="source" value="{{ s.key }}:{{ s.name }}">
<button type="submit" class="pxe-btn pxe-btn-primary btn-row-action" {% if job_running %}disabled{% endif %}>
<i class="bi bi-box-seam"></i> Stage</button>
</form>
{% if s.deletable %}
<form method="post" action="{{ url_for('blancco_update.delete_upload', name=s.name) }}" style="display:inline;"
onsubmit="return confirm({{ ('Delete ' ~ s.name ~ '?') | tojson | forceescape }});">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="pxe-btn pxe-btn-ghost btn-row-action"><i class="bi bi-trash"></i></button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted mb-0">No release files yet. Upload the vendor ISO or USB image above.</p>
{% endif %}
</div>
{% if current.missing %}<div class="notice bad">Missing from the live folder: <span class="mono">{{ current.missing | join(', ') }}</span></div>{% endif %}
{% if current.changed_by_hand %}<div class="notice">Files changed outside this page since the last activate or roll back.</div>{% endif %}
<dl class="facts">
<div><dt>Blancco kernel</dt><dd class="mono">{{ current.kernel or '-' }}</dd></div>
<div><dt>Preferences</dt><dd class="mono">{{ prefs(current) }}</dd></div>
<div><dt>Size</dt><dd>{{ mb(current.size) }}</dd></div>
<div><dt>Rootfs date</dt><dd class="mono">{{ current.rootfs_mtime | timestamp_fmt if current.rootfs_mtime else '-' }}</dd></div>
<div><dt>Installed</dt><dd>{% if current.activated %}{{ current.activated | local_dt }}
{% if current.restored_from %}<div class="sub">rolled back to {{ current.restored_from }}</div>
{% elif current.source %}<div class="sub">from {{ current.source }}</div>{% endif %}
{% else %}<span class="muted">by hand (before this page)</span>{% endif %}
{% if current.inferred %}<div class="sub">version read from the preferences in config.img</div>{% endif %}</dd></div>
</dl>
<div class="sub" style="overflow-wrap: anywhere">Served from <span class="mono">{{ current.path }}</span>.</div>
</section>
{# ---- Staged -------------------------------------------------------------- #}
<div class="section-card">
<div class="section-title"><i class="bi bi-box-seam"></i> Staged releases</div>
{% if staged %}
<div class="table-container">
<table class="data-table">
<thead><tr><th>Version</th><th>Blancco kernel</th><th>Preferences</th><th>Size</th><th>Status</th><th>Staged</th><th class="text-end"></th></tr></thead>
<tbody>
{% for s in staged %}
<tr>
<td class="mono">{{ s.name }}{% if s.in_menu %} <span class="badge badge-warning">in boot menu</span>{% endif %}</td>
{{ release_cells(s) }}
<td>
{% if s.status == 'ready' %}<span class="badge badge-success">Ready</span>
{% if s.merged_prefs %}<div class="small text-muted">merged preferences</div>{% endif %}
{% elif s.status == 'blocked' %}<span class="badge badge-danger">Needs preferences merge</span>
{% elif s.status == 'failed' %}<span class="badge badge-danger">Failed</span>
{% if s.error %}<div class="small text-danger wrap">{{ s.error }}</div>{% endif %}
{% else %}<span class="badge badge-secondary">{{ s.status }}</span>{% endif %}
</td>
<td class="small text-nowrap">{{ s.staged | local_dt }}<div class="text-muted">{{ s.source or '' }}</div></td>
<td class="text-end text-nowrap">
{% if s.status == 'ready' %}
{% if not s.in_menu %}
<form method="post" action="{{ url_for('blancco_update.test_add', version=s.name) }}" style="display:inline;"
onsubmit="return confirm({{ ('Add a test entry for ' ~ s.name ~ ' to the PXE boot menu' ~ (' (replacing the one for ' ~ (test.version or '?') ~ ')' if test else '') ~ '? The menu is backed up first.') | tojson | forceescape }});">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="pxe-btn pxe-btn-secondary btn-row-action"><i class="bi bi-list-check"></i> Add test entry</button>
</form>
{% endif %}
<form method="post" action="{{ url_for('blancco_update.activate', version=s.name) }}" style="display:inline;"
onsubmit="return confirm({{ ('Make Blancco ' ~ s.name ~ ' the live release? Every Blancco boot from now on gets it. The current files (' ~ current.version ~ ') are backed up first and can be rolled back.') | tojson | forceescape }});">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="pxe-btn pxe-btn-primary btn-row-action"><i class="bi bi-check2-circle"></i> Activate</button>
</form>
{% endif %}
<form method="post" action="{{ url_for('blancco_update.delete_staged', version=s.name) }}" style="display:inline;"
onsubmit="return confirm({{ ('Delete staged ' ~ s.name ~ '?') | tojson | forceescape }});">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="pxe-btn pxe-btn-ghost btn-row-action" title="Delete"><i class="bi bi-trash"></i></button>
</form>
</td>
</tr>
{% if s.status == 'blocked' %}
<tr>
<td colspan="7" class="wrap">
<div class="alert alert-warning mb-2">{{ s.blocked }}</div>
<ol class="small mb-2">
<li><a href="{{ url_for('blancco_update.vendor_prefs', version=s.name) }}">Download the vendor preferences.xml of {{ s.name }}</a>.</li>
<li>Carry our settings across from <span class="mono">playbook/blancco-preferences.xml</span> (report share
<span class="mono">{{ server }}</span>/<span class="mono">blancco-reports</span>, <span class="mono">auto_backup</span>, WiFi,
screensaver, business location and the rest), keeping the new file's <span class="mono">&lt;version&gt;</span> block and any new settings.</li>
<li>Upload the merged file here; staging then finishes. Commit it to the repo as <span class="mono">playbook/blancco-preferences.xml</span>.</li>
</ol>
<form method="post" action="{{ url_for('blancco_update.merged_prefs', version=s.name) }}" 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(20rem, 100%);">
<label class="form-label" for="prefs-{{ loop.index }}">Merged preferences.xml</label>
<input type="file" class="form-control" id="prefs-{{ loop.index }}" name="prefs_file" accept=".xml" required>
</div>
<button type="submit" class="pxe-btn pxe-btn-primary"><i class="bi bi-upload"></i> Upload and finish staging</button>
</form>
</td>
</tr>
<section class="panel ui-list" aria-labelledby="stg-h">
<div class="sec-h"><div><h2 id="stg-h" class="sec">Staged releases</h2>
<div class="sub">Next step for each: add a test entry, test-boot it from the PXE menu, then activate.</div></div></div>
{% for s in staged %}
{% set q_test = ('Add a test entry for ' ~ s.name ~ ' to the PXE boot menu' ~ (' (replacing the one for ' ~ (test.version or '?') ~ ')' if test else '') ~ '? The menu is backed up first.') %}
{% set q_act = ('Make Blancco ' ~ s.name ~ ' the live release? Every Blancco boot from now on gets it. The current files (' ~ current.version ~ ') are backed up first and can be rolled back.') %}
<div class="ui-row">
<details {% if s.status in ('blocked', 'failed') %}open{% endif %}>
<summary class="sgrid">
<span class="vname"><span class="mono" style="font-weight: 600">{{ s.name }}</span>{% if s.in_menu %}{{ ui.chip('in boot menu', 'warn') }}{% endif %}</span>
<span class="tip c-meta" data-tip="kernel {{ s.kernel or '-' }}, preferences {{ prefs(s) }}, {{ mb(s.size) }}"><span class="trunc sub">Staged {{ s.staged | local_dt }}{% if s.source %} from {{ s.source }}{% endif %}</span></span>
<span>
{% if s.status == 'ready' and s.in_menu %}{{ ui.chip('Ready: testing', 'ok') }}
{% elif s.status == 'ready' %}{{ ui.chip('Ready to test', 'ok') }}
{% elif s.status == 'blocked' %}{{ ui.chip('Needs preferences merge', 'act') }}
{% elif s.status == 'failed' %}{{ ui.chip('Failed', 'err') }}
{% else %}{{ ui.chip(s.status, 'neutral') }}{% endif %}
</span>
<span class="chev" aria-hidden="true">&#9662;</span>
</summary>
<div class="rbody">
{% if s.status == 'failed' and s.error %}<div class="errbox">{{ s.error }}</div>{% endif %}
<dl class="kv">
<dt>Blancco kernel</dt><dd class="mono">{{ s.kernel or '-' }}</dd>
<dt>Preferences</dt><dd class="mono">{{ prefs(s) }}{% if s.merged_prefs %} <span class="sub">(merged preferences)</span>{% endif %}</dd>
<dt>Size</dt><dd>{{ mb(s.size) }}</dd>
<dt>Staged</dt><dd>{{ s.staged | local_dt }}{% if s.source %} from <span class="mono">{{ s.source }}</span>{% endif %}</dd>
</dl>
{% if s.status == 'ready' %}
<div class="sub">{% if s.in_menu %}The test entry boots this release. When a test boot works, activate it.{% else %}Add a test entry, test-boot it, then activate.{% endif %}</div>
{% endif %}
{% endfor %}
</tbody>
</table>
{% if s.status == 'blocked' %}
<div class="notice">{{ s.blocked }}</div>
<ol class="steps">
<li><a href="{{ url_for('blancco_update.vendor_prefs', version=s.name) }}">Download the vendor preferences.xml of {{ s.name }}</a>.</li>
<li>Carry our settings across from <span class="mono">playbook/blancco-preferences.xml</span> (report share
<span class="mono">{{ server }}</span>/<span class="mono">blancco-reports</span>, <span class="mono">auto_backup</span>, WiFi,
screensaver, business location and the rest), keeping the new file's <span class="mono">&lt;version&gt;</span> block and any new settings.</li>
<li>Upload the merged file here; staging then finishes. Commit it to the repo as <span class="mono">playbook/blancco-preferences.xml</span>.</li>
</ol>
<form method="post" action="{{ url_for('blancco_update.merged_prefs', version=s.name) }}" enctype="multipart/form-data"
style="display: flex; flex-wrap: wrap; gap: 10px; align-items: flex-end; margin: 0">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div style="display: flex; flex-direction: column; gap: 4px; flex: 1 1 260px; min-width: 0">
<label for="prefs-{{ loop.index }}" style="font-weight: 600">Merged preferences.xml</label>
<input type="file" class="field" id="prefs-{{ loop.index }}" name="prefs_file" accept=".xml" required>
</div>
<button type="submit" class="ui-btn pri">Upload and finish staging</button>
</form>
{% endif %}
</div>
</details>
<div class="act">
{% if s.status == 'ready' and not s.in_menu %}
<form method="post" action="{{ url_for('blancco_update.test_add', version=s.name) }}" style="margin: 0"
onsubmit="return confirm({{ q_test | tojson | forceescape }});">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="ui-btn pri">Add test entry</button>
</form>
{% elif s.status == 'ready' %}
<form method="post" action="{{ url_for('blancco_update.activate', version=s.name) }}" style="margin: 0"
onsubmit="return confirm({{ q_act | tojson | forceescape }});">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="ui-btn pri">Activate</button>
</form>
{% endif %}
{% call ui.more('staged release ' ~ s.name) %}
{% if s.status == 'ready' and not s.in_menu %}
<form method="post" action="{{ url_for('blancco_update.activate', version=s.name) }}"
onsubmit="return confirm({{ q_act | tojson | forceescape }});">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit">Activate without a test boot</button>
</form>
<div class="sep"></div>
{% endif %}
<form method="post" action="{{ url_for('blancco_update.delete_staged', version=s.name) }}"
onsubmit="return confirm({{ ('Delete staged ' ~ s.name ~ '?') | tojson | forceescape }});">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="del">Delete staged release</button>
</form>
{% endcall %}
</div>
</div>
{% else %}
<p class="text-muted mb-0">Nothing staged.</p>
{% endif %}
</div>
<div class="hidden-bar">Nothing staged. Upload a release file below, then Stage it.</div>
{% endfor %}
</section>
{# ---- Test entry ---------------------------------------------------------- #}
<div class="section-card">
<div class="section-title"><i class="bi bi-list-check"></i> Test entry in the PXE boot menu</div>
{% if test %}
<p class="mb-2">
The menu has <span class="mono">{{ test.item or 'a blancco_staged entry' }}</span>, booting the staged
<strong class="mono">{{ test.version or '?' }}</strong> from <span class="mono">/blancco/staged/{{ test.version }}/</span>.
</p>
{% if test.efi is false %}
<div class="alert alert-warning">The entry's UEFI branch is off: when it was added, the UEFI initrd did not read
<section class="panel" aria-labelledby="test-h" style="padding: 18px 24px; display: flex; flex-direction: column; gap: 10px">
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 10px 16px">
<div style="flex: 1 1 300px; min-width: 0">
<h2 id="test-h" class="sec" style="font-size: 15px">Test entry in the PXE boot menu</h2>
{% if test %}
<div class="sub" style="overflow-wrap: anywhere">The menu has <span class="mono">{{ test.item or 'a blancco_staged entry' }}</span>, booting the staged
<strong class="mono">{{ test.version or '?' }}</strong> from <span class="mono">/blancco/staged/{{ test.version }}/</span>.</div>
{% else %}
<div class="sub">No test entry. Add test entry on a staged release puts one under the normal Blancco entry.</div>
{% endif %}
</div>
{% if test %}
<form method="post" action="{{ url_for('blancco_update.test_remove') }}" style="margin: 0"
onsubmit="return confirm('Remove the test entry from the PXE boot menu? The menu is backed up first.');">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="ui-btn">Remove test entry</button>
</form>
{% endif %}
</div>
{% if test and test.efi is false %}
<div class="notice">The entry's UEFI branch is off: when it was added, the UEFI initrd did not read
<span class="mono">blancco.base=</span>, so a UEFI test boot would have run the LIVE release. A UEFI boot of the entry
says so and returns to the menu. After rebuilding the initrd, add the test entry again.</div>
{% endif %}
<form method="post" action="{{ url_for('blancco_update.test_remove') }}"
onsubmit="return confirm('Remove the test entry from the PXE boot menu? The menu is backed up first.');">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="pxe-btn pxe-btn-secondary"><i class="bi bi-x-circle"></i> Remove test entry</button>
</form>
{% else %}
<p class="text-muted">No test entry. <strong>Add test entry</strong> on a staged release puts one under the normal Blancco entry.</p>
{% if initrd.state == 'no' %}
<div class="notice bad"><span>The UEFI initrd <span class="mono">{{ initrd.file or 'kexec-initrd' }}</span> predates <span class="mono">blancco.base=</span>: booted, it would run the LIVE release, so a test
entry added now has its UEFI branch off (legacy BIOS still works). Rebuild the initrd with the current
<span class="mono">playbook/blancco-init.sh</span>, then add the entry again (the boot screen shows "release under test" when it works).</span></div>
{% endif %}
<ul class="small mt-3 mb-0">
<li><strong>Legacy BIOS:</strong> the entry boots Blancco's own kernel from the staged folder
(<span class="mono">archiso_http_srv</span> pointed at it). Works with any initrd.</li>
<li><strong>UEFI:</strong> the entry copies the live <span class="mono">:blancco_efi</span> kernel line (Ubuntu kernel,
Secure Boot as the normal entry) and adds <span class="mono">blancco.base=/blancco/staged/&lt;version&gt;</span>, which
the init in <span class="mono">{{ initrd.file or 'kexec-initrd' }}</span> must understand:
{% if initrd.state == 'yes' %}<span class="badge badge-success">supported</span>
{% elif initrd.state == 'no' %}<span class="badge badge-danger">not supported</span>
<div class="text-danger">This initrd predates <span class="mono">blancco.base=</span>: booted, it would run the LIVE release, so a test
entry added now has its UEFI branch off (legacy BIOS still works). Rebuild the initrd with the current
<span class="mono">playbook/blancco-init.sh</span>, then add the entry again (the boot screen shows "release under test"
when it works).</div>
{% else %}<span class="badge badge-secondary">unknown</span> <span class="text-muted">{{ initrd.detail }}</span>{% endif %}</li>
<li>Add, replace and remove edit only the test entry's lines; each edit saves
<span class="mono">GetPxeScript.aspx.bak-&lt;time&gt;-pre-blancco-staged*</span> next to the menu first. Activating the
version under test removes its entry.</li>
</ul>
</div>
<details>
<summary class="disc"><span class="chev" aria-hidden="true">&#9662;</span>How the test entry boots
{% if initrd.state == 'yes' %}{{ ui.chip('UEFI supported', 'ok') }}{% elif initrd.state == 'no' %}{{ ui.chip('UEFI not supported', 'err') }}{% else %}{{ ui.chip('UEFI unknown', 'off') }}{% endif %}</summary>
<ul class="sub" style="margin: 4px 0 0; padding-left: 18px; line-height: 1.6; display: flex; flex-direction: column; gap: 4px">
<li><strong>Legacy BIOS:</strong> the entry boots Blancco's own kernel from the staged folder
(<span class="mono">archiso_http_srv</span> pointed at it). Works with any initrd.</li>
<li><strong>UEFI:</strong> the entry copies the live <span class="mono">:blancco_efi</span> kernel line (Ubuntu kernel,
Secure Boot as the normal entry) and adds <span class="mono">blancco.base=/blancco/staged/&lt;version&gt;</span>, which
the init in <span class="mono">{{ initrd.file or 'kexec-initrd' }}</span> must understand:
{% if initrd.state == 'yes' %}supported.{% elif initrd.state == 'no' %}not supported (see above).{% else %}unknown, {{ initrd.detail }}.{% endif %}</li>
<li>Add, replace and remove edit only the test entry's lines; each edit saves
<span class="mono">GetPxeScript.aspx.bak-&lt;time&gt;-pre-blancco-staged*</span> next to the menu first. Activating the
version under test removes its entry.</li>
</ul>
</details>
</section>
{# ---- Backups ------------------------------------------------------------- #}
<div class="section-card">
<div class="section-title"><i class="bi bi-clock-history"></i> Backups</div>
<p class="small text-muted mb-2">Folders <span class="mono">{{ current.path }}/backup-*</span>. A rollback first saves what is live, so it can be undone the same way;
the restored backup's folder is then released, because its files are the live files.</p>
{% if backups %}
<div class="table-container">
<table class="data-table">
<thead><tr><th>Backup</th><th>Version</th><th>Blancco kernel</th><th>Preferences</th><th>Size</th><th>Saved</th><th class="text-end"></th></tr></thead>
<tbody>
{% for b in backups %}
<tr>
<td class="mono">{{ b.name }}{% if b.reason %}<div class="small text-muted">{{ b.reason }}</div>{% endif %}</td>
<td class="mono">{{ b.version }}</td>
{{ release_cells(b) }}
<td class="mono text-nowrap">{{ b.created | local_dt if b.created is string else b.created | timestamp_fmt }}</td>
<td class="text-end text-nowrap">
{% if b.complete %}
<form method="post" action="{{ url_for('blancco_update.rollback', name=b.name) }}" style="display:inline;"
onsubmit="return confirm({{ ('Put ' ~ b.name ~ ' back live? What is live now (' ~ current.version ~ ') is backed up first.') | tojson | forceescape }});">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="pxe-btn pxe-btn-secondary btn-row-action"><i class="bi bi-arrow-counterclockwise"></i> Roll back</button>
</form>
{% else %}
<span class="badge badge-secondary" title="Missing {{ b.missing | join(', ') }}">Incomplete</span>
{% endif %}
<form method="post" action="{{ url_for('blancco_update.delete_backup', name=b.name) }}" style="display:inline;"
onsubmit="return confirm({{ ('Delete ' ~ b.name ~ '? It cannot be rolled back to afterwards.') | tojson | forceescape }});">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="pxe-btn pxe-btn-ghost btn-row-action" title="Delete"><i class="bi bi-trash"></i></button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{# ---- Release files and upload -------------------------------------------- #}
<section class="panel ui-list" id="upload" aria-labelledby="up-h">
<div class="sec-h"><div><h2 id="up-h" class="sec">Release files</h2>
<div class="sub">Upload the vendor ISO or USB image (about 1 GB), then Stage it.</div></div></div>
<form id="blanccoUpload" method="post" action="{{ url_for('blancco_update.upload') }}" enctype="multipart/form-data" class="upl">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="fld">
<label for="blanccoFile">Vendor ISO or USB image</label>
<input type="file" class="field" id="blanccoFile" name="release_file" accept=".iso,.img,.gz" required aria-describedby="blanccoFileHelp">
</div>
<button type="submit" class="ui-btn pri">Upload</button>
<span id="blanccoFileHelp" class="sub" style="flex-basis: 100%; overflow-wrap: anywhere">Streamed to <span class="mono">{{ upload_dir }}</span>.</span>
</form>
<div id="blanccoUploadProgress" class="prog" style="display: none;" role="status">
<span id="blanccoUploadText">Uploading...</span>
<div class="bar"><div id="blanccoUploadBar" style="width: 0%;"></div></div>
</div>
{% for s in sources %}
<div class="ui-row">
<div class="fgrid">
<span class="tip" data-tip="{{ s.name }}"><span class="trunc mono">{{ s.name }}</span></span>
<span class="sub c-folder">{{ s.key }}</span>
<span class="sub">{{ mb(s.size) }}</span>
<span class="sub mono c-mod">{{ s.modified | timestamp_fmt }}</span>
</div>
<div class="act" style="top: 6px">
<form method="post" action="{{ url_for('blancco_update.stage') }}" style="margin: 0">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="source" value="{{ s.key }}:{{ s.name }}">
<button type="submit" class="ui-btn" {% if job_running %}disabled{% endif %}>Stage</button>
</form>
{% if s.deletable %}
{% call ui.more(s.name) %}
<form method="post" action="{{ url_for('blancco_update.delete_upload', name=s.name) }}"
onsubmit="return confirm({{ ('Delete ' ~ s.name ~ '?') | tojson | forceescape }});">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="del">Delete file</button>
</form>
{% endcall %}
{% endif %}
</div>
</div>
<div class="form-text">Each backup holds one release, about 1 GB. Taking one is instant (hard links of the live files before they are replaced).</div>
{% else %}
<p class="text-muted mb-0">No backups yet. Activating a release saves the one it replaces here.</p>
{% endif %}
</div>
<div class="hidden-bar">No release files yet. Upload the vendor ISO or USB image above.</div>
{% endfor %}
<div class="hidden-bar" style="overflow-wrap: anywhere"><span>Files copied in by hand are listed too, from
{% for key, folder in source_dirs %}<span class="mono">{{ folder }}</span>{{ ', ' if not loop.last }}{% endfor %}
(<span class="mono">.iso</span>, <span class="mono">.img</span>, <span class="mono">.img.gz</span>, <span class="mono">.iso.gz</span>).</span></div>
</section>
{# ---- Backups (folded) ----------------------------------------------------- #}
<section class="panel ui-list" aria-labelledby="bk-h">
<details>
<summary class="sec-h">
<div><h2 id="bk-h" class="sec" style="font-size: 15px">Backups</h2>
<div class="sub">Activating a release saves the one it replaces here. A rollback first saves what is live.</div></div>
<span style="display: flex; align-items: center; gap: 10px">{{ ui.chip(backups | length ~ ' backup' ~ ('s' if backups | length != 1), 'neutral') }}<span class="chev" aria-hidden="true">&#9662;</span></span>
</summary>
{% for b in backups %}
<div class="ui-row">
<details>
<summary class="bgrid">
<span class="tip" data-tip="{{ b.name }}"><span class="trunc mono">{{ b.name }}</span></span>
<span class="mono c-ver">{{ b.version }}</span>
<span class="sub mono c-saved">{{ b.created | local_dt if b.created is string else b.created | timestamp_fmt }}</span>
<span class="chev" aria-hidden="true">&#9662;</span>
</summary>
<div class="rbody">
{% if b.reason %}<div>{{ b.reason }}</div>{% endif %}
{% if not b.complete %}<div class="notice bad">Incomplete, cannot be rolled back to: missing <span class="mono">{{ b.missing | join(', ') }}</span></div>{% endif %}
<dl class="kv">
<dt>Blancco kernel</dt><dd class="mono">{{ b.kernel or '-' }}</dd>
<dt>Preferences</dt><dd class="mono">{{ prefs(b) }}</dd>
<dt>Size</dt><dd>{{ mb(b.size) }}</dd>
</dl>
</div>
</details>
<div class="act">
{% if b.complete %}
<form method="post" action="{{ url_for('blancco_update.rollback', name=b.name) }}" style="margin: 0"
onsubmit="return confirm({{ ('Put ' ~ b.name ~ ' back live? What is live now (' ~ current.version ~ ') is backed up first.') | tojson | forceescape }});">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="ui-btn">Roll back</button>
</form>
{% else %}{{ ui.chip('Incomplete', 'off') }}{% endif %}
{% call ui.more(b.name) %}
<form method="post" action="{{ url_for('blancco_update.delete_backup', name=b.name) }}"
onsubmit="return confirm({{ ('Delete ' ~ b.name ~ '? It cannot be rolled back to afterwards.') | tojson | forceescape }});">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="del">Delete backup</button>
</form>
{% endcall %}
</div>
</div>
{% else %}
<div class="hidden-bar">No backups yet. Activating a release saves the one it replaces here.</div>
{% endfor %}
<div class="hidden-bar" style="overflow-wrap: anywhere"><span>Folders <span class="mono">{{ current.path }}/backup-*</span>, about 1 GB each. Taking one is instant
(hard links of the live files before they are replaced); the restored backup's folder is released after a rollback, because its files are the live files.</span></div>
</details>
</section>
{# ---- How it works -------------------------------------------------------- #}
<div class="pxe-card">
<h3 class="pxe-card-title"><i class="bi bi-info-circle"></i> What a release changes</h3>
<p class="small mb-2">
Replaced in <span class="mono">{{ current.path }}</span>: <span class="mono">arch/x86_64/airootfs.sfs</span>,
<span class="mono">vmlinuz-bde-linux</span>, <span class="mono">initramfs-bde-linux.img</span> (copytoram network patch),
<span class="mono">intel-ucode.img</span>, <span class="mono">amd-ucode.img</span>, <span class="mono">config.img</span>
(vendor cpio with our preferences written in at the same size), <span class="mono">preferences.xml</span>,
<span class="mono">config-clean.xml</span> (the licence container from <span class="mono">config.img</span>) and, when the
ISO has one, <span class="mono">blancco-shim.efi</span>. Never touched: <span class="mono">vmlinuz-ubuntu*</span>,
<span class="mono">kexec-initrd*</span>, <span class="mono">kmod*.tar.gz</span>, <span class="mono">grubx64.efi</span>,
<span class="mono">shimx64.efi</span>, <span class="mono">blancco-progress.py</span>.
</p>
<p class="small mb-0">
Our preferences come from the live <span class="mono">preferences.xml</span> (the playbook deploys
<span class="mono">playbook/blancco-preferences.xml</span> there). They are a merge onto one vendor release's file,
so when a release's <span class="mono">&lt;version&gt;</span> differs, staging stops until a merged file is uploaded.
After activating such a release, commit the merged file to the repo, or the next playbook run puts the old one back.
</p>
<p class="small mb-0 mt-2">
A playbook run also copies <span class="mono">boot-tools/blancco/*</span> from the USB over the live files. After activating
a release here, refresh that copy (<span class="mono">scripts/prepare-boot-tools.sh</span> with the new ISO), or a playbook run
puts the old release back.
</p>
<details>
<summary class="disc"><span class="chev" aria-hidden="true">&#9662;</span>What a release changes</summary>
<div class="sub" style="max-width: 900px; line-height: 1.6; display: flex; flex-direction: column; gap: 8px; margin-top: 4px; overflow-wrap: anywhere">
<p style="margin: 0">Installs a new Blancco Drive Eraser release from the vendor ISO (or the vendor's USB image, <span class="mono">.img</span>
or <span class="mono">.img.gz</span>). Staging extracts the release next to the live one, applies the same changes as
<span class="mono">scripts/prepare-boot-tools.sh</span> (our preferences written into <span class="mono">config.img</span>,
the initramfs network patch), and a test entry in the PXE boot menu boots the staged copy. Only Activate
changes what the normal Blancco entry boots, and the files it replaces are kept as a backup you can roll back to.</p>
<p style="margin: 0">Replaced in <span class="mono">{{ current.path }}</span>: <span class="mono">arch/x86_64/airootfs.sfs</span>,
<span class="mono">vmlinuz-bde-linux</span>, <span class="mono">initramfs-bde-linux.img</span> (copytoram network patch),
<span class="mono">intel-ucode.img</span>, <span class="mono">amd-ucode.img</span>, <span class="mono">config.img</span>
(vendor cpio with our preferences written in at the same size), <span class="mono">preferences.xml</span>,
<span class="mono">config-clean.xml</span> (the licence container from <span class="mono">config.img</span>) and, when the
ISO has one, <span class="mono">blancco-shim.efi</span>. Never touched: <span class="mono">vmlinuz-ubuntu*</span>,
<span class="mono">kexec-initrd*</span>, <span class="mono">kmod*.tar.gz</span>, <span class="mono">grubx64.efi</span>,
<span class="mono">shimx64.efi</span>, <span class="mono">blancco-progress.py</span>.</p>
<p style="margin: 0">Our preferences come from the live <span class="mono">preferences.xml</span> (the playbook deploys
<span class="mono">playbook/blancco-preferences.xml</span> there). They are a merge onto one vendor release's file,
so when a release's <span class="mono">&lt;version&gt;</span> differs, staging stops until a merged file is uploaded.
After activating such a release, commit the merged file to the repo, or the next playbook run puts the old one back.</p>
<p style="margin: 0">A playbook run also copies <span class="mono">boot-tools/blancco/*</span> from the USB over the live files. After activating
a release here, refresh that copy (<span class="mono">scripts/prepare-boot-tools.sh</span> with the new ISO), or a playbook run
puts the old release back.</p>
</div>
</details>
</div>
{% endblock %}