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:
@@ -560,6 +560,10 @@ def clonezilla_upload():
|
||||
|
||||
os.makedirs(config.CLONEZILLA_SHARE, exist_ok=True)
|
||||
dest = os.path.join(config.CLONEZILLA_SHARE, filename)
|
||||
# A backup is a whole disk image: never replace one by accident.
|
||||
if os.path.exists(dest) and request.form.get("overwrite") != "1":
|
||||
flash(f"{filename} already exists. Tick Replace the existing backup to overwrite it.", "danger")
|
||||
return redirect(url_for("clonezilla_backups"))
|
||||
f.save(dest)
|
||||
audit("BACKUP_UPLOAD", filename)
|
||||
flash(f"Uploaded {filename} successfully.", "success")
|
||||
|
||||
@@ -203,7 +203,7 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// AutoLogon Enabled toggle — keep hidden input in sync
|
||||
// AutoLogon Enabled toggle - keep hidden input in sync
|
||||
// -----------------------------------------------------------------------
|
||||
var autologonToggle = document.getElementById('autologonEnabledToggle');
|
||||
if (autologonToggle) {
|
||||
@@ -216,7 +216,7 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// OOBE toggle switches — keep hidden input in sync
|
||||
// OOBE toggle switches - keep hidden input in sync
|
||||
// -----------------------------------------------------------------------
|
||||
document.querySelectorAll('.oobe-toggle').forEach(function (cb) {
|
||||
cb.addEventListener('change', function () {
|
||||
|
||||
@@ -167,6 +167,8 @@
|
||||
var rd = rawDirty();
|
||||
var head = document.querySelector('.image-head');
|
||||
if (head) head.classList.toggle('dirty', fd || rd);
|
||||
var page = document.getElementById('unaPage');
|
||||
if (page) page.classList.toggle('dirty', fd || rd);
|
||||
if (reviewBtn) reviewBtn.disabled = !(fd || rd);
|
||||
var now = sectionState(f);
|
||||
var groups = {};
|
||||
@@ -415,7 +417,7 @@
|
||||
return box;
|
||||
}
|
||||
var bad = lint.new_errors > 0;
|
||||
var a = el('div', 'alert mb-0 ' + (bad ? 'alert-danger' : 'alert-warning'));
|
||||
var a = el('div', 'notice una-lintbox' + (bad ? ' bad' : ''));
|
||||
a.appendChild(el('strong', '', bad
|
||||
? 'This change adds ' + lint.new_errors + ' lint error' + (lint.new_errors === 1 ? '' : 's') +
|
||||
' and will not be saved. One error invalidates the whole answer file for its pass.'
|
||||
@@ -423,8 +425,8 @@
|
||||
var ul = el('ul', 'una-lint-list');
|
||||
lint.findings.forEach(function (f) {
|
||||
var li = el('li');
|
||||
li.appendChild(el('span', 'badge ' + (f.level === 'ERROR' ? 'badge-danger' : 'badge-warning'), f.level));
|
||||
if (f['new']) li.appendChild(el('span', 'badge badge-info ms-1', 'new'));
|
||||
li.appendChild(el('span', 'chip ' + (f.level === 'ERROR' ? 'c-err' : 'c-warn'), f.level));
|
||||
if (f['new']) li.appendChild(el('span', 'chip c-edit', 'new'));
|
||||
li.appendChild(document.createTextNode(' ' + f.message));
|
||||
ul.appendChild(li);
|
||||
});
|
||||
@@ -462,7 +464,7 @@
|
||||
if (!r.ok) { out.innerHTML = ''; fail(r.data.error || 'Preview failed.'); return; }
|
||||
var n = PxeImage.renderDiff(out, r.data.files);
|
||||
if ((mode === 'form' && rd) || (mode === 'raw' && fd)) {
|
||||
out.insertBefore(el('div', 'alert alert-info',
|
||||
out.insertBefore(el('div', 'notice info',
|
||||
(mode === 'form' ? 'Your Raw XML edits are' : 'Your Form edits are') +
|
||||
' not part of this save and are lost when the page reloads after it.'), out.firstChild);
|
||||
}
|
||||
|
||||
@@ -1,60 +1,176 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Audit Log - PXE Server Manager{% endblock %}
|
||||
{% import '_ui.html' as ui %}
|
||||
{% block title %}Audit log - PXE Manager{% endblock %}
|
||||
|
||||
{# Audit log, newest first. One line per entry: time, source, action chip and
|
||||
the details truncated with a tip; the full details are in the row body.
|
||||
Chips are coloured by action family; destructive actions use c-err. The
|
||||
whole log is rendered; the script shows the first 300 matches and a
|
||||
"Show more" control. #}
|
||||
|
||||
{% set families = [
|
||||
('images', 'Images', 'w11'),
|
||||
('drivers', 'Drivers and BIOS', 'off'),
|
||||
('enrollment', 'Enrollment', 'ok'),
|
||||
('blancco', 'Blancco', 'neutral'),
|
||||
('imaging', 'Imaging', 'w10'),
|
||||
('boot', 'Boot files', 'neutral'),
|
||||
('backups', 'Backups', 'w10'),
|
||||
('other', 'Other', 'neutral'),
|
||||
] %}
|
||||
{% set fam_kind = {} %}{% for k, l, c in families %}{% set _ = fam_kind.update({k: c}) %}{% endfor %}
|
||||
|
||||
{% macro family(a) -%}
|
||||
{%- if a.startswith('IMAGING_') -%}imaging
|
||||
{%- elif a.startswith(('IMAGE_', 'UNATTEND_', 'SHOPFLOOR_MENU', 'STARTNET', 'PACKAGE_')) -%}images
|
||||
{%- elif a.startswith(('DRIVER_', 'DELL_', 'HARDWARE_', 'BIOS_')) -%}drivers
|
||||
{%- elif a.startswith(('ENROLLMENT_', 'PPKG_', 'MCL_', 'BPRT_')) -%}enrollment
|
||||
{%- elif a.startswith(('BLANCCO_', 'REPORT_')) -%}blancco
|
||||
{%- elif a.startswith('IPXE_') -%}boot
|
||||
{%- elif a.startswith('BACKUP_') -%}backups
|
||||
{%- else -%}other{%- endif -%}
|
||||
{%- endmacro %}
|
||||
|
||||
{% block extra_head %}
|
||||
<style>
|
||||
.agrid { display: grid; grid-template-columns: 156px 116px 210px minmax(0, 1fr) 14px; align-items: center;
|
||||
gap: 0 14px; padding: 9px 24px; font-size: 14px; min-height: 24px; }
|
||||
.ahead { font-size: 12px; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); padding-top: 6px; padding-bottom: 6px; }
|
||||
.a-when, .a-src { font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.a-when { color: var(--muted); }
|
||||
.a-act { min-width: 0; }
|
||||
.a-act .chip { max-width: 100%; overflow: hidden; text-overflow: ellipsis; vertical-align: middle; }
|
||||
.abody { padding: 2px 24px 14px 310px; font-size: 13px; display: flex; flex-direction: column; gap: 8px; }
|
||||
.abody .full { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.abody .raw { margin: 0; font-size: 12px; color: var(--muted); white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.al-empty { padding: 28px 24px; text-align: center; color: var(--muted); border-top: 1px solid var(--line); }
|
||||
.afoot { justify-content: space-between; }
|
||||
@container (max-width: 820px) {
|
||||
.agrid { grid-template-columns: 148px minmax(0, 1fr) 14px; }
|
||||
.agrid .a-src { display: none; }
|
||||
.agrid .a-det { grid-column: 1 / -1; grid-row: 2; }
|
||||
.agrid .chev { grid-column: 3; grid-row: 1; }
|
||||
.ahead { display: none; }
|
||||
.abody { padding-left: 24px; }
|
||||
}
|
||||
@container (max-width: 520px) {
|
||||
.agrid { grid-template-columns: auto minmax(0, 1fr) 14px; padding: 10px 16px; gap: 4px 10px; }
|
||||
.agrid .a-when { overflow: visible; }
|
||||
.agrid .a-when { grid-column: 1; grid-row: 1; }
|
||||
.agrid .a-act { grid-column: 2; grid-row: 1; text-align: right; }
|
||||
.abody { padding: 2px 16px 12px; }
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>Audit Log</h1>
|
||||
<div class="header-actions">
|
||||
<span class="badge badge-secondary badge-lg">{{ entries|length }} entries</span>
|
||||
</div>
|
||||
</div>
|
||||
{{ ui.head('Audit log', 'Tools', (entries | length) ~ ' entr' ~ ('ies' if entries | length != 1 else 'y') ~ ', newest first.') }}
|
||||
|
||||
<div class="section-card">
|
||||
<div class="section-title">Activity History</div>
|
||||
<section class="panel ui-list" aria-labelledby="al-h">
|
||||
<h2 id="al-h" class="vh">Activity history</h2>
|
||||
{% if entries %}
|
||||
<div class="table-container">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 180px;">Timestamp</th>
|
||||
<th style="width: 130px;">Source</th>
|
||||
<th style="width: 180px;">Action</th>
|
||||
<th>Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in entries %}
|
||||
<tr>
|
||||
{% set parts = entry.split(' ', 1) %}
|
||||
{% if parts|length == 2 %}
|
||||
{% set meta = parts[1].split('] ', 1) %}
|
||||
<td class="mono">{{ parts[0] }}</td>
|
||||
{% if meta|length == 2 %}
|
||||
<td class="mono">{{ meta[0].lstrip('[') }}</td>
|
||||
{% set action_detail = meta[1].split(': ', 1) %}
|
||||
{% if action_detail|length == 2 %}
|
||||
<td><span class="badge badge-primary">{{ action_detail[0] }}</span></td>
|
||||
<td>{{ action_detail[1] }}</td>
|
||||
{% else %}
|
||||
<td colspan="2">{{ meta[1] }}</td>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<td colspan="3">{{ parts[1] }}</td>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<td colspan="4">{{ entry }}</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{# Parse once, so the filter pills can show counts. #}
|
||||
{% set rows = [] %}
|
||||
{% for entry in entries %}
|
||||
{% set parts = entry.split(' ', 1) %}
|
||||
{% set r = {'raw': entry, 'when': '', 'src': '', 'action': '', 'detail': entry} %}
|
||||
{% if parts | length == 2 %}
|
||||
{% set _ = r.update({'when': parts[0].split(',')[0], 'detail': parts[1]}) %}
|
||||
{% set meta = parts[1].split('] ', 1) %}
|
||||
{% if meta | length == 2 %}
|
||||
{% set _ = r.update({'src': meta[0].lstrip('['), 'detail': meta[1]}) %}
|
||||
{% set ad = meta[1].split(': ', 1) %}
|
||||
{% if ad | length == 2 %}{% set _ = r.update({'action': ad[0], 'detail': ad[1]}) %}{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% set fam = family(r.action) if r.action else 'other' %}
|
||||
{% set _ = r.update({'fam': fam, 'bad': r.action and ('DELETE' in r.action or 'ROLLBACK' in r.action or 'REMOVE' in r.action)}) %}
|
||||
{% set _ = rows.append(r) %}
|
||||
{% endfor %}
|
||||
{% set bad_count = rows | selectattr('bad') | list | length %}
|
||||
|
||||
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 10px; padding: 16px 24px">
|
||||
<div role="search" style="flex: 1 1 220px; min-width: 160px; display: flex">
|
||||
<label for="aq" class="vh">Filter by time, source, action or details</label>
|
||||
<input id="aq" type="search" class="field" style="flex: 1; min-width: 0" placeholder="Time, source, action or details">
|
||||
</div>
|
||||
<div role="group" aria-label="Action family" id="afilters" style="display: flex; gap: 6px; flex-wrap: wrap">
|
||||
<button type="button" class="fbtn" data-f="" aria-pressed="true">All</button>
|
||||
{% if bad_count %}<button type="button" class="fbtn" data-f="destructive" aria-pressed="false">Destructive {{ bad_count }}</button>{% endif %}
|
||||
{% for k, label, c in families %}
|
||||
{% set n = rows | selectattr('fam', 'equalto', k) | list | length %}
|
||||
{% if n %}<button type="button" class="fbtn" data-f="{{ k }}" aria-pressed="false">{{ label }} {{ n }}</button>{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="agrid ahead" aria-hidden="true"><span>Time</span><span class="a-src">Source</span><span>Action</span><span class="a-det">Details</span><span></span></div>
|
||||
|
||||
{% for r in rows %}
|
||||
<div class="ui-row arow" data-f="{{ r.fam }}{{ ' destructive' if r.bad }}" data-q="{{ r.raw | lower }}">
|
||||
<details>
|
||||
<summary class="agrid">
|
||||
<span class="a-when mono">{{ r.when or '-' }}</span>
|
||||
<span class="a-src mono">{{ r.src or '-' }}</span>
|
||||
<span class="a-act">{% if r.action %}<span class="tip inl" data-tip="{{ r.action }}" style="max-width: 100%">{{ ui.chip(r.action, 'err' if r.bad else fam_kind[r.fam]) }}</span>{% else %}<span class="sub">-</span>{% endif %}</span>
|
||||
<span class="a-det tip" data-tip="{{ r.detail }}"><span class="trunc">{{ r.detail or '-' }}</span></span>
|
||||
<span class="chev" aria-hidden="true">▾</span>
|
||||
</summary>
|
||||
<div class="abody">
|
||||
<p class="full">{{ r.detail or '(no details)' }}</p>
|
||||
<p class="raw mono">{% if r.src %}From <span>{{ r.src }}</span> | {% endif %}{{ r.raw }}</p>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div class="hidden-bar" id="anomatch" hidden><span>No entry matches.</span></div>
|
||||
<div class="hidden-bar afoot" id="amore" hidden>
|
||||
<span id="amoreText" aria-live="polite"></span>
|
||||
<span style="display: flex; gap: 8px">
|
||||
<button type="button" class="ui-btn sm" id="amoreBtn">Show 300 more</button>
|
||||
<button type="button" class="ui-btn sm ghost" id="aallBtn">Show all</button>
|
||||
</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-5">
|
||||
<i class="bi bi-list-check" style="font-size: 2rem; opacity: 0.4;"></i>
|
||||
<p class="mt-2 mb-1">No audit log entries yet.</p>
|
||||
<p class="small mb-0">Actions like image imports, unattend edits, and backup operations will be logged here.</p>
|
||||
<div class="al-empty">
|
||||
No audit log entries yet. Image imports, unattend edits, backup operations and other changes are logged here.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
(function () {
|
||||
var q = document.getElementById('aq');
|
||||
if (!q) return;
|
||||
var STEP = 300, limit = STEP, f = '';
|
||||
var rows = Array.prototype.slice.call(document.querySelectorAll('.arow'));
|
||||
var more = document.getElementById('amore'), moreText = document.getElementById('amoreText');
|
||||
function apply() {
|
||||
var needle = q.value.trim().toLowerCase(), matched = 0;
|
||||
rows.forEach(function (r) {
|
||||
var ok = (!needle || r.getAttribute('data-q').indexOf(needle) >= 0) &&
|
||||
(!f || (' ' + r.getAttribute('data-f') + ' ').indexOf(' ' + f + ' ') >= 0);
|
||||
if (ok) matched++;
|
||||
r.hidden = !ok || matched > limit;
|
||||
});
|
||||
document.getElementById('anomatch').hidden = matched > 0;
|
||||
var shown = Math.min(matched, limit);
|
||||
more.hidden = matched <= limit;
|
||||
moreText.textContent = 'Showing ' + shown + ' of ' + matched + (needle || f ? ' matching' : '') + ' entries';
|
||||
}
|
||||
function reset() { limit = STEP; apply(); }
|
||||
q.addEventListener('input', reset);
|
||||
document.getElementById('amoreBtn').addEventListener('click', function () { limit += STEP; apply(); });
|
||||
document.getElementById('aallBtn').addEventListener('click', function () { limit = rows.length; apply(); });
|
||||
document.querySelectorAll('#afilters .fbtn').forEach(function (b) {
|
||||
b.addEventListener('click', function () {
|
||||
f = b.getAttribute('data-f') || '';
|
||||
document.querySelectorAll('#afilters .fbtn').forEach(function (x) { x.setAttribute('aria-pressed', x === b ? 'true' : 'false'); });
|
||||
reset();
|
||||
});
|
||||
});
|
||||
apply();
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,100 +1,107 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Clonezilla Backups - PXE Server Manager{% endblock %}
|
||||
{% import '_ui.html' as ui %}
|
||||
{% block title %}Clonezilla backups - PXE Manager{% endblock %}
|
||||
|
||||
{# Clonezilla backups: one line per zip on the share (machine number, file,
|
||||
size, date). Download is the row's action; Delete is in the menu and opens
|
||||
the confirm modal. #}
|
||||
|
||||
{% block extra_head %}
|
||||
<style>
|
||||
.bgrid { display: grid; grid-template-columns: 180px minmax(0, 1fr) 90px 190px; align-items: center;
|
||||
gap: 0 14px; padding: 11px 160px 11px 24px; font-size: 14px; min-height: 36px; }
|
||||
.bhead { font-size: 12px; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); padding-top: 6px; padding-bottom: 6px; min-height: 0; }
|
||||
.b-mach { font-weight: 600; }
|
||||
.b-size { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
.b-when { font-size: 13px; color: var(--muted); white-space: nowrap; }
|
||||
.bl-empty { padding: 28px 24px; text-align: center; color: var(--muted); border-top: 1px solid var(--line); }
|
||||
.bnote { padding: 18px 24px; font-size: 13px; color: var(--text-2); display: flex; flex-direction: column; gap: 6px; }
|
||||
.bnote p { margin: 0; }
|
||||
@container (max-width: 820px) {
|
||||
.bgrid { grid-template-columns: minmax(0, 1fr) 80px; padding: 11px 160px 11px 18px; gap: 2px 12px; }
|
||||
.bgrid .b-file, .bgrid .b-when { grid-column: 1 / -1; }
|
||||
.bhead { display: none; }
|
||||
}
|
||||
@container (max-width: 520px) {
|
||||
/* The file is the machine number plus .zip; the expandable-free row keeps machine, size and date. */
|
||||
.bgrid { grid-template-columns: minmax(0, 1fr); padding: 12px 150px 12px 16px; }
|
||||
.bgrid .b-file { display: none; }
|
||||
.b-mach .trunc { white-space: normal; overflow-wrap: anywhere; }
|
||||
.b-size { text-align: left; }
|
||||
.ui-row > .act { right: 12px; top: 12px; }
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>Clonezilla Backups</h1>
|
||||
<div class="header-actions">
|
||||
<button class="pxe-btn pxe-btn-primary" data-bs-toggle="modal" data-bs-target="#uploadModal">
|
||||
<i class="bi bi-upload"></i> Upload Backup
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% call ui.head('Clonezilla backups', 'Tools', (backups | length) ~ ' backup' ~ ('s' if backups | length != 1) ~ ' on the share') %}
|
||||
<button type="button" class="ui-btn pri" data-bs-toggle="modal" data-bs-target="#uploadModal">Upload backup</button>
|
||||
{% endcall %}
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header d-flex align-items-center">
|
||||
<i class="bi bi-hdd-stack me-2"></i>
|
||||
Machine Backups
|
||||
<span class="badge badge-secondary ms-2">{{ backups|length }}</span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% if backups %}
|
||||
<div class="table-container">
|
||||
<table class="data-table mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Machine #</th>
|
||||
<th>Filename</th>
|
||||
<th>Size</th>
|
||||
<th>Last Modified</th>
|
||||
<th class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for b in backups %}
|
||||
<tr>
|
||||
<td><strong>{{ b.machine }}</strong></td>
|
||||
<td><span class="mono">{{ b.filename }}</span></td>
|
||||
<td>{{ "%.1f"|format(b.size / 1073741824) }} GB</td>
|
||||
<td>{{ b.modified | timestamp_fmt }}</td>
|
||||
<td class="actions text-end text-nowrap">
|
||||
<a href="{{ url_for('clonezilla_download', filename=b.filename) }}"
|
||||
class="pxe-btn pxe-btn-secondary btn-row-action" title="Download">
|
||||
<i class="bi bi-download"></i> Download
|
||||
</a>
|
||||
<button type="button" class="pxe-btn pxe-btn-danger btn-row-action"
|
||||
data-bs-toggle="modal" data-bs-target="#deleteModal"
|
||||
data-filename="{{ b.filename }}" data-machine="{{ b.machine }}" title="Delete">
|
||||
<i class="bi bi-trash"></i> Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="ui-stack">
|
||||
<section class="panel ui-list" aria-labelledby="bl-h">
|
||||
<h2 id="bl-h" class="vh">Machine backups</h2>
|
||||
{% if backups %}
|
||||
<div class="bgrid bhead" aria-hidden="true"><span>Machine</span><span class="b-file">File</span><span class="b-size">Size</span><span class="b-when">Modified</span></div>
|
||||
{% for b in backups %}
|
||||
<div class="ui-row">
|
||||
<div class="bgrid">
|
||||
<span class="b-mach mono tip" data-tip="{{ b.machine }}"><span class="trunc">{{ b.machine }}</span></span>
|
||||
<span class="b-file tip" data-tip="{{ b.filename }}"><span class="trunc mono sub">{{ b.filename }}</span></span>
|
||||
<span class="b-size">{{ "%.1f"|format(b.size / 1073741824) }} GB</span>
|
||||
<span class="b-when mono">{{ b.modified | timestamp_fmt }}</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-5">
|
||||
<i class="bi bi-hdd-stack" style="font-size: 2rem; opacity: 0.4;"></i>
|
||||
<p class="mt-2 mb-0">No backups found. Upload a Clonezilla backup .zip to get started.</p>
|
||||
<div class="act">
|
||||
<a class="ui-btn" href="{{ url_for('clonezilla_download', filename=b.filename) }}">Download</a>
|
||||
{% call ui.more(b.machine) %}
|
||||
<button type="button" class="copy-btn" data-copy-text="{{ b.filename }}">Copy file name</button>
|
||||
<div class="sep"></div>
|
||||
<button type="button" class="del" data-bs-toggle="modal" data-bs-target="#deleteModal"
|
||||
data-filename="{{ b.filename }}" data-machine="{{ b.machine }}">Delete backup...</button>
|
||||
{% endcall %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="bl-empty">No backups found. Upload a Clonezilla backup .zip to get started.</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<div class="section-card mt-3">
|
||||
<div class="section-title">Backup Naming Convention</div>
|
||||
<p class="mb-0">
|
||||
Name backup files with the machine number (e.g., <span class="mono">6501.zip</span>).
|
||||
The Samba share <span class="mono">\\pxe-server\clonezilla</span> is also available on the network for direct Clonezilla save/restore operations.
|
||||
</p>
|
||||
<section class="panel bnote" aria-labelledby="bn-h">
|
||||
<h2 id="bn-h" class="sec" style="font-size: 15px">Naming</h2>
|
||||
<p>Name backup files with the machine number, for example <span class="mono">6501.zip</span>. The machine column is the file name without <span class="mono">.zip</span>.</p>
|
||||
<p>The Samba share <span class="mono">\\pxe-server\clonezilla</span> is also on the network for direct Clonezilla save and restore.</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Upload Modal -->
|
||||
<div class="modal fade" id="uploadModal" tabindex="-1">
|
||||
<div class="modal fade" id="uploadModal" tabindex="-1" aria-labelledby="uploadTitle">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form action="{{ url_for('clonezilla_upload') }}" method="post" enctype="multipart/form-data">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Upload Backup</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
<h3 class="modal-title" id="uploadTitle">Upload backup</h3>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-field mb-0">
|
||||
<label for="backupFile" class="form-label">Backup File (.zip)</label>
|
||||
<label for="backupFile" class="form-label">Backup file (.zip)</label>
|
||||
<input type="file" class="form-control" id="backupFile" name="backup_file"
|
||||
accept=".zip" required>
|
||||
<div class="form-text">
|
||||
Name the file with the machine number (e.g., <span class="mono">6501.zip</span>).
|
||||
Name the file with the machine number (e.g. <span class="mono">6501.zip</span>).
|
||||
A file with the same name as an existing backup is refused unless you tick the box below.
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-check mt-2">
|
||||
<input class="form-check-input" type="checkbox" name="overwrite" value="1" id="backupOverwrite">
|
||||
<label class="form-check-label" for="backupOverwrite">Replace the existing backup with the same name</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="pxe-btn pxe-btn-ghost" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="pxe-btn pxe-btn-primary">
|
||||
<i class="bi bi-upload"></i> Upload
|
||||
</button>
|
||||
<button type="button" class="ui-btn" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="ui-btn pri">Upload</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -102,24 +109,22 @@
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div class="modal fade" id="deleteModal" tabindex="-1">
|
||||
<div class="modal fade" id="deleteModal" tabindex="-1" aria-labelledby="deleteTitle">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form id="deleteForm" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Confirm Delete</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
<h3 class="modal-title" id="deleteTitle">Delete backup</h3>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to delete the backup for machine <strong id="deleteMachine"></strong>?</p>
|
||||
<p class="text-muted mb-0">This action cannot be undone.</p>
|
||||
<p class="muted mb-0">This action cannot be undone.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="pxe-btn pxe-btn-ghost" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="pxe-btn pxe-btn-danger">
|
||||
<i class="bi bi-trash"></i> Delete
|
||||
</button>
|
||||
<button type="button" class="ui-btn pri" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="ui-btn danger">Delete</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -1,213 +1,315 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Enrollment Packages - PXE Server Manager{% endblock %}
|
||||
{% import '_ui.html' as ui %}
|
||||
{% block title %}Enrollment packages - PXE Manager{% endblock %}
|
||||
|
||||
{# Enrollment packages, in sections: the active SFLD PPKG (ppkg.conf), the
|
||||
SFLD packages (the active one is marked and cannot be deleted), the MCL
|
||||
packages in _shared/Applications/BPRT with their duplicate/ignored/used
|
||||
flags, the MCL archive, and which images link to the shared BPRT. #}
|
||||
|
||||
{% set link_chip = {
|
||||
'ok': ('Linked', 'ok'),
|
||||
'copy': ('Own copy', 'err'),
|
||||
'elsewhere': ('Links elsewhere', 'err'),
|
||||
'none': ('No BPRT', 'off'),
|
||||
} %}
|
||||
|
||||
{% block extra_head %}
|
||||
<style>
|
||||
.phead { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; padding: 18px 24px 12px; }
|
||||
.phead .sp { margin-left: auto; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||
.ptext { padding: 0 24px 14px; font-size: 13px; color: var(--text-2); display: flex; flex-direction: column; gap: 8px; max-width: 90ch; }
|
||||
.ptext p { margin: 0; }
|
||||
.notice > span, .ptext { overflow-wrap: anywhere; min-width: 0; }
|
||||
.colhead { font-size: 12px; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); padding-top: 6px; padding-bottom: 6px; min-height: 0; }
|
||||
.when { font-size: 13px; color: var(--muted); white-space: nowrap; }
|
||||
.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
.empty { padding: 22px 24px; color: var(--muted); border-top: 1px solid var(--line); font-size: 13px; }
|
||||
|
||||
/* Active PPKG */
|
||||
.active { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; padding: 18px 24px; }
|
||||
.active .file { font-size: 16px; font-weight: 600; overflow-wrap: anywhere; }
|
||||
|
||||
/* SFLD rows */
|
||||
.sgrid { display: grid; grid-template-columns: minmax(0, 1fr) 90px 190px; align-items: center; gap: 0 14px;
|
||||
padding: 11px 160px 11px 24px; font-size: 14px; min-height: 36px; }
|
||||
.s-name { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.s-name .tip { flex: 0 1 auto; }
|
||||
|
||||
/* MCL rows */
|
||||
.mgrid { display: grid; grid-template-columns: 210px minmax(0, 1fr) 70px 80px 14px; align-items: center; gap: 0 14px;
|
||||
padding: 11px 72px 11px 24px; font-size: 14px; min-height: 24px; }
|
||||
.m-pre { display: flex; align-items: center; gap: 6px; min-width: 0; }
|
||||
.m-pre .tip { flex: 0 1 auto; }
|
||||
.flags { display: flex; gap: 6px; flex-wrap: nowrap; }
|
||||
.mbody { padding: 2px 72px 14px 24px; font-size: 13px; display: flex; flex-direction: column; gap: 8px; }
|
||||
.mfacts { display: flex; flex-wrap: wrap; gap: 4px 20px; color: var(--muted); font-size: 12px; }
|
||||
.mfacts .mono { overflow-wrap: anywhere; color: var(--text-2); }
|
||||
.legend { display: flex; flex-direction: column; gap: 6px; padding: 0 24px 14px; font-size: 13px; }
|
||||
.legend div { display: flex; gap: 8px; align-items: baseline; }
|
||||
.upform { display: flex; flex-wrap: wrap; gap: 10px 12px; align-items: flex-end; padding: 0 24px 18px; }
|
||||
.upform .form-field { min-width: min(22rem, 100%); flex: 0 1 28rem; margin: 0; }
|
||||
|
||||
/* Archive rows */
|
||||
.agrid { display: grid; grid-template-columns: 150px minmax(0, 1fr) 70px 80px; align-items: center; gap: 0 14px;
|
||||
padding: 11px 130px 11px 24px; font-size: 14px; min-height: 36px; }
|
||||
|
||||
/* Image links */
|
||||
.lgrid { display: grid; grid-template-columns: 170px 130px minmax(0, 1fr); align-items: center; gap: 4px 14px; padding: 11px 24px; font-size: 14px; }
|
||||
.lgrid .mono { overflow-wrap: anywhere; font-size: 13px; }
|
||||
|
||||
@container (max-width: 760px) {
|
||||
.colhead { display: none; }
|
||||
.sgrid { grid-template-columns: minmax(0, 1fr) auto; padding: 12px 16px 4px; gap: 2px 12px; }
|
||||
.sgrid .when { grid-column: 1 / -1; }
|
||||
.mgrid { grid-template-columns: minmax(0, 1fr) auto 14px; padding: 12px 64px 12px 16px; gap: 4px 10px; }
|
||||
.mgrid .m-file { grid-column: 1 / -1; grid-row: 2; }
|
||||
.mgrid .m-size { display: none; }
|
||||
.mbody { padding: 2px 16px 12px; }
|
||||
.agrid { grid-template-columns: minmax(0, 1fr) auto; padding: 12px 16px 4px; gap: 2px 12px; }
|
||||
.agrid .a-file { grid-column: 1 / -1; }
|
||||
.agrid .a-size { display: none; }
|
||||
.ui-row > .act.wide { position: static; display: flex; justify-content: flex-end; padding: 0 16px 10px; }
|
||||
.lgrid { grid-template-columns: minmax(0, 1fr) auto; padding: 11px 16px; }
|
||||
.lgrid .mono, .lgrid .lnote { grid-column: 1 / -1; }
|
||||
.phead, .active { padding-left: 16px; padding-right: 16px; }
|
||||
.ptext, .legend, .upform { padding-left: 16px; padding-right: 16px; }
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1><i class="bi bi-pc-display"></i> Enrollment Packages</h1>
|
||||
<div class="header-actions">
|
||||
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#uploadModal">
|
||||
<i class="bi bi-upload"></i> Upload Package
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% call ui.head('Enrollment packages', 'Tools', 'Shop floor (SFLD) and Standard / Engineer (MCL) provisioning packages.') %}
|
||||
<button type="button" class="ui-btn pri" data-bs-toggle="modal" data-bs-target="#uploadModal">Upload SFLD package</button>
|
||||
{% endcall %}
|
||||
|
||||
{% if active_ppkg and active_ppkg.SOURCE_PPKG %}
|
||||
<div class="alert alert-info">
|
||||
<strong>Active PPKG:</strong> <span class="mono">{{ active_ppkg.SOURCE_PPKG }}</span>
|
||||
<span class="badge badge-info">{{ active_ppkg.PPKG_VER }}</span>
|
||||
{% if active_ppkg.PPKG_EXP %}<span class="badge badge-secondary">exp {{ active_ppkg.PPKG_EXP }}</span>{% endif %}
|
||||
<span class="form-text">- startnet.cmd boots with this (from <span class="mono">ppkg.conf</span>).</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="ui-stack">
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span><i class="bi bi-box-seam"></i> Shop Floor (SFLD) Provisioning Packages</span>
|
||||
<span class="badge badge-secondary">{{ packages|length }}</span>
|
||||
{# ---------- Active PPKG ---------- #}
|
||||
<section class="panel" aria-labelledby="act-h">
|
||||
<div class="phead" style="padding-bottom: 0"><h2 id="act-h" class="sec">Active SFLD package</h2></div>
|
||||
{% if active_ppkg and active_ppkg.SOURCE_PPKG %}
|
||||
<div class="active">
|
||||
<span class="file mono">{{ active_ppkg.SOURCE_PPKG }}</span>
|
||||
{% if active_ppkg.PPKG_VER %}{{ ui.chip(active_ppkg.PPKG_VER, 'run') }}{% endif %}
|
||||
{% if active_ppkg.PPKG_EXP %}{{ ui.chip('Expires ' ~ active_ppkg.PPKG_EXP, 'neutral') }}{% endif %}
|
||||
<span class="sub" style="flex-basis: 100%">startnet.cmd boots with this (from <span class="mono">ppkg.conf</span> on the enrollment share).</span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% if packages %}
|
||||
<div class="table-container">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Filename</th>
|
||||
<th>Size</th>
|
||||
<th>Last Modified</th>
|
||||
<th class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for p in packages %}
|
||||
{% set is_active = active_ppkg and active_ppkg.SOURCE_PPKG and p.filename|lower == active_ppkg.SOURCE_PPKG|lower %}
|
||||
<tr>
|
||||
<td><span class="mono"><i class="bi bi-file-earmark-zip"></i> {{ p.filename }}</span>{% if is_active %} <span class="badge badge-info">Active</span>{% endif %}</td>
|
||||
<td>{{ "%.1f"|format(p.size / 1048576) }} MB</td>
|
||||
<td>{{ p.modified | timestamp_fmt }}</td>
|
||||
<td class="actions text-end text-nowrap">
|
||||
<a href="{{ url_for('enrollment_download', filename=p.filename) }}"
|
||||
class="btn btn-sm btn-outline-primary btn-row-action" title="Download">
|
||||
<i class="bi bi-download"></i> Download
|
||||
</a>
|
||||
{% if is_active %}
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary btn-row-action" disabled>
|
||||
<i class="bi bi-lock"></i> Active
|
||||
</button>
|
||||
{% else %}
|
||||
<button type="button" class="btn btn-sm btn-outline-danger btn-row-action"
|
||||
data-bs-toggle="modal" data-bs-target="#deleteModal"
|
||||
data-filename="{{ p.filename }}" title="Delete">
|
||||
<i class="bi bi-trash"></i> Delete
|
||||
</button>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="active"><div class="notice" style="flex: 1"><span aria-hidden="true" style="font-weight: 700">!</span><span>No active package is set in <span class="mono">ppkg.conf</span>. Upload a standard <span class="mono">GCCH_Prod_SFLD_v<ver>.ppkg</span> to set one.</span></div></div>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{# ---------- SFLD packages ---------- #}
|
||||
<section class="panel ui-list" aria-labelledby="sfld-h">
|
||||
<div class="phead">
|
||||
<h2 id="sfld-h" class="sec">Shop floor (SFLD) packages</h2>
|
||||
<span class="sub">{{ packages | length }}</span>
|
||||
</div>
|
||||
{% if packages %}
|
||||
<div class="sgrid colhead" aria-hidden="true"><span>File</span><span class="num">Size</span><span class="when">Modified</span></div>
|
||||
{% for p in packages %}
|
||||
{% set is_active = active_ppkg and active_ppkg.SOURCE_PPKG and p.filename|lower == active_ppkg.SOURCE_PPKG|lower %}
|
||||
<div class="ui-row">
|
||||
<div class="sgrid">
|
||||
<span class="s-name">
|
||||
<span class="tip" data-tip="{{ p.filename }}"><span class="trunc mono">{{ p.filename }}</span></span>
|
||||
{% if is_active %}{{ ui.chip('Active', 'ok') }}{% endif %}
|
||||
</span>
|
||||
<span class="num">{{ "%.1f"|format(p.size / 1048576) }} MB</span>
|
||||
<span class="when mono">{{ p.modified | timestamp_fmt }}</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-5">
|
||||
<i class="bi bi-inbox" style="font-size: 2.5rem; opacity: 0.5;"></i>
|
||||
<p class="mt-2 mb-0">No enrollment packages found. Upload a <span class="mono">.ppkg</span> file to get started.</p>
|
||||
<div class="act wide">
|
||||
<a class="ui-btn" href="{{ url_for('enrollment_download', filename=p.filename) }}">Download</a>
|
||||
{% call ui.more(p.filename) %}
|
||||
<button type="button" class="copy-btn" data-copy-text="{{ p.filename }}">Copy file name</button>
|
||||
<div class="sep"></div>
|
||||
{% if is_active %}
|
||||
<button type="button" disabled>Active: cannot delete</button>
|
||||
{% else %}
|
||||
<button type="button" class="del" data-bs-toggle="modal" data-bs-target="#deleteModal"
|
||||
data-filename="{{ p.filename }}">Delete package...</button>
|
||||
{% endif %}
|
||||
{% endcall %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="empty">No enrollment packages found. Upload a <span class="mono">.ppkg</span> file to get started.</div>
|
||||
{% endif %}
|
||||
<details class="hidden-bar" style="display: block">
|
||||
<summary class="disc" style="min-height: 28px">About SFLD packages <span class="chev" aria-hidden="true">▾</span></summary>
|
||||
<div class="ptext" style="padding: 6px 0 4px">
|
||||
<p>GCCH enrollment <span class="mono">.ppkg</span> provisioning packages are copied to
|
||||
<span class="mono">C:\Enrollment\</span> on the target machine after imaging. At OOBE, connect to a network with
|
||||
internet, press <strong>Windows key 5 times</strong>, then browse to <span class="mono">C:\Enrollment\</span> and
|
||||
select the package. No USB stick needed.</p>
|
||||
<p><strong>Naming:</strong> use <span class="mono">with-office.ppkg</span> and <span class="mono">without-office.ppkg</span>
|
||||
to match the WinPE enrollment menu options.</p>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<div class="pxe-card">
|
||||
<h3 class="pxe-card-title"><i class="bi bi-info-circle"></i> About Enrollment Packages</h3>
|
||||
<p>
|
||||
GCCH enrollment <span class="mono">.ppkg</span> provisioning packages are copied to
|
||||
<span class="mono">C:\Enrollment\</span> on the target machine after imaging.
|
||||
At OOBE, connect to a network with internet, press <strong>Windows key 5 times</strong>,
|
||||
then browse to <span class="mono">C:\Enrollment\</span> and select the package. No USB stick needed.
|
||||
</p>
|
||||
<p class="mb-0">
|
||||
<strong>Naming convention:</strong> Use <span class="mono">with-office.ppkg</span> and
|
||||
<span class="mono">without-office.ppkg</span> to match the WinPE enrollment menu options.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card" id="mcl">
|
||||
<div class="card-header">
|
||||
<span><i class="bi bi-box-seam"></i> Standard / Engineer (MCL) Provisioning Packages</span>
|
||||
<span class="badge badge-secondary">{{ mcl_packages|length }}</span>
|
||||
{# ---------- MCL packages ---------- #}
|
||||
{% set mcl_users = mcl_links | selectattr('state', 'equalto', 'ok') | map(attribute='image') | list %}
|
||||
{% set any_dup = mcl_packages | selectattr('conflict') | list | length %}
|
||||
{% set any_ign = mcl_packages | rejectattr('prefix') | list | length %}
|
||||
{% set any_used = mcl_packages | selectattr('prefix') | rejectattr('placeholder') | list | length %}
|
||||
<section class="panel ui-list" id="mcl" aria-labelledby="mcl-h">
|
||||
<div class="phead">
|
||||
<h2 id="mcl-h" class="sec">Standard / Engineer (MCL) packages</h2>
|
||||
<span class="sub">{{ mcl_packages | length }}</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% set mcl_users = mcl_links | selectattr('state', 'equalto', 'ok') | map(attribute='image') | list %}
|
||||
<p class="text-muted">
|
||||
Packages live in <span class="mono">_shared/Applications/BPRT</span>, which {{ mcl_users | join(', ') if mcl_users else 'no image' }} link{{ 's' if mcl_users|length == 1 }} to.
|
||||
The bulk-enroll scripts pick the package by its first three name fields (tenant, environment, purpose) and fail
|
||||
enrollment if two match, so an upload <strong>replaces</strong> the package with the same fields; the old one moves
|
||||
to the archive below and can be restored. Every PC built from those images copies this folder while it images, so
|
||||
upload, archive or restore only when no build is in progress.
|
||||
</p>
|
||||
<p class="text-muted">
|
||||
<i class="bi bi-arrow-left-right"></i> A whole new vendor drop (packages, Office zips and scripts from MediaCreator Lite)
|
||||
goes through <a href="/updates/bprt/">BPRT Sync</a> instead: it uploads only the changed files from the work PC, checks
|
||||
them and swaps the folder in one step, keeping the site's own scripts.
|
||||
</p>
|
||||
{% for l in mcl_links if l.state in ('copy', 'elsewhere') %}
|
||||
<div class="alert alert-warning py-2 mb-2">
|
||||
<div class="ptext">
|
||||
<p>Packages live in <span class="mono">_shared/Applications/BPRT</span>, which {{ mcl_users | join(', ') if mcl_users else 'no image' }}
|
||||
link{{ 's' if mcl_users|length == 1 }} to. The bulk-enroll scripts pick the package by its first three name fields
|
||||
(tenant, environment, purpose) and fail enrollment if two match, so an upload <strong>replaces</strong> the package with
|
||||
the same fields; the old one moves to the archive below and can be restored. Every PC built from those images copies
|
||||
this folder while it images, so upload, archive or restore only when no build is in progress.</p>
|
||||
<p>A whole new vendor drop (packages, Office zips and scripts from MediaCreator Lite) goes through
|
||||
<a href="/updates/bprt/">BPRT Sync</a> instead: it uploads only the changed files from the work PC, checks them and
|
||||
swaps the folder in one step, keeping the site's own scripts.</p>
|
||||
</div>
|
||||
{% for l in mcl_links if l.state in ('copy', 'elsewhere') %}
|
||||
<div style="padding: 0 24px 10px">
|
||||
<div class="notice"><span aria-hidden="true" style="font-weight: 700">!</span><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, not a link to
|
||||
<span class="mono">_shared/Applications/BPRT</span>; an image import does this),
|
||||
{% else %}links BPRT to <span class="mono">{{ l.target or '?' }}</span> instead of <span class="mono">_shared/Applications/BPRT</span>,
|
||||
{% endif %}so packages changed here do not reach its PCs. Move that folder aside and link it back to the shared one.
|
||||
</div>
|
||||
{% endfor %}
|
||||
<form action="{{ url_for('mcl_ppkg_upload') }}" method="post" enctype="multipart/form-data" class="d-flex flex-wrap gap-2 align-items-end mb-3">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="form-field mb-0" style="min-width: min(22rem, 100%);">
|
||||
<label for="mclFile" class="form-label">Package (.ppkg)</label>
|
||||
<input type="file" class="form-control" id="mclFile" name="ppkg_file" accept=".ppkg" required>
|
||||
<div class="form-text">Vendor name, unchanged, e.g. <span class="mono">GCCH_Prod_MCL-PERS_Exp_XXXXXXXX_v2.19.ppkg</span></div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary"><i class="bi bi-upload"></i> Upload and replace</button>
|
||||
</form>
|
||||
{% if mcl_packages %}
|
||||
<div class="table-container">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr><th>Tenant / purpose</th><th>Filename</th><th>Version</th><th>Size</th><th>Last Modified</th><th class="text-end"></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for p in mcl_packages %}
|
||||
<tr>
|
||||
<td class="mono">{{ p.prefix or '-' }}
|
||||
{% if p.conflict %}<span class="badge badge-danger" title="This prefix also matches {{ p.conflict_with | join(', ') }}; enrollment for it will fail">Duplicate</span>{% endif %}
|
||||
{% if not p.prefix %}<span class="badge badge-warning" title="Name does not match the vendor pattern; the enroll scripts ignore it">Ignored</span>
|
||||
{% elif not p.placeholder %}<span class="badge badge-warning" title="No Exp_XXXXXXXX placeholder in the name: this looks like a package already used on a PC, and the enroll script cannot inject a new token into it">Used</span>{% endif %}
|
||||
</td>
|
||||
<td class="mono">{{ p.filename }}</td>
|
||||
<td><span class="badge badge-info">{{ p.version or '?' }}</span></td>
|
||||
<td>{{ "%.1f"|format(p.size / 1048576) }} MB</td>
|
||||
<td>{{ p.modified | timestamp_fmt }}</td>
|
||||
<td class="text-end">
|
||||
<form action="{{ url_for('mcl_ppkg_archive') }}" method="post" style="display:inline;"
|
||||
onsubmit="return confirm('Move this package to the archive? Enrollment for its tenant and purpose fails until another package is uploaded or restored.');">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="filename" value="{{ p.filename }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger btn-row-action" title="Move to the archive"><i class="bi bi-archive"></i></button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted mb-0">No MCL packages found in BPRT.</p>
|
||||
{% endif %}
|
||||
{% if mcl_archive %}
|
||||
<h6 class="mt-4">Archive</h6>
|
||||
<div class="table-container">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead><tr><th>Archived</th><th>Filename</th><th>Version</th><th>Size</th><th class="text-end"></th></tr></thead>
|
||||
<tbody>
|
||||
{% for p in mcl_archive %}
|
||||
<tr>
|
||||
<td class="mono">{{ p.batch }}</td>
|
||||
<td class="mono">{{ p.filename }}</td>
|
||||
<td>{{ p.version or '?' }}</td>
|
||||
<td>{{ "%.1f"|format(p.size / 1048576) }} MB</td>
|
||||
<td class="text-end">
|
||||
<form action="{{ url_for('mcl_ppkg_restore') }}" method="post" style="display:inline;"
|
||||
onsubmit="return confirm('Restore this package? The current package for the same tenant and purpose moves to the archive.');">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="batch" value="{{ p.batch }}">
|
||||
<input type="hidden" name="filename" value="{{ p.filename }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-primary btn-row-action"><i class="bi bi-arrow-counterclockwise"></i> Restore</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
</span></div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<form action="{{ url_for('mcl_ppkg_upload') }}" method="post" enctype="multipart/form-data" class="upform">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="form-field">
|
||||
<label for="mclFile" class="form-label">Package (.ppkg)</label>
|
||||
<input type="file" class="form-control" id="mclFile" name="ppkg_file" accept=".ppkg" required>
|
||||
<div class="form-text">Vendor name, unchanged, e.g. <span class="mono">GCCH_Prod_MCL-PERS_Exp_XXXXXXXX_v2.19.ppkg</span></div>
|
||||
</div>
|
||||
<button type="submit" class="ui-btn pri" style="margin-bottom: 22px">Upload and replace</button>
|
||||
</form>
|
||||
|
||||
{% if any_dup or any_ign or any_used %}
|
||||
<div class="legend" aria-label="What the flags mean">
|
||||
{% if any_dup %}<div>{{ ui.chip('Duplicate', 'err') }}<span>Its prefix also matches another package: enrollment for that tenant and purpose fails.</span></div>{% endif %}
|
||||
{% if any_ign %}<div>{{ ui.chip('Ignored', 'warn') }}<span>The name does not match the vendor pattern, so the enroll scripts ignore it.</span></div>{% endif %}
|
||||
{% if any_used %}<div>{{ ui.chip('Used', 'warn') }}<span>No <span class="mono">Exp_XXXXXXXX</span> placeholder in the name: it looks already used on a PC, and the enroll script cannot inject a new token into it.</span></div>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if mcl_packages %}
|
||||
<div class="mgrid colhead" aria-hidden="true"><span>Tenant / purpose</span><span class="m-file">File</span><span>Version</span><span class="num m-size">Size</span><span></span></div>
|
||||
{% for p in mcl_packages %}
|
||||
<div class="ui-row">
|
||||
<details>
|
||||
<summary class="mgrid">
|
||||
<span class="m-pre">
|
||||
<span class="tip" data-tip="{{ p.prefix or 'No prefix' }}"><span class="trunc mono">{{ p.prefix or '-' }}</span></span>
|
||||
<span class="flags">
|
||||
{% if p.conflict %}{{ ui.chip('Duplicate', 'err') }}{% endif %}
|
||||
{% if not p.prefix %}{{ ui.chip('Ignored', 'warn') }}{% elif not p.placeholder %}{{ ui.chip('Used', 'warn') }}{% endif %}
|
||||
</span>
|
||||
</span>
|
||||
<span class="m-file tip" data-tip="{{ p.filename }}"><span class="trunc mono sub">{{ p.filename }}</span></span>
|
||||
<span>{{ ui.chip(p.version or '?', 'neutral') }}</span>
|
||||
<span class="num m-size">{{ "%.1f"|format(p.size / 1048576) }} MB</span>
|
||||
<span class="chev" aria-hidden="true">▾</span>
|
||||
</summary>
|
||||
<div class="mbody">
|
||||
{% if p.conflict %}<div class="notice bad"><span aria-hidden="true" style="font-weight: 700">!</span><span>This prefix also matches {{ p.conflict_with | join(', ') }}; enrollment for it will fail.</span></div>{% endif %}
|
||||
{% if not p.prefix %}<div class="notice"><span>Name does not match the vendor pattern; the enroll scripts ignore it.</span></div>
|
||||
{% elif not p.placeholder %}<div class="notice"><span>No <span class="mono">Exp_XXXXXXXX</span> placeholder in the name: this looks like a package already used on a PC, and the enroll script cannot inject a new token into it.</span></div>{% endif %}
|
||||
<div class="mfacts"><span>File <span class="mono">{{ p.filename }}</span></span><span>{{ "%.1f"|format(p.size / 1048576) }} MB</span><span>Modified {{ p.modified | timestamp_fmt }}</span></div>
|
||||
</div>
|
||||
</details>
|
||||
<div class="act">
|
||||
{% call ui.more(p.filename) %}
|
||||
<button type="button" class="copy-btn" data-copy-text="{{ p.filename }}">Copy file name</button>
|
||||
<div class="sep"></div>
|
||||
<form action="{{ url_for('mcl_ppkg_archive') }}" method="post"
|
||||
onsubmit="return confirm('Move this package to the archive? Enrollment for its tenant and purpose fails until another package is uploaded or restored.');">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="filename" value="{{ p.filename }}">
|
||||
<button type="submit" class="del">Move to archive...</button>
|
||||
</form>
|
||||
{% endcall %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="empty">No MCL packages found in BPRT.</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{# ---------- MCL archive ---------- #}
|
||||
{% if mcl_archive %}
|
||||
<section class="panel ui-list" aria-labelledby="arc-h">
|
||||
<div class="phead">
|
||||
<h2 id="arc-h" class="sec">MCL archive</h2>
|
||||
<span class="sub">{{ mcl_archive | length }}</span>
|
||||
</div>
|
||||
<div class="agrid colhead" aria-hidden="true"><span>Archived</span><span class="a-file">File</span><span>Version</span><span class="num a-size">Size</span></div>
|
||||
{% for p in mcl_archive %}
|
||||
<div class="ui-row">
|
||||
<div class="agrid">
|
||||
<span class="mono when">{{ p.batch }}</span>
|
||||
<span class="a-file tip" data-tip="{{ p.filename }}"><span class="trunc mono sub">{{ p.filename }}</span></span>
|
||||
<span>{{ ui.chip(p.version or '?', 'neutral') }}</span>
|
||||
<span class="num a-size">{{ "%.1f"|format(p.size / 1048576) }} MB</span>
|
||||
</div>
|
||||
<div class="act wide">
|
||||
<form action="{{ url_for('mcl_ppkg_restore') }}" method="post" style="margin: 0"
|
||||
onsubmit="return confirm('Restore this package? The current package for the same tenant and purpose moves to the archive.');">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="batch" value="{{ p.batch }}">
|
||||
<input type="hidden" name="filename" value="{{ p.filename }}">
|
||||
<button type="submit" class="ui-btn">Restore</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{# ---------- Image links ---------- #}
|
||||
{% if mcl_links %}
|
||||
<section class="panel ui-list" aria-labelledby="lnk-h">
|
||||
<div class="phead">
|
||||
<h2 id="lnk-h" class="sec">Image links to the shared BPRT</h2>
|
||||
</div>
|
||||
{% for l in mcl_links %}
|
||||
{% set c = link_chip.get(l.state, (l.state, 'neutral')) %}
|
||||
<div class="ui-row lgrid">
|
||||
<span>{{ l.image }}</span>
|
||||
<span>{{ ui.chip(c[0], c[1]) }}</span>
|
||||
<span class="mono">{% if l.state == 'ok' %}{{ l.path }}{% elif l.state == 'elsewhere' %}{{ l.path }} links to {{ l.target or '?' }}{% elif l.state == 'copy' %}{{ l.path }} is a real folder{% else %}<span class="sub">No Deploy/Applications/BPRT</span>{% endif %}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Upload Modal -->
|
||||
<div class="modal fade" id="uploadModal" tabindex="-1">
|
||||
<div class="modal fade" id="uploadModal" tabindex="-1" aria-labelledby="uploadTitle">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form action="{{ url_for('enrollment_upload') }}" method="post" enctype="multipart/form-data">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Upload Enrollment Package</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
<h3 class="modal-title" id="uploadTitle">Upload SFLD package</h3>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-field">
|
||||
<label for="ppkgFile" class="form-label">Provisioning Package (.ppkg)</label>
|
||||
<label for="ppkgFile" class="form-label">Provisioning package (.ppkg)</label>
|
||||
<input type="file" class="form-control" id="ppkgFile" name="ppkg_file"
|
||||
accept=".ppkg" required>
|
||||
<div class="form-text">
|
||||
A standard <span class="mono">GCCH_Prod_SFLD_v<ver>.ppkg</span> upload is set as the active PPKG automatically
|
||||
(written to <span class="mono">ppkg.conf</span> on the share; startnet.cmd reads it at boot - no boot.wim edit).
|
||||
(written to <span class="mono">ppkg.conf</span> on the share; startnet.cmd reads it at boot, no boot.wim edit).
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
@@ -218,8 +320,8 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary"><i class="bi bi-upload"></i> Upload</button>
|
||||
<button type="button" class="ui-btn" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="ui-btn pri">Upload</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -227,22 +329,22 @@
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div class="modal fade" id="deleteModal" tabindex="-1">
|
||||
<div class="modal fade" id="deleteModal" tabindex="-1" aria-labelledby="deleteTitle">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form id="deleteForm" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Confirm Delete</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
<h3 class="modal-title" id="deleteTitle">Delete package</h3>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to delete <strong id="deleteFilename" class="mono"></strong>?</p>
|
||||
<p class="text-muted mb-0">This action cannot be undone.</p>
|
||||
<p class="muted mb-0">This action cannot be undone.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-danger"><i class="bi bi-trash"></i> Delete</button>
|
||||
<button type="button" class="ui-btn pri" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="ui-btn danger">Delete</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -1,88 +1,224 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Hardware Coverage - PXE Server Manager{% endblock %}
|
||||
{% import '_ui.html' as ui %}
|
||||
{% block title %}Hardware coverage - PXE Manager{% endblock %}
|
||||
|
||||
{% macro driver_badge(d) -%}
|
||||
{% if d.status == 'ok' %}<span class="badge badge-success" title="{{ d.file }}">OK</span>
|
||||
{% elif d.status == 'no-entry' %}<span class="badge badge-danger" title="No HardwareDriver.json entry matches this model">Missing</span>
|
||||
{% elif d.status == 'zip-missing' %}<span class="badge badge-danger" title="Entry {{ d.file }} matches, but the zip is not on the server">Zip missing</span>
|
||||
{% else %}<span class="text-muted">-</span>{% endif %}
|
||||
{# Hardware coverage: one line per model WinPE has reported. The line shows
|
||||
the model, one coverage chip per image, the BIOS state and when it was
|
||||
last seen; the body holds SKUs, serials, PC types, BIOS versions and the
|
||||
driver files each image resolves to. #}
|
||||
|
||||
{% set drv_chip = {
|
||||
'ok': ('OK', 'ok'),
|
||||
'no-entry': ('Missing', 'err'),
|
||||
'zip-missing': ('Zip missing', 'err'),
|
||||
} %}
|
||||
|
||||
{# Chip for one image's driver status; the tip names the file or the reason. #}
|
||||
{% macro driver_chip(d) -%}
|
||||
{% set c = drv_chip.get(d.status) %}
|
||||
{% if c %}
|
||||
<span class="tip inl" data-tip="{% if d.status == 'ok' %}{{ d.file }}{% elif d.status == 'no-entry' %}No HardwareDriver.json entry matches this model{% else %}Entry {{ d.file }} matches, but the zip is not on the server{% endif %}">{{ ui.chip(c[0], c[1]) }}</span>
|
||||
{%- else %}<span class="sub" title="Image not on this server">-</span>{% endif %}
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro bios_badge(b) -%}
|
||||
{% if b.status == 'current' %}<span class="badge badge-success">{{ b.target }} (current)</span>
|
||||
{% elif b.status == 'update' %}<span class="badge badge-info" title="{{ b.file }}">Flashes to {{ b.target }}</span>
|
||||
{% elif b.status == 'file-missing' %}<span class="badge badge-danger" title="{{ b.file }} not in the BIOS folder">File missing</span>
|
||||
{% else %}<span class="badge badge-danger">No BIOS entry</span>{% endif %}
|
||||
{% macro bios_chip(b) -%}
|
||||
{% if b.status == 'current' %}{{ ui.chip('Current ' ~ b.target, 'ok') }}
|
||||
{% elif b.status == 'update' %}{{ ui.chip('Flashes to ' ~ b.target, 'neutral') }}
|
||||
{% elif b.status == 'file-missing' %}{{ ui.chip('File missing', 'err') }}
|
||||
{% else %}{{ ui.chip('No BIOS entry', 'err') }}{% endif %}
|
||||
{%- endmacro %}
|
||||
|
||||
{# A newer Dell pack/BIOS for the file this model uses, from the last Updates > Dell scan. #}
|
||||
{% macro update_hint(found, file) -%}
|
||||
{% macro new_chip(found, file) -%}
|
||||
{% if file and file | lower in found %}
|
||||
<div class="mt-1"><a href="{{ url_for('dell_updates.page') }}" class="badge badge-info text-decoration-none" title="{{ file }}: Dell has {{ found[file | lower] or 'a newer version' }}. Open Updates to download it.">Update {{ found[file | lower] }}</a></div>
|
||||
<a class="tip inl newlink" href="{{ url_for('dell_updates.page') }}" data-tip="{{ file }}: Dell has {{ found[file | lower] or 'a newer version' }}. Open Updates to download it.">{{ ui.chip('New', 'run') }}<span class="vh"> Dell version {{ found[file | lower] }}</span></a>
|
||||
{% endif %}
|
||||
{%- endmacro %}
|
||||
|
||||
{% block extra_head %}
|
||||
<style>
|
||||
.hgrid { display: grid; grid-template-columns: minmax(0, 1fr) 336px 170px 184px 14px; align-items: center;
|
||||
gap: 0 14px; padding: 11px 72px 11px 24px; font-size: 14px; min-height: 24px; }
|
||||
.hhead { font-size: 12px; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); padding-top: 6px; padding-bottom: 6px; }
|
||||
.hhead .cov span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.h-model { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.h-model .tip { flex: 0 1 auto; }
|
||||
.h-model .mfr { flex: none; font-size: 12px; color: var(--muted); white-space: nowrap; }
|
||||
.cov { display: grid; grid-template-columns: repeat(3, 104px); gap: 0 12px; align-items: center; }
|
||||
.cov > span { display: flex; align-items: center; gap: 4px; min-width: 0; }
|
||||
.hgrid .ilab { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
|
||||
.h-bios { display: flex; align-items: center; gap: 4px; min-width: 0; }
|
||||
.h-seen { font-size: 13px; color: var(--muted); white-space: nowrap; }
|
||||
.newlink { text-decoration: none; }
|
||||
.newlink .chip { font-size: 11px; padding: 1px 6px; }
|
||||
.hbody { padding: 2px 72px 16px 24px; font-size: 13px; display: flex; flex-direction: column; gap: 12px; }
|
||||
.hfacts { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(200px, 100%), 1fr)); gap: 10px 24px; margin: 0; }
|
||||
.hfacts dt { font-size: 12px; font-weight: 400; color: var(--muted); }
|
||||
.hfacts dd { margin: 0; overflow-wrap: anywhere; }
|
||||
.hfiles { display: flex; flex-direction: column; gap: 6px; }
|
||||
.hfile { display: grid; grid-template-columns: 190px 104px minmax(0, 1fr); gap: 4px 12px; align-items: baseline; }
|
||||
.hfile .mono { overflow-wrap: anywhere; }
|
||||
.hl-empty { padding: 28px 24px; text-align: center; color: var(--muted); border-top: 1px solid var(--line); }
|
||||
.hintro { margin: 0; padding: 16px 24px 0; max-width: 80ch; }
|
||||
@container (max-width: 1000px) {
|
||||
.hgrid { grid-template-columns: minmax(0, 1fr) 336px 168px 14px; }
|
||||
.hgrid .h-seen { display: none; }
|
||||
}
|
||||
@container (max-width: 760px) {
|
||||
.hgrid { grid-template-columns: minmax(0, 1fr) 14px; padding: 12px 64px 12px 16px; gap: 6px 10px; }
|
||||
.hhead { display: none; }
|
||||
.hgrid .h-model { grid-column: 1; grid-row: 1; }
|
||||
.hgrid .chev { grid-column: 2; grid-row: 1; }
|
||||
.hgrid .cov, .hgrid .h-bios { grid-column: 1 / -1; }
|
||||
.cov { display: flex; flex-wrap: wrap; gap: 6px 14px; }
|
||||
.hgrid .ilab { position: static; width: auto; height: auto; overflow: visible; clip: auto; font-size: 12px; color: var(--muted); }
|
||||
.hbody { padding: 2px 16px 14px; }
|
||||
.hfile { grid-template-columns: minmax(0, 1fr) auto; }
|
||||
.hfile .mono { grid-column: 1 / -1; }
|
||||
.hintro { padding: 14px 16px 0; }
|
||||
}
|
||||
@container (min-width: 521px) and (max-width: 760px) {
|
||||
.hgrid { grid-template-columns: auto minmax(0, 1fr) 14px; }
|
||||
.hgrid .h-model { grid-column: 1 / 3; }
|
||||
.hgrid .chev { grid-column: 3; }
|
||||
.hgrid .cov { grid-column: 1; grid-row: 2; }
|
||||
.hgrid .h-bios { grid-column: 2; grid-row: 2; padding-left: 14px; }
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1><i class="bi bi-cpu"></i> Hardware Coverage</h1>
|
||||
<div class="header-actions">
|
||||
<span class="badge badge-secondary badge-lg">{{ rows | length }} model{{ 's' if rows | length != 1 }} seen</span>
|
||||
</div>
|
||||
</div>
|
||||
{% set gaps = rows | selectattr('gap') | rejectattr('dismissed') | list %}
|
||||
{% set dismissed = rows | selectattr('gap') | selectattr('dismissed') | list %}
|
||||
{{ ui.head('Hardware coverage', 'Operate', (rows | length) ~ ' model' ~ ('s' if rows | length != 1) ~ ' seen' ~ (', ' ~ (gaps | length) ~ ' with open gaps' if gaps else '')) }}
|
||||
|
||||
<p class="text-muted">
|
||||
Every model that has booted WinPE here, as reported by the machine itself (WMI model, Dell system SKU,
|
||||
BIOS version), checked against each image's driver map and the WinPE BIOS manifest. A model missing a
|
||||
Win11 driver pack or a BIOS entry also shows on the Technician To-Do until it is added or dismissed.
|
||||
</p>
|
||||
|
||||
<div class="section-card">
|
||||
<section class="panel ui-list" aria-labelledby="hl-h">
|
||||
<h2 id="hl-h" class="vh">Models seen</h2>
|
||||
<p class="sub hintro">
|
||||
Every model that has booted WinPE here, as the machine reported it (WMI model, Dell system SKU, BIOS version),
|
||||
checked against each image's driver map and the WinPE BIOS manifest. A model missing a driver pack or a BIOS
|
||||
entry also shows on the Technician to-do until it is added or dismissed.
|
||||
</p>
|
||||
{% if rows %}
|
||||
<div class="table-container">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Model (WMI)</th>
|
||||
<th>System SKU</th>
|
||||
<th>BIOS seen</th>
|
||||
{% for image, os_label in images %}<th title="{{ image }}">{{ image | replace('gea-', '') }} <span class="text-muted">({{ os_label }})</span></th>{% endfor %}
|
||||
<th>BIOS update</th>
|
||||
<th>Last seen</th>
|
||||
<th class="text-end"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in rows %}
|
||||
<tr title="Serials: {{ r.serials | join(', ') }}{% if r.pctypes %} | Types: {{ r.pctypes | join(', ') }}{% endif %}">
|
||||
<td>
|
||||
<span class="mono">{{ r.model }}</span>
|
||||
{% if r.manufacturer %}<div class="small text-muted">{{ r.manufacturer }}</div>{% endif %}
|
||||
</td>
|
||||
<td class="mono">{{ r.skus | join(', ') or '-' }}</td>
|
||||
<td class="mono">{{ r.last_bios_version or '-' }}</td>
|
||||
{% for image, os_label in images %}<td>{{ driver_badge(r.drivers[image]) }}{{ update_hint(hints.drivers, r.drivers[image].file) }}</td>{% endfor %}
|
||||
<td>{{ bios_badge(r.bios) }}{{ update_hint(hints.bios, r.bios.file) }}</td>
|
||||
<td class="mono text-nowrap">{{ r.last_seen | local_dt }}<div class="small text-muted">{{ r.count }} boot{{ 's' if r.count != 1 }}</div></td>
|
||||
<td class="text-end text-nowrap">
|
||||
{% if r.gap %}
|
||||
<form method="post" action="{{ url_for('hardware_dismiss') }}" style="display:inline;">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="model" value="{{ r.model }}">
|
||||
<input type="hidden" name="done" value="{{ '0' if r.dismissed else '1' }}">
|
||||
<button type="submit" class="pxe-btn pxe-btn-ghost btn-row-action">{{ 'Reopen' if r.dismissed else 'Dismiss' }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 10px; padding: 16px 24px">
|
||||
<div role="search" style="flex: 1 1 220px; min-width: 160px; display: flex">
|
||||
<label for="hq" class="vh">Search by model, SKU, serial, PC type or driver file</label>
|
||||
<input id="hq" type="search" class="field" style="flex: 1; min-width: 0" placeholder="Model, SKU, serial, PC type or file">
|
||||
</div>
|
||||
<div role="group" aria-label="Show only" id="hfilters" style="display: flex; gap: 6px; flex-wrap: wrap">
|
||||
<button type="button" class="fbtn" data-f="" aria-pressed="true">All {{ rows | length }}</button>
|
||||
<button type="button" class="fbtn" data-f="gap" aria-pressed="false">Gaps {{ gaps | length }}</button>
|
||||
<button type="button" class="fbtn" data-f="dismissed" aria-pressed="false">Dismissed {{ dismissed | length }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hgrid hhead" aria-hidden="true">
|
||||
<span>Model</span>
|
||||
<span class="cov">{% for image, os_label in images %}<span title="{{ image }} ({{ os_label }})">{{ image | replace('gea-', '') }}</span>{% endfor %}</span>
|
||||
<span>BIOS</span><span class="h-seen">Last seen</span><span></span>
|
||||
</div>
|
||||
|
||||
{% for r in rows %}
|
||||
<div class="ui-row hrow" data-f="{{ 'gap ' if r.gap and not r.dismissed }}{{ 'dismissed' if r.gap and r.dismissed }}"
|
||||
data-q="{{ (r.model ~ ' ' ~ (r.manufacturer or '') ~ ' ' ~ (r.skus | join(' ')) ~ ' ' ~ (r.serials | join(' ')) ~ ' ' ~ (r.pctypes | join(' ')) ~ ' ' ~ (r.drivers.values() | map(attribute='file', default='') | join(' ')) ~ ' ' ~ (r.bios.file or '')) | lower }}">
|
||||
<details>
|
||||
<summary class="hgrid">
|
||||
<span class="h-model">
|
||||
<span class="tip" data-tip="{{ r.model }}"><span class="trunc" style="font-weight: 600">{{ r.model }}</span></span>
|
||||
{% if r.manufacturer %}<span class="mfr">{{ r.manufacturer }}</span>{% endif %}
|
||||
{% if r.gap and r.dismissed %}{{ ui.chip('Dismissed', 'off') }}{% endif %}
|
||||
</span>
|
||||
<span class="cov">
|
||||
{% for image, os_label in images %}
|
||||
<span><span class="ilab">{{ image | replace('gea-', '') }}</span>{{ driver_chip(r.drivers[image]) }}{{ new_chip(hints.drivers, r.drivers[image].file) }}</span>
|
||||
{% endfor %}
|
||||
</span>
|
||||
<span class="h-bios"><span class="ilab">BIOS</span>{{ bios_chip(r.bios) }}{{ new_chip(hints.bios, r.bios.file) }}</span>
|
||||
<span class="h-seen mono">{{ r.last_seen | local_dt }}</span>
|
||||
<span class="chev" aria-hidden="true">▾</span>
|
||||
</summary>
|
||||
<div class="hbody">
|
||||
<dl class="hfacts">
|
||||
<div><dt>Model (WMI)</dt><dd class="mono">{{ r.model }}</dd></div>
|
||||
{% if r.manufacturer %}<div><dt>Manufacturer</dt><dd>{{ r.manufacturer }}</dd></div>{% endif %}
|
||||
<div><dt>System SKU</dt><dd class="mono">{{ r.skus | join(', ') or '-' }}</dd></div>
|
||||
<div><dt>Serials</dt><dd class="mono">{{ r.serials | join(', ') or '-' }}</dd></div>
|
||||
<div><dt>PC types</dt><dd>{{ r.pctypes | join(', ') or '-' }}</dd></div>
|
||||
<div><dt>BIOS versions seen</dt><dd class="mono">{{ r.bios_versions | join(', ') or '-' }}{% if r.last_bios_version %} <span class="sub">(last {{ r.last_bios_version }})</span>{% endif %}</dd></div>
|
||||
<div><dt>Last seen</dt><dd>{{ r.last_seen | local_dt }}, {{ r.count }} boot{{ 's' if r.count != 1 }}</dd></div>
|
||||
<div><dt>First seen</dt><dd>{{ r.first_seen | local_dt }}</dd></div>
|
||||
</dl>
|
||||
<div class="hfiles">
|
||||
{% for image, os_label in images %}
|
||||
{% set d = r.drivers[image] %}
|
||||
<div class="hfile">
|
||||
<span>{{ image }} <span class="sub">{{ os_label }}</span></span>
|
||||
<span>{% if drv_chip.get(d.status) %}{{ ui.chip(drv_chip[d.status][0], drv_chip[d.status][1]) }}{% else %}<span class="sub">Not on server</span>{% endif %}</span>
|
||||
<span>
|
||||
{% if d.status == 'no-entry' %}No HardwareDriver.json entry matches this model.
|
||||
{% elif d.status == 'zip-missing' %}<span class="mono">{{ d.file }}</span> matches, but the zip is not on the server.
|
||||
{% elif d.file %}<span class="mono">{{ d.file }}</span>{% endif %}
|
||||
{% if d.file and d.file | lower in hints.drivers %} <a href="{{ url_for('dell_updates.page') }}">Dell has {{ hints.drivers[d.file | lower] or 'a newer version' }}</a>{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div class="hfile">
|
||||
<span>BIOS</span>
|
||||
<span>{{ bios_chip(r.bios) }}</span>
|
||||
<span>
|
||||
{% if r.bios.status == 'none' %}No line in the WinPE BIOS manifest matches this model.
|
||||
{% elif r.bios.status == 'file-missing' %}<span class="mono">{{ r.bios.file }}</span> is not in the BIOS folder.
|
||||
{% elif r.bios.file %}<span class="mono">{{ r.bios.file }}</span>{% endif %}
|
||||
{% if r.bios.file and r.bios.file | lower in hints.bios %} <a href="{{ url_for('dell_updates.page') }}">Dell has {{ hints.bios[r.bios.file | lower] or 'a newer version' }}</a>{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
{% if r.gap %}
|
||||
<div class="act">
|
||||
{% call ui.more(r.model) %}
|
||||
<form method="post" action="{{ url_for('hardware_dismiss') }}">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="model" value="{{ r.model }}">
|
||||
<input type="hidden" name="done" value="{{ '0' if r.dismissed else '1' }}">
|
||||
<button type="submit">{{ 'Reopen gap' if r.dismissed else 'Dismiss gap' }}</button>
|
||||
</form>
|
||||
{% endcall %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div class="hidden-bar" id="hnomatch" hidden><span>No model matches.</span></div>
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-5">
|
||||
<p class="mb-1">No machines have reported yet.</p>
|
||||
<p class="small">Models appear here the next time a machine boots WinPE from this server.</p>
|
||||
<div class="hl-empty">
|
||||
No machines have reported yet. Models appear here the next time a machine boots WinPE from this server.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
(function () {
|
||||
var q = document.getElementById('hq');
|
||||
if (!q) return;
|
||||
var f = '';
|
||||
function apply() {
|
||||
var needle = q.value.trim().toLowerCase(), any = false;
|
||||
document.querySelectorAll('.hrow').forEach(function (r) {
|
||||
var ok = (!needle || r.getAttribute('data-q').indexOf(needle) >= 0) && (!f || r.getAttribute('data-f').indexOf(f) >= 0);
|
||||
r.hidden = !ok; if (ok) any = true;
|
||||
});
|
||||
document.getElementById('hnomatch').hidden = any;
|
||||
}
|
||||
q.addEventListener('input', apply);
|
||||
document.querySelectorAll('#hfilters .fbtn').forEach(function (b) {
|
||||
b.addEventListener('click', function () {
|
||||
f = b.getAttribute('data-f') || '';
|
||||
document.querySelectorAll('#hfilters .fbtn').forEach(function (x) { x.setAttribute('aria-pressed', x === b ? 'true' : 'false'); });
|
||||
apply();
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,270 +1,305 @@
|
||||
{% extends "base.html" %}
|
||||
{% import '_ui.html' as ui %}
|
||||
{% block title %}{{ friendly_name }} - OS and Packages{% endblock %}
|
||||
|
||||
{# OS and Packages tab. Each list is a <table> whose rows are drawn as one
|
||||
line plus an expandable body, because static/app.js removes a row with
|
||||
.remove-row by its closest <tr> and renumbers .order-num in the tbody.
|
||||
Edits are staged in the page; the save bar's Review and save posts only
|
||||
the sections that changed (views/images.py os_preview / os_save). #}
|
||||
|
||||
{% block extra_head %}
|
||||
<style>
|
||||
.osp-table td { vertical-align: top; white-space: normal; }
|
||||
.osp-table td.num { width: 40px; color: var(--text-light); font-weight: 600; text-align: center; }
|
||||
.osp-table .wrap { overflow-wrap: anywhere; }
|
||||
.osp-table .sub { font-size: 12px; color: var(--text-light); }
|
||||
.osp-table .form-control-sm { min-width: 10rem; }
|
||||
.osp-table tr.row-new td { background: rgba(65, 129, 255, 0.06); }
|
||||
#ospModelTable td { vertical-align: middle; }
|
||||
.osp-note { font-size: 13px; color: var(--text-light); padding: 0.75rem 1.25rem; border-top: 1px solid var(--border); }
|
||||
.osp-explain { font-size: 13.5px; }
|
||||
.osp-explain p:last-child { margin-bottom: 0; }
|
||||
.dirty-flag { font-size: 12px; color: var(--warning-dark); display: none; align-items: center; gap: 0.35rem; }
|
||||
[data-theme="dark"] .dirty-flag { color: var(--warning); }
|
||||
.dirty .dirty-flag { display: inline-flex; }
|
||||
.empty-row td { text-align: center; color: var(--text-light); padding: 1.25rem; }
|
||||
.osp-list, .osp-list tbody, .osp-list tr, .osp-list td { display: block; }
|
||||
.osp-list { width: 100%; border-collapse: collapse; margin: 0; }
|
||||
.osp-list td { padding: 0; }
|
||||
.osp-list tr.row-new > td { background: var(--edit-soft); }
|
||||
.osp-phead { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; padding: 16px 24px 12px; }
|
||||
.osp-phead h2 { margin: 0; font-size: 16px; font-weight: 600; }
|
||||
.osp-phead .sp { margin-left: auto; display: flex; align-items: center; gap: 10px; }
|
||||
.osp-note { font-size: 13px; color: var(--text-2); padding: 12px 24px; border-top: 1px solid var(--line); line-height: 1.55; }
|
||||
.osp-list td.osp-empty { padding: 20px 24px; text-align: center; color: var(--muted); border-top: 1px solid var(--line); font-size: 14px; }
|
||||
.osp-head.og, .osp-head.pg, .osp-head.mg { font-size: 12px; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); padding-top: 6px !important; padding-bottom: 6px !important; }
|
||||
.osp-list .num { color: var(--muted); font-size: 13px; }
|
||||
.osp-list .chips { display: flex; gap: 6px; flex-wrap: nowrap; min-width: 0; }
|
||||
.osp-list .name { font-weight: 600; }
|
||||
|
||||
/* Operating systems */
|
||||
.og { display: grid; grid-template-columns: 26px minmax(0, 1fr) 54px 150px 190px minmax(0, 1fr) 14px; align-items: center;
|
||||
gap: 0 14px; padding: 11px 76px 11px 24px; font-size: 14px; min-height: 24px; }
|
||||
/* Packages */
|
||||
.pg { display: grid; grid-template-columns: 26px minmax(0, 1.2fr) minmax(0, 1fr) 64px 82px 92px 14px; align-items: center;
|
||||
gap: 0 14px; padding: 11px 130px 11px 24px; font-size: 14px; min-height: 24px; }
|
||||
/* Hardware models (inputs on the row, no body) */
|
||||
.mg { display: grid; grid-template-columns: 26px minmax(0, 1fr) minmax(0, 1.3fr) 190px 84px; align-items: center;
|
||||
gap: 0 14px; padding: 8px 24px; font-size: 14px; }
|
||||
.mg .field { width: 100%; min-width: 0; height: 32px; font-size: 13px; }
|
||||
.mg .ents { display: flex; align-items: center; gap: 8px; white-space: nowrap; min-width: 0; }
|
||||
.mg > :last-child { justify-self: end; }
|
||||
|
||||
.obody { padding: 2px 24px 16px 64px; font-size: 13px; }
|
||||
.obody dl { margin: 0; display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 5px 16px; }
|
||||
.obody dt { color: var(--muted); font-weight: 400; }
|
||||
.obody dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
|
||||
.osp-list .act { display: flex; align-items: center; gap: 10px; }
|
||||
.osp-list .act .form-switch { margin: 0; padding-left: 2.5em; min-height: 0; display: flex; align-items: center; }
|
||||
.osp-list .act .form-check-input { margin-top: 0; cursor: pointer; }
|
||||
|
||||
.osp-models > summary { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; padding: 16px 24px; }
|
||||
.osp-models > summary h2 { margin: 0; font-size: 16px; font-weight: 600; }
|
||||
.osp-models .explain { padding: 0 24px 14px; font-size: 13px; color: var(--text-2); line-height: 1.6; max-width: 980px; }
|
||||
.osp-models .explain p { margin: 0 0 8px; }
|
||||
.osp-intro { margin: 0; font-size: 14px; color: var(--text-2); line-height: 1.6; max-width: 980px; }
|
||||
|
||||
.savebar { position: sticky; bottom: 12px; z-index: 50; display: none; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px;
|
||||
padding: 12px 12px 12px 20px; border-radius: 12px; background: var(--savebar-bg); border: 1px solid var(--savebar-edge);
|
||||
color: var(--savebar-text); box-shadow: 0 8px 24px rgba(0,0,0,.25); }
|
||||
.dirty .savebar { display: flex; }
|
||||
.savebar .ui-btn { background: transparent; color: var(--savebar-text); border-color: #8E9AAB; }
|
||||
.savebar .ui-btn.pri { background: var(--savebar-pri); border-color: var(--savebar-pri); color: var(--on-savebar-pri); }
|
||||
.savebar .dirty-flag { display: inline-flex; align-items: center; gap: 8px; font-size: 14px; }
|
||||
.savebar .dirty-flag .dot { background: var(--act); }
|
||||
|
||||
@container (max-width: 1000px) {
|
||||
.og { grid-template-columns: 26px minmax(0, 1fr) 150px 190px 14px; }
|
||||
.og .c-id, .og .c-dest { display: none; }
|
||||
.pg { grid-template-columns: 26px minmax(0, 1fr) 82px 92px 14px; }
|
||||
.pg .c-file, .pg .c-id { display: none; }
|
||||
.mg { grid-template-columns: 26px minmax(0, 1fr) minmax(0, 1fr) auto 84px; }
|
||||
.mg .ents .sub { display: none; }
|
||||
.obody { padding-left: 24px; }
|
||||
}
|
||||
@container (max-width: 600px) {
|
||||
.og, .pg { grid-template-columns: 22px auto minmax(0, 1fr) 14px; padding: 11px 56px 11px 16px; gap: 6px 10px; }
|
||||
.pg { padding-right: 108px; }
|
||||
.og > .tip:not(.c-dest), .pg > .tip:not(.c-file) { grid-column: 2 / 4; grid-row: 1; }
|
||||
.og .c-st, .pg .c-en { grid-column: 2; grid-row: 2; }
|
||||
.og .c-wim, .pg .c-disk { grid-column: 3; grid-row: 2; flex-wrap: wrap; }
|
||||
.og .chev, .pg .chev { grid-column: 4; grid-row: 1; }
|
||||
.osp-head { display: none; }
|
||||
.osp-list .ui-row > .act { right: 8px; gap: 4px; }
|
||||
.mg { grid-template-columns: 22px minmax(0, 1fr) auto; padding: 10px 16px; gap: 6px 10px; }
|
||||
.mg .c-model { grid-column: 2; grid-row: 1; }
|
||||
.mg .c-fam { grid-column: 2 / -1; grid-row: 2; }
|
||||
.mg .ents { grid-column: 2; grid-row: 3; padding: 0; }
|
||||
.mg > :last-child { grid-column: 3; grid-row: 1; }
|
||||
.obody { padding: 2px 16px 14px; }
|
||||
.osp-phead, .osp-note, .osp-models > summary, .osp-models .explain { padding-left: 16px; padding-right: 16px; }
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% set header_actions %}
|
||||
<span class="dirty-flag" id="ospDirty"><i class="bi bi-circle-fill" style="font-size:8px;"></i> Unsaved changes</span>
|
||||
{% if last_save %}
|
||||
<form method="POST" action="{{ url_for('images.os_undo', image_type=image_type) }}" class="d-inline"
|
||||
onsubmit="return confirm('Put back {{ last_save.files | map(attribute='name') | join(', ') }} as they were before the save of {{ last_save.ts | local_dt }}?');">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-outline-secondary btn-sm" title="Last save: {{ last_save.ts | local_dt }}">
|
||||
<i class="bi bi-arrow-counterclockwise"></i> Undo last save
|
||||
</button>
|
||||
<button type="submit" class="ui-btn" title="Last save: {{ last_save.ts | local_dt }}">Undo last save</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<span class="sub">Undo last save: nothing saved from this page yet</span>
|
||||
{% endif %}
|
||||
<button type="button" class="btn btn-success btn-sm" id="ospReview" disabled>
|
||||
<i class="bi bi-file-earmark-diff"></i> Review and save
|
||||
</button>
|
||||
{% endset %}
|
||||
{% set header_subtitle = 'Deploy/Control' %}
|
||||
{% include "_image_header.html" %}
|
||||
|
||||
<p class="text-muted osp-explain">
|
||||
<div class="ui-stack" id="ospPage">
|
||||
<p class="osp-intro">
|
||||
What PESetup puts on the disk besides drivers: the Windows image from
|
||||
<span class="mono">OperatingSystem.json</span>, then the offline servicing packages from
|
||||
<span class="mono">packages.json</span> (both in <span class="mono">Deploy/Control</span>).
|
||||
Edits here are staged until <strong>Review and save</strong>, which shows the exact diff of every file first.
|
||||
<span class="mono">packages.json</span>. Edits are staged until <strong>Review and save</strong>,
|
||||
which shows the exact diff of every file first.
|
||||
</p>
|
||||
|
||||
{# ==================== Operating system ==================== #}
|
||||
<div class="card" data-section="operating_systems">
|
||||
<div class="card-header">
|
||||
<span class="d-flex align-items-center gap-2">
|
||||
<i class="bi bi-windows"></i> Operating system
|
||||
<span class="badge badge-secondary">{{ oses | length }}</span>
|
||||
</span>
|
||||
<span class="small text-muted fw-normal mono">OperatingSystem.json</span>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<table class="table mb-0 osp-table" id="ospOsTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Windows</th>
|
||||
<th>osId</th>
|
||||
<th>Status</th>
|
||||
<th>install.wim</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in oses %}
|
||||
{% set osv = r.osv %}
|
||||
<tr data-json='{{ r.entry | tojson_ordered }}'>
|
||||
<td class="num order-num">{{ loop.index }}</td>
|
||||
<td>
|
||||
<strong>{{ osv.productName or osv.marketingName or 'Windows' }}</strong>
|
||||
<div class="sub">{{ osv.marketingName }}{% if osv.versionNumber %} | version {{ osv.versionNumber }}{% endif %}{% if osv.buildNumber %} | build {{ osv.buildNumber }}{% endif %}</div>
|
||||
</td>
|
||||
<td class="mono">{{ osv.id }}</td>
|
||||
<td class="text-nowrap">
|
||||
{% if osv.isActive %}<span class="badge badge-success">Active</span>{% else %}<span class="badge badge-secondary">Inactive</span>{% endif %}
|
||||
{% if os_selection is not none and osv.id | string == os_selection | string %}<span class="badge badge-primary" title="user_selections.json OperatingSystemSelection">Selected</span>{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div class="mono wrap">{{ r.dest or '(no DestinationDir)' }}</div>
|
||||
<div class="sub">
|
||||
{% if r.wim.exists %}<span class="badge badge-success">On disk</span> {{ r.wim.size | filesizeformat }}, modified {{ r.wim.mtime | local_dt }}
|
||||
{% else %}<span class="badge badge-danger">install.wim not on disk</span>{% endif %}
|
||||
| index {{ osv.wimindex if osv.wimindex is defined else (r.entry.wimindex if r.entry.wimindex is defined else '-') }}
|
||||
{% if osv.wim and osv.wim.size and r.wim.exists and osv.wim.size != r.wim.size %}
|
||||
<span class="badge badge-warning" title="size in OperatingSystem.json: {{ osv.wim.size }}">size differs from the JSON</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<button type="button" class="btn btn-outline-danger btn-row-action remove-row" title="Remove this entry">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
<tr class="empty-row" {% if oses %}style="display:none"{% endif %}><td colspan="6">No operating system entries. PESetup has nothing to apply.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<section class="panel ui-list" data-section="operating_systems" aria-labelledby="osp-os-h">
|
||||
<div class="osp-phead">
|
||||
<h2 id="osp-os-h">Operating system</h2>
|
||||
{{ ui.chip(oses | length, 'neutral') }}
|
||||
<span class="sp"><span class="sub mono">OperatingSystem.json</span></span>
|
||||
</div>
|
||||
<div class="og osp-head" aria-hidden="true"><span>#</span><span>Windows</span><span class="c-id">osId</span><span class="c-st">Status</span><span class="c-wim">install.wim</span><span class="c-dest">Folder</span><span></span></div>
|
||||
<table class="osp-list" id="ospOsTable">
|
||||
<tbody>
|
||||
{% for r in oses %}
|
||||
{% set osv = r.osv %}
|
||||
{% set idx = osv.wimindex if osv.wimindex is defined else (r.entry.wimindex if r.entry.wimindex is defined else '-') %}
|
||||
{% set size_differs = osv.wim and osv.wim.size and r.wim.exists and osv.wim.size != r.wim.size %}
|
||||
<tr data-json='{{ r.entry | tojson_ordered }}'><td class="ui-row">
|
||||
<details>
|
||||
<summary class="og">
|
||||
<span class="num order-num">{{ loop.index }}</span>
|
||||
<span class="tip" data-tip="{{ osv.productName or osv.marketingName or 'Windows' }}{% if osv.versionNumber %} {{ osv.versionNumber }}{% endif %}"><span class="trunc"><span class="name">{{ osv.productName or osv.marketingName or 'Windows' }}</span> <span class="muted">{{ osv.versionNumber }}</span></span></span>
|
||||
<span class="mono c-id">{{ osv.id }}</span>
|
||||
<span class="chips c-st">
|
||||
{% if osv.isActive %}{{ ui.chip('Active', 'ok') }}{% else %}{{ ui.chip('Inactive', 'off') }}{% endif %}
|
||||
{% if os_selection is not none and osv.id | string == os_selection | string %}{{ ui.chip('Selected', 'w11', 'user_selections.json OperatingSystemSelection') }}{% endif %}
|
||||
</span>
|
||||
<span class="chips c-wim">{% if r.wim.exists %}{{ ui.chip('On disk', 'ok') }}{% else %}{{ ui.chip('Not on disk', 'err') }}{% endif %}{% if size_differs %}{{ ui.chip('Size differs', 'warn') }}{% endif %}</span>
|
||||
<span class="tip right c-dest" data-tip="{{ r.dest or '(no DestinationDir)' }}"><span class="trunc mono sub">{{ r.dest or '(no DestinationDir)' }}</span></span>
|
||||
<span class="chev" aria-hidden="true">▾</span>
|
||||
</summary>
|
||||
<div class="obody"><dl>
|
||||
<dt>Windows</dt><dd>{{ osv.productName or '-' }}{% if osv.marketingName %} ({{ osv.marketingName }}){% endif %}</dd>
|
||||
<dt>Version</dt><dd>{{ osv.versionNumber or '-' }}{% if osv.buildNumber %}, build {{ osv.buildNumber }}{% endif %}, osId <span class="mono">{{ osv.id }}</span></dd>
|
||||
<dt>DestinationDir</dt><dd class="mono">{{ r.dest or '(no DestinationDir)' }}</dd>
|
||||
<dt>install.wim</dt><dd>{% if r.wim.exists %}On disk, {{ r.wim.size | filesizeformat }}, modified {{ r.wim.mtime | local_dt }}{% else %}<strong>install.wim not on disk</strong>{% endif %}, image index {{ idx }}</dd>
|
||||
{% if size_differs %}<dt>Size</dt><dd>Differs from the JSON: OperatingSystem.json says {{ osv.wim.size | filesizeformat }} ({{ osv.wim.size }} bytes)</dd>{% endif %}
|
||||
</dl></div>
|
||||
</details>
|
||||
<div class="act">
|
||||
{% call ui.more('operating system ' ~ loop.index) %}<button type="button" class="remove-row del">Remove entry</button>{% endcall %}
|
||||
</div>
|
||||
</td></tr>
|
||||
{% endfor %}
|
||||
<tr class="empty-row" {% if oses %}style="display:none"{% endif %}><td class="osp-empty">No operating system entries. PESetup has nothing to apply.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="osp-note">
|
||||
PESetup applies the image index of <span class="mono">install.wim</span> from the entry's folder
|
||||
(<span class="mono">*destinationdir*</span> is this image's share).
|
||||
{% if os_selection is not none %}The entry marked Selected is the one the media was built for
|
||||
(<span class="mono">OperatingSystemSelection = {{ os_selection }}</span>).{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{# ==================== Packages ==================== #}
|
||||
<div class="card" data-section="packages">
|
||||
<div class="card-header">
|
||||
<span class="d-flex align-items-center gap-2">
|
||||
<i class="bi bi-box-seam"></i> Packages
|
||||
<span class="badge badge-secondary">{{ packages | length }}</span>
|
||||
<section class="panel ui-list" data-section="packages" aria-labelledby="osp-pk-h">
|
||||
<div class="osp-phead">
|
||||
<h2 id="osp-pk-h">Packages</h2>
|
||||
{{ ui.chip(packages | length, 'neutral') }}
|
||||
<span class="sp">
|
||||
<span class="sub mono">packages.json</span>
|
||||
<button type="button" class="ui-btn" data-bs-toggle="modal" data-bs-target="#packageUploadModal">Upload file</button>
|
||||
</span>
|
||||
<div class="header-actions">
|
||||
<span class="small text-muted fw-normal mono align-self-center">packages.json</span>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary"
|
||||
data-bs-toggle="modal" data-bs-target="#packageUploadModal">
|
||||
<i class="bi bi-upload"></i> Upload file
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<table class="table mb-0 osp-table" id="ospPkgTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Package</th>
|
||||
<th>File</th>
|
||||
<th>osId</th>
|
||||
<th>Enabled</th>
|
||||
<th>On disk</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in packages %}
|
||||
{% set p = r.entry %}
|
||||
<tr data-json='{{ p | tojson_ordered }}'>
|
||||
<td class="num order-num">{{ loop.index }}</td>
|
||||
<td>
|
||||
<strong>{{ p.name or '(no name)' }}</strong>
|
||||
{% if p.comment %}<div class="sub">{{ p.comment }}</div>{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div class="mono wrap">{{ p.fileName or p.FileName or '' }}</div>
|
||||
<div class="sub mono wrap">{{ p.destinationDir or p.DestinationDir or '' }}</div>
|
||||
</td>
|
||||
<td class="mono">{{ p.osId }}</td>
|
||||
<td>
|
||||
<div class="form-check form-switch m-0">
|
||||
<input class="form-check-input pkg-enabled" type="checkbox" role="switch"
|
||||
aria-label="Enabled" {% if p.enabled %}checked{% endif %}>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-nowrap">
|
||||
{% if r.file.exists %}<span class="badge badge-success">Yes</span><div class="sub">{{ r.file.size | filesizeformat }}</div>
|
||||
{% else %}<span class="badge badge-danger">No</span>{% endif %}
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<button type="button" class="btn btn-outline-danger btn-row-action remove-row" title="Remove this entry">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
<tr class="empty-row" {% if packages %}style="display:none"{% endif %}><td colspan="7">No packages configured.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pg osp-head" aria-hidden="true"><span>#</span><span>Package</span><span class="c-file">File</span><span class="c-id">osId</span><span class="c-en">Enabled</span><span class="c-disk">On disk</span><span></span></div>
|
||||
<table class="osp-list" id="ospPkgTable">
|
||||
<tbody>
|
||||
{% for r in packages %}
|
||||
{% set p = r.entry %}
|
||||
{% set fname = p.fileName or p.FileName or '' %}
|
||||
{% set pdest = p.destinationDir or p.DestinationDir or '' %}
|
||||
<tr data-json='{{ p | tojson_ordered }}'><td class="ui-row">
|
||||
<details>
|
||||
<summary class="pg">
|
||||
<span class="num order-num">{{ loop.index }}</span>
|
||||
<span class="tip" data-tip="{{ p.name or '(no name)' }}"><span class="trunc name">{{ p.name or '(no name)' }}</span></span>
|
||||
<span class="tip c-file" data-tip="{{ fname }}"><span class="trunc mono sub">{{ fname }}</span></span>
|
||||
<span class="mono c-id">{{ p.osId }}</span>
|
||||
<span class="c-en pkg-state">{% if p.enabled %}{{ ui.chip('Enabled', 'ok') }}{% else %}{{ ui.chip('Disabled', 'off') }}{% endif %}</span>
|
||||
<span class="c-disk">{% if r.file.exists %}{{ ui.chip('On disk', 'ok') }}{% else %}{{ ui.chip('Missing', 'err') }}{% endif %}</span>
|
||||
<span class="chev" aria-hidden="true">▾</span>
|
||||
</summary>
|
||||
<div class="obody"><dl>
|
||||
{% if p.comment %}<dt>Comment</dt><dd>{{ p.comment }}</dd>{% endif %}
|
||||
<dt>File</dt><dd class="mono">{{ fname or '-' }}</dd>
|
||||
<dt>DestinationDir</dt><dd class="mono">{{ pdest or '-' }}</dd>
|
||||
<dt>On disk</dt><dd>{% if r.file.exists %}Yes, {{ r.file.size | filesizeformat }}{% else %}<strong>No</strong>: the file is not in that folder{% endif %}</dd>
|
||||
<dt>osId</dt><dd class="mono">{{ p.osId }}</dd>
|
||||
</dl></div>
|
||||
</details>
|
||||
<div class="act">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input pkg-enabled" type="checkbox" role="switch"
|
||||
aria-label="Enabled: {{ p.name or fname }}" {% if p.enabled %}checked{% endif %}>
|
||||
</div>
|
||||
{% call ui.more('package ' ~ loop.index) %}<button type="button" class="remove-row del">Remove entry</button>{% endcall %}
|
||||
</div>
|
||||
</td></tr>
|
||||
{% endfor %}
|
||||
<tr class="empty-row" {% if packages %}style="display:none"{% endif %}><td class="osp-empty">No packages configured.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="osp-note">
|
||||
A package is applied when it is enabled and its osId list contains the OS being installed.
|
||||
Removing an entry here leaves the file on disk.
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{# ==================== Hardware models (user_selections.json) ==================== #}
|
||||
<div class="card" data-section="hardware_models">
|
||||
<div class="card-header">
|
||||
<span class="d-flex align-items-center gap-2">
|
||||
<i class="bi bi-cpu"></i> Hardware Models
|
||||
<span class="badge badge-secondary">{{ models | length }}</span>
|
||||
</span>
|
||||
<div class="header-actions">
|
||||
<span class="small text-muted fw-normal mono align-self-center">Tools/user_selections.json</span>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" id="ospAddModel" {% if not has_selections %}disabled{% endif %}>
|
||||
<i class="bi bi-plus-lg"></i> Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body osp-explain border-bottom">
|
||||
<p class="mb-2">
|
||||
<strong>Media build selection, not used while imaging.</strong>
|
||||
{% set n_none = models | selectattr('fam.entries', 'equalto', 0) | list | length %}
|
||||
<details class="panel ui-list osp-models" data-section="hardware_models" {% if not models %}open{% endif %}>
|
||||
<summary>
|
||||
<span class="chev" aria-hidden="true">▾</span>
|
||||
<h2>Hardware models</h2>
|
||||
{{ ui.chip(models | length, 'neutral') }}
|
||||
{% if n_none %}{{ ui.chip(n_none ~ ' with no driver entry', 'warn') }}{% endif %}
|
||||
<span class="sub">Media build selection, not used while imaging</span>
|
||||
</summary>
|
||||
<div class="explain">
|
||||
<p>
|
||||
Media Creator Lite saved this list when the media was built: the driver families (Family ID) whose packs it
|
||||
downloaded, and the OS it built for. <span class="mono">scripts/Download-Drivers.ps1</span> and
|
||||
<span class="mono">Upload-Image.ps1</span> read it to choose which driver packs and OS to fetch and upload.
|
||||
</p>
|
||||
<p class="text-muted">
|
||||
<p>
|
||||
PESetup never reads this file: at imaging it matches the PC's WMI model against
|
||||
<a href="{{ image_tab_url(image_type, 'drivers') }}">HardwareDriver.json</a> alone, so adding a model here does not
|
||||
give a PC drivers. The Driver entries column counts map entries with the same <span class="mono">family</span>.
|
||||
give a PC drivers. Driver entries counts map entries with the same <span class="mono">family</span>.
|
||||
</p>
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 10px; align-items: center">
|
||||
<span class="sub mono">Tools/user_selections.json</span>
|
||||
<button type="button" class="ui-btn" id="ospAddModel" {% if not has_selections %}disabled{% endif %}>Add model</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<table class="table mb-0 osp-table" id="ospModelTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Model</th>
|
||||
<th>Family ID</th>
|
||||
<th>Driver entries</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in models %}
|
||||
<tr data-json='{{ r.m | tojson_ordered }}'>
|
||||
<td class="num order-num">{{ loop.index }}</td>
|
||||
<td><input type="text" class="form-control form-control-sm" data-field="Model" value="{{ r.m.Model }}" aria-label="Model"></td>
|
||||
<td><input type="text" class="form-control form-control-sm" data-field="Id" value="{{ r.m.Id }}" aria-label="Family ID"></td>
|
||||
<td class="text-nowrap">
|
||||
<div class="mg osp-head" aria-hidden="true"><span>#</span><span class="c-model">Model</span><span class="c-fam">Family ID</span><span class="ents">Driver entries</span><span></span></div>
|
||||
<table class="osp-list" id="ospModelTable">
|
||||
<tbody>
|
||||
{% for r in models %}
|
||||
<tr data-json='{{ r.m | tojson_ordered }}'><td class="ui-row">
|
||||
<div class="mg">
|
||||
<span class="num order-num">{{ loop.index }}</span>
|
||||
<input type="text" class="field c-model" data-field="Model" value="{{ r.m.Model }}" aria-label="Model">
|
||||
<input type="text" class="field c-fam" data-field="Id" value="{{ r.m.Id }}" aria-label="Family ID">
|
||||
<span class="ents tip right"{% if r.fam.entries %} data-tip="{{ r.fam.on_disk }} of {{ r.fam.entries }} zips on disk"{% endif %}>
|
||||
{% if r.fam.entries %}
|
||||
<span class="badge {{ 'badge-success' if r.fam.on_disk == r.fam.entries else 'badge-warning' }}">{{ r.fam.entries }} entr{{ 'ies' if r.fam.entries != 1 else 'y' }}</span>
|
||||
<div class="sub">{{ r.fam.on_disk }} zip{{ 's' if r.fam.on_disk != 1 }} on disk</div>
|
||||
{{ ui.chip(r.fam.entries ~ ' entr' ~ ('ies' if r.fam.entries != 1 else 'y'), 'ok' if r.fam.on_disk == r.fam.entries else 'warn') }}
|
||||
<span class="sub">{{ r.fam.on_disk }} zip{{ 's' if r.fam.on_disk != 1 }} on disk</span>
|
||||
{% else %}
|
||||
<span class="badge badge-secondary">None</span>
|
||||
{{ ui.chip('None', 'off') }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<button type="button" class="btn btn-outline-danger btn-row-action remove-row" title="Remove this model">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
<tr class="empty-row" {% if models %}style="display:none"{% endif %}><td colspan="5">{% if has_selections %}No hardware models selected.{% else %}No user_selections.json for this image.{% endif %}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</span>
|
||||
<button type="button" class="ui-btn ghost sm remove-row" aria-label="Remove model {{ r.m.Model }}">Remove</button>
|
||||
</div>
|
||||
</td></tr>
|
||||
{% endfor %}
|
||||
<tr class="empty-row" {% if models %}style="display:none"{% endif %}><td class="osp-empty">{% if has_selections %}No hardware models selected.{% else %}No user_selections.json for this image.{% endif %}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</details>
|
||||
|
||||
<div class="savebar" role="region" aria-label="Unsaved changes">
|
||||
<span class="dirty-flag" id="ospDirty" role="status"><span class="dot" aria-hidden="true"></span> Unsaved changes</span>
|
||||
<div style="display: flex; gap: 8px">
|
||||
<button type="button" class="ui-btn" id="ospDiscard">Discard</button>
|
||||
<button type="button" class="ui-btn pri" id="ospReview" disabled>Review and save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Package upload modal (unchanged from the old Configuration page) -->
|
||||
<div class="modal fade" id="packageUploadModal" tabindex="-1">
|
||||
<!-- Package upload modal -->
|
||||
<div class="modal fade" id="packageUploadModal" tabindex="-1" aria-labelledby="pkgUploadTitle">
|
||||
<div class="modal-dialog">
|
||||
<form method="POST" action="{{ url_for('images_packages_upload', image_type=image_type) }}" enctype="multipart/form-data">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">Upload package</h3>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
<h3 class="modal-title" id="pkgUploadTitle">Upload package</h3>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-field">
|
||||
<label class="form-label">Package file</label>
|
||||
<input type="file" class="form-control" name="package_file" required>
|
||||
<div class="form-text">Lands in <code>{{ image_type }}/Deploy/Packages/</code>.</div>
|
||||
<label class="form-label" for="pkgUploadFile">Package file</label>
|
||||
<input type="file" class="form-control" name="package_file" id="pkgUploadFile" required>
|
||||
<div class="form-text">Lands in <span class="mono">{{ image_type }}/Deploy/Packages/</span>.</div>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label">Destination directory (optional)</label>
|
||||
<input type="text" class="form-control mono" name="destination_dir"
|
||||
<label class="form-label" for="pkgUploadDest">Destination directory (optional)</label>
|
||||
<input type="text" class="form-control mono" name="destination_dir" id="pkgUploadDest"
|
||||
placeholder="*destinationdir*\Packages">
|
||||
<div class="form-text">Setting this also appends an entry to <code>packages.json</code>.</div>
|
||||
<div class="form-text">Setting this also appends an entry to <span class="mono">packages.json</span>.</div>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="overwrite" value="1" id="pkgOverwriteCheck">
|
||||
@@ -272,8 +307,8 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Upload</button>
|
||||
<button type="button" class="ui-btn" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="ui-btn pri">Upload</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@@ -289,11 +324,13 @@
|
||||
var dirty = {};
|
||||
var reviewBtn = document.getElementById('ospReview');
|
||||
var head = document.querySelector('.image-head');
|
||||
var page = document.getElementById('ospPage');
|
||||
|
||||
function markDirty(section) {
|
||||
dirty[section] = true;
|
||||
reviewBtn.disabled = false;
|
||||
if (head) head.classList.add('dirty');
|
||||
if (page) page.classList.add('dirty');
|
||||
}
|
||||
|
||||
function rowsOf(tableId) {
|
||||
@@ -315,7 +352,15 @@
|
||||
document.querySelectorAll('[data-section]').forEach(function (card) {
|
||||
var section = card.getAttribute('data-section');
|
||||
card.addEventListener('input', function () { markDirty(section); });
|
||||
card.addEventListener('change', function () { markDirty(section); });
|
||||
card.addEventListener('change', function (e) {
|
||||
markDirty(section);
|
||||
// Keep the row's Enabled chip in step with its switch.
|
||||
if (e.target.classList.contains('pkg-enabled')) {
|
||||
var st = e.target.closest('tr').querySelector('.pkg-state');
|
||||
if (st) st.innerHTML = e.target.checked
|
||||
? '<span class="chip c-ok">Enabled</span>' : '<span class="chip c-off">Disabled</span>';
|
||||
}
|
||||
});
|
||||
card.addEventListener('click', function (e) {
|
||||
// app.js removes the row and renumbers (delegated on document, so it
|
||||
// runs after this); refresh the empty row once it is gone.
|
||||
@@ -334,11 +379,13 @@
|
||||
var tr = document.createElement('tr');
|
||||
tr.className = 'row-new';
|
||||
tr.innerHTML =
|
||||
'<td class="num order-num"></td>' +
|
||||
'<td><input type="text" class="form-control form-control-sm" data-field="Model" placeholder="Model name" aria-label="Model"></td>' +
|
||||
'<td><input type="text" class="form-control form-control-sm" data-field="Id" placeholder="Driver family ID" aria-label="Family ID"></td>' +
|
||||
'<td><span class="badge badge-info">New</span></td>' +
|
||||
'<td class="text-end"><button type="button" class="btn btn-outline-danger btn-row-action remove-row" title="Remove this model"><i class="bi bi-trash"></i></button></td>';
|
||||
'<td class="ui-row"><div class="mg">' +
|
||||
'<span class="num order-num"></span>' +
|
||||
'<input type="text" class="field c-model" data-field="Model" placeholder="Model name" aria-label="Model">' +
|
||||
'<input type="text" class="field c-fam" data-field="Id" placeholder="Driver family ID" aria-label="Family ID">' +
|
||||
'<span class="ents"><span class="chip c-edit">New</span></span>' +
|
||||
'<button type="button" class="ui-btn ghost sm remove-row" aria-label="Remove this model">Remove</button>' +
|
||||
'</div></td>';
|
||||
tbody.insertBefore(tr, tbody.querySelector('.empty-row'));
|
||||
rowsOf('ospModelTable').forEach(function (r, i) { r.querySelector('.order-num').textContent = i + 1; });
|
||||
refreshEmpty(tbody.closest('table'));
|
||||
@@ -391,6 +438,15 @@
|
||||
});
|
||||
});
|
||||
|
||||
var discardBtn = document.getElementById('ospDiscard');
|
||||
if (discardBtn) {
|
||||
discardBtn.addEventListener('click', function () {
|
||||
if (!confirm('Discard the unsaved changes on this page?')) return;
|
||||
dirty = {};
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener('beforeunload', function (e) {
|
||||
if (Object.keys(dirty).length) { e.preventDefault(); e.returnValue = ''; }
|
||||
});
|
||||
|
||||
@@ -1,261 +1,313 @@
|
||||
{% extends "base.html" %}
|
||||
{% import '_ui.html' as ui %}
|
||||
{% block title %}{{ friendly_name }} - Overview{% endblock %}
|
||||
|
||||
{# Overview of one image: what needs attention first (each item links to the
|
||||
tab that fixes it), a calm row of counts, the control files one line each
|
||||
(description in the row body), then compact lists of hardware gaps, newer
|
||||
Dell packs and missing zips. Deep links into the Drivers tab use its
|
||||
?q=, ?os=, ?missing=1 and ?findings=1 filters. #}
|
||||
|
||||
{% block extra_head %}
|
||||
<style>
|
||||
.ov-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; }
|
||||
.ov-tile {
|
||||
display: block; text-decoration: none; color: var(--text);
|
||||
background: var(--bg-card); border: 1px solid var(--border); border-radius: 0.25rem;
|
||||
border-left: 4px solid var(--border);
|
||||
padding: 0.9rem 1rem; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06);
|
||||
transition: border-color 0.15s;
|
||||
/* Needs attention */
|
||||
.ov-attn { list-style: none; margin: 0; padding: 0; }
|
||||
.ov-attn li { display: grid; grid-template-columns: 74px minmax(0, 1fr) auto; gap: 4px 14px; align-items: start;
|
||||
padding: 12px 24px; border-top: 1px solid var(--line); font-size: 14px; line-height: 1.5; }
|
||||
.ov-attn li .chip { justify-self: start; margin-top: 1px; }
|
||||
.ov-attn li a { white-space: nowrap; font-size: 13px; font-weight: 600; text-decoration: none; }
|
||||
.ov-attn li a:hover { text-decoration: underline; }
|
||||
.ov-clear { padding: 12px 24px 16px; border-top: 1px solid var(--line); font-size: 14px; color: var(--text-2); display: flex; gap: 10px; align-items: center; }
|
||||
.ov-phead { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; padding: 16px 24px 12px; }
|
||||
.ov-phead h2 { margin: 0; font-size: 16px; font-weight: 600; }
|
||||
.ov-phead .sp { margin-left: auto; font-size: 13px; }
|
||||
|
||||
/* Summary counts */
|
||||
.ov-stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
@container (max-width: 760px) { .ov-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
.ov-stat { display: flex; flex-direction: column; gap: 4px; padding: 16px 20px; text-decoration: none; color: var(--text);
|
||||
border-top: 1px solid var(--line); border-left: 1px solid var(--line); margin: 0 0 -1px -1px; min-width: 0; }
|
||||
a.ov-stat:hover { background: var(--surface-2); color: var(--text); }
|
||||
.ov-stat .lbl { font-size: 13px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.ov-stat .st { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.ov-stat .val { font-size: 24px; font-weight: 600; line-height: 1.2; }
|
||||
.ov-stat .val.txt { font-size: 15px; line-height: 1.35; }
|
||||
.ov-stat .det { font-size: 12px; color: var(--muted); }
|
||||
.ov-stats-wrap { overflow: hidden; border-radius: 0 0 14px 14px; container-type: inline-size; }
|
||||
|
||||
/* Control files: one line each */
|
||||
.cf-grid { display: grid; grid-template-columns: minmax(0, 1fr) 190px 80px 72px 14px; align-items: center; gap: 0 14px;
|
||||
padding: 11px 100px 11px 24px; font-size: 14px; min-height: 24px; }
|
||||
.cf-head { font-size: 12px; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); padding-top: 6px; padding-bottom: 6px; }
|
||||
.cf-grid .r { text-align: right; white-space: nowrap; }
|
||||
.cf-grid .when { white-space: nowrap; font-size: 13px; color: var(--text-2); }
|
||||
.cf-body { padding: 0 24px 14px 24px; font-size: 13px; color: var(--text-2); display: flex; flex-direction: column; gap: 4px; }
|
||||
.cf-body .mono { overflow-wrap: anywhere; color: var(--muted); }
|
||||
.ui-row > .act { top: 8px; }
|
||||
|
||||
/* Compact lists */
|
||||
.ov-cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(420px, 100%), 1fr)); gap: 22px; align-items: start; }
|
||||
.cl { list-style: none; margin: 0; padding: 0; }
|
||||
.cl li { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: 0 12px; align-items: center; padding: 10px 24px; border-top: 1px solid var(--line); font-size: 14px; }
|
||||
.cl li .when { font-size: 12px; color: var(--muted); white-space: nowrap; }
|
||||
.cl li a.rowlink { color: var(--text); text-decoration: none; min-width: 0; }
|
||||
.cl li a.rowlink:hover { color: var(--accent); }
|
||||
.cl .pos { color: var(--muted); font-size: 12px; }
|
||||
.cl-more { padding: 10px 24px 14px; border-top: 1px solid var(--line); font-size: 13px; }
|
||||
.cl-empty { padding: 12px 24px 16px; border-top: 1px solid var(--line); font-size: 13px; color: var(--muted); }
|
||||
|
||||
@container (max-width: 760px) {
|
||||
.cf-grid { grid-template-columns: minmax(0, 1fr) auto 14px; padding: 11px 90px 11px 16px; }
|
||||
.cf-grid .c-size, .cf-grid .c-bak { display: none; }
|
||||
.cf-head { display: none; }
|
||||
.cf-body { padding: 0 16px 14px; }
|
||||
}
|
||||
@container (max-width: 480px) {
|
||||
.cf-grid { grid-template-columns: minmax(0, 1fr) 14px; }
|
||||
.cf-grid .when { grid-column: 1; grid-row: 2; }
|
||||
.cf-grid .chev { grid-column: 2; grid-row: 1; }
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.ov-attn li { grid-template-columns: auto minmax(0, 1fr); padding: 12px 16px; }
|
||||
.ov-attn li a { grid-column: 2; }
|
||||
.ov-phead, .ov-clear, .cl li, .cl-more, .cl-empty { padding-left: 16px; padding-right: 16px; }
|
||||
.cl li { grid-template-columns: minmax(0, 1fr) auto; }
|
||||
.cl li .when { grid-column: 1; }
|
||||
}
|
||||
a.ov-tile:hover { color: var(--text); border-color: var(--primary); }
|
||||
.ov-tile.ok { border-left-color: var(--success); }
|
||||
.ov-tile.warn { border-left-color: var(--warning); }
|
||||
.ov-tile.bad { border-left-color: var(--danger); }
|
||||
.ov-tile .label { font-size: 11px; color: var(--text-light); text-transform: uppercase; letter-spacing: 1px; font-weight: 600; }
|
||||
.ov-tile .value { font-size: 1.6rem; font-weight: 600; line-height: 1.2; margin: 0.25rem 0 0.15rem; }
|
||||
.ov-tile .value.small-value { font-size: 0.95rem; line-height: 1.35; }
|
||||
.ov-tile .detail { font-size: 12px; color: var(--text-light); }
|
||||
.ov-tile.bad .value { color: var(--danger); }
|
||||
.ov-tile.warn .value { color: var(--warning-dark); }
|
||||
[data-theme="dark"] .ov-tile.warn .value { color: var(--warning); }
|
||||
.ov-attn { list-style: none; padding: 0; margin: 0; }
|
||||
.ov-attn li { display: flex; gap: 0.6rem; align-items: flex-start; padding: 0.55rem 1.25rem; border-bottom: 1px solid var(--border); font-size: 14px; }
|
||||
.ov-attn li:last-child { border-bottom: none; }
|
||||
.ov-attn .bi { margin-top: 0.15rem; }
|
||||
.ov-attn .bi-x-octagon { color: var(--danger); }
|
||||
.ov-attn .bi-exclamation-triangle { color: var(--warning); }
|
||||
.ov-attn .bi-info-circle { color: var(--primary); }
|
||||
.ov-attn a { margin-left: auto; white-space: nowrap; font-size: 13px; }
|
||||
.ov-table td { vertical-align: top; white-space: normal; }
|
||||
.ov-table .file-cell { overflow-wrap: anywhere; }
|
||||
.ov-table .small { font-size: 12px; }
|
||||
.ov-list-more { font-size: 12.5px; color: var(--text-light); padding: 0.5rem 1.25rem; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% set header_actions %}
|
||||
<a href="{{ url_for('hardware_page') }}" class="btn btn-outline-secondary btn-sm"><i class="bi bi-cpu"></i> Hardware Coverage</a>
|
||||
<a href="{{ url_for('dell_updates.page') }}" class="btn btn-outline-secondary btn-sm"><i class="bi bi-motherboard"></i> Dell Updates</a>
|
||||
<a href="{{ url_for('hardware_page') }}" class="ui-btn">Hardware coverage</a>
|
||||
<a href="{{ url_for('dell_updates.page') }}" class="ui-btn">Dell updates</a>
|
||||
{% endset %}
|
||||
{% include "_image_header.html" %}
|
||||
|
||||
{% set d = ov.drivers %}
|
||||
{% set lint = ov.lint %}
|
||||
{# missing-zip findings have their own tile; bad_lint is what is wrong in the map itself #}
|
||||
{# missing-zip findings are counted on their own; bad_lint is what is wrong in the map itself #}
|
||||
{% set bad_lint = lint.map_errors if lint.available else 0 %}
|
||||
{% set os_row = ov.os.current %}
|
||||
{% set drv_url = image_tab_url(image_type, 'drivers') %}
|
||||
{% set os_url = image_tab_url(image_type, 'os') %}
|
||||
|
||||
{# -------- tiles -------- #}
|
||||
<div class="ov-grid">
|
||||
<a class="ov-tile {{ 'ok' if d.total else 'bad' }}" href="{{ image_tab_url(image_type, 'drivers') }}">
|
||||
<div class="label">Driver entries</div>
|
||||
<div class="value">{{ d.total }}</div>
|
||||
<div class="detail">{{ d.by_os.Win11 }} Win11 | {{ d.by_os.Win10 }} Win10{% if d.by_os.Other %} | {{ d.by_os.Other }} other{% endif %}</div>
|
||||
</a>
|
||||
<a class="ov-tile {{ 'bad' if d.missing else 'ok' }}" href="{{ image_tab_url(image_type, 'drivers') }}">
|
||||
<div class="label">Driver zips missing</div>
|
||||
<div class="value">{{ d.missing | length }}</div>
|
||||
<div class="detail">{% if d.missing %}{{ d.missing | selectattr('os', 'equalto', 'Win11') | list | length }} of them Win11{% else %}every entry's zip is on the share{% endif %}</div>
|
||||
</a>
|
||||
<a class="ov-tile {{ ('bad' if bad_lint else ('warn' if lint.total else 'ok')) if lint.available else '' }}" href="{{ image_tab_url(image_type, 'drivers') }}">
|
||||
<div class="label">Driver map lint</div>
|
||||
{% if lint.available %}
|
||||
<div class="value">{{ lint.total }}</div>
|
||||
<div class="detail">{% for lv, n in lint.by_level | dictsort %}{{ n }} {{ lv | lower }}{% if not loop.last %} | {% endif %}{% else %}no findings{% endfor %}</div>
|
||||
{% else %}
|
||||
<div class="value small-value text-muted">Not available</div>
|
||||
<div class="detail">Shown once the Drivers tab lint is installed</div>
|
||||
{% endif %}
|
||||
</a>
|
||||
<a class="ov-tile {{ ('ok' if os_row.wim.exists else 'bad') if os_row else 'bad' }}" href="{{ image_tab_url(image_type, 'os') }}">
|
||||
<div class="label">Operating system</div>
|
||||
{% if os_row %}
|
||||
<div class="value small-value">{{ os_row.osv.productName or os_row.osv.marketingName }} {{ os_row.osv.versionNumber }}</div>
|
||||
<div class="detail">osId {{ os_row.osv.id }} |
|
||||
{% if os_row.wim.exists %}install.wim on disk ({{ (os_row.wim.size / 1073741824) | round(1) }} GB){% else %}<strong>install.wim not on disk</strong>{% endif %}</div>
|
||||
{% else %}
|
||||
<div class="value small-value">None</div>
|
||||
<div class="detail">OperatingSystem.json is empty or missing</div>
|
||||
{% endif %}
|
||||
</a>
|
||||
<a class="ov-tile {{ 'bad' if ov.packages.missing else 'ok' }}" href="{{ image_tab_url(image_type, 'os') }}">
|
||||
<div class="label">Packages</div>
|
||||
<div class="value">{{ ov.packages.enabled }}<span class="detail"> of {{ ov.packages.total }} enabled</span></div>
|
||||
<div class="detail">{% if ov.packages.missing %}{{ ov.packages.missing | length }} enabled file{{ 's' if ov.packages.missing | length != 1 }} missing{% else %}all enabled files on disk{% endif %}</div>
|
||||
</a>
|
||||
<a class="ov-tile {{ ('bad' if ov.gaps else 'ok') if ov.gaps_checked else '' }}" href="{{ url_for('hardware_page') }}">
|
||||
<div class="label">Hardware gaps</div>
|
||||
{% if ov.gaps_checked %}
|
||||
<div class="value">{{ ov.gaps | length }}</div>
|
||||
<div class="detail">models seen by WinPE with no working driver entry here</div>
|
||||
{% else %}
|
||||
<div class="value small-value text-muted">Not tracked</div>
|
||||
<div class="detail">Hardware Coverage checks the three GE images only</div>
|
||||
{% endif %}
|
||||
</a>
|
||||
<a class="ov-tile {{ 'warn' if ov.dell.rows else ('ok' if ov.dell.checked else '') }}" href="{{ url_for('dell_updates.page') }}">
|
||||
<div class="label">Dell updates</div>
|
||||
{% if ov.dell.checked %}
|
||||
<div class="value">{{ ov.dell.rows | length }}</div>
|
||||
<div class="detail">newer driver pack{{ 's' if ov.dell.rows | length != 1 }} | checked {{ ov.dell.checked | local_dt }}</div>
|
||||
{% else %}
|
||||
<div class="value small-value text-muted">Not checked</div>
|
||||
<div class="detail">Run a check on Updates, Dell Drivers and BIOS</div>
|
||||
{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{# -------- needs attention -------- #}
|
||||
{# -------- needs attention: (kind, text, link, label) -------- #}
|
||||
{% set attn = [] %}
|
||||
{% if d.missing %}{% set _ = attn.append(('x-octagon', d.missing | length ~ ' driver entr' ~ ('ies point' if d.missing | length != 1 else 'y points') ~ ' at a zip that is not on the share. PESetup only warns, and the PC comes up without NIC or WiFi drivers.', image_tab_url(image_type, 'drivers'), 'Drivers')) %}{% endif %}
|
||||
{% if bad_lint %}{% set _ = attn.append(('x-octagon', bad_lint ~ ' driver map lint error' ~ ('s' if bad_lint != 1) ~ ' in the entries themselves (a token or order PESetup will mis-match; see the findings on the Drivers tab).', image_tab_url(image_type, 'drivers'), 'Drivers')) %}{% endif %}
|
||||
{% if not os_row %}{% set _ = attn.append(('x-octagon', 'OperatingSystem.json has no entry: PESetup has no Windows image to apply.', image_tab_url(image_type, 'os'), 'OS and Packages')) %}
|
||||
{% elif not os_row.wim.exists %}{% set _ = attn.append(('x-octagon', 'install.wim is not on disk in ' ~ (os_row.dest or 'the configured folder (DestinationDir is empty)') ~ '.', image_tab_url(image_type, 'os'), 'OS and Packages')) %}{% endif %}
|
||||
{% if ov.packages.missing %}{% set _ = attn.append(('exclamation-triangle', ov.packages.missing | length ~ ' enabled package file' ~ ('s are' if ov.packages.missing | length != 1 else ' is') ~ ' not on disk.', image_tab_url(image_type, 'os'), 'OS and Packages')) %}{% endif %}
|
||||
{% if ov.gaps %}{% set _ = attn.append(('exclamation-triangle', ov.gaps | length ~ ' model' ~ ('s' if ov.gaps | length != 1) ~ ' booted WinPE here with no working driver entry in this image.', url_for('hardware_page'), 'Hardware Coverage')) %}{% endif %}
|
||||
{% if ov.unattend_override %}{% set _ = attn.append(('exclamation-triangle', 'Deploy/Tools/GE/XML/FlatUnattendW10.xml exists. PESetup loads that file first, so the Unattend tab (which edits Deploy/FlatUnattendW10.xml) is not what imaging uses.', image_tab_url(image_type, 'unattend'), 'Unattend')) %}{% endif %}
|
||||
{% if ov.hw_drivers_extra %}{% set _ = attn.append(('info-circle', 'Deploy/Control/hw_drivers.json exists. PESetup never reads it; only HardwareDriver.json decides drivers.', image_tab_url(image_type, 'drivers'), 'Drivers')) %}{% endif %}
|
||||
{% if ov.dell.rows %}{% set _ = attn.append(('info-circle', ov.dell.rows | length ~ ' driver pack' ~ ('s have' if ov.dell.rows | length != 1 else ' has') ~ ' a newer Dell release (last check ' ~ (ov.dell.checked | local_dt) ~ ').', url_for('dell_updates.page'), 'Dell Updates')) %}{% endif %}
|
||||
{% if ov.models.no_entry %}{% set _ = attn.append(('info-circle', ov.models.no_entry | length ~ ' hardware model selection' ~ ('s name' if ov.models.no_entry | length != 1 else ' names') ~ ' a driver family with no entry in HardwareDriver.json (media build selection only; imaging does not use it).', image_tab_url(image_type, 'os'), 'OS and Packages')) %}{% endif %}
|
||||
{% if d.missing %}{% set _ = attn.append(('err', d.missing | length ~ ' driver entr' ~ ('ies point' if d.missing | length != 1 else 'y points') ~ ' at a zip that is not on the share. PESetup only warns, and the PC comes up without NIC or WiFi drivers.', image_tab_url(image_type, 'drivers', missing=1), 'Drivers')) %}{% endif %}
|
||||
{% if bad_lint %}{% set _ = attn.append(('err', bad_lint ~ ' driver map lint error' ~ ('s' if bad_lint != 1) ~ ' in the entries themselves (a token or order PESetup will mis-match; see the findings on the Drivers tab).', image_tab_url(image_type, 'drivers', findings=1), 'Drivers')) %}{% endif %}
|
||||
{% if not os_row %}{% set _ = attn.append(('err', 'OperatingSystem.json has no entry: PESetup has no Windows image to apply.', os_url, 'OS and Packages')) %}
|
||||
{% elif not os_row.wim.exists %}{% set _ = attn.append(('err', 'install.wim is not on disk in ' ~ (os_row.dest or 'the configured folder (DestinationDir is empty)') ~ '.', os_url, 'OS and Packages')) %}{% endif %}
|
||||
{% if ov.packages.missing %}{% set _ = attn.append(('warn', ov.packages.missing | length ~ ' enabled package file' ~ ('s are' if ov.packages.missing | length != 1 else ' is') ~ ' not on disk.', os_url, 'OS and Packages')) %}{% endif %}
|
||||
{% if ov.gaps %}{% set _ = attn.append(('warn', ov.gaps | length ~ ' model' ~ ('s' if ov.gaps | length != 1) ~ ' booted WinPE here with no working driver entry in this image.', url_for('hardware_page'), 'Hardware coverage')) %}{% endif %}
|
||||
{% if ov.unattend_override %}{% set _ = attn.append(('warn', 'Deploy/Tools/GE/XML/FlatUnattendW10.xml exists. PESetup loads that file first, so the Unattend tab (which edits Deploy/FlatUnattendW10.xml) is not what imaging uses.', image_tab_url(image_type, 'unattend'), 'Unattend')) %}{% endif %}
|
||||
{% if ov.hw_drivers_extra %}{% set _ = attn.append(('neutral', 'Deploy/Control/hw_drivers.json exists. PESetup never reads it; only HardwareDriver.json decides drivers.', drv_url, 'Drivers')) %}{% endif %}
|
||||
{% if ov.dell.rows %}{% set _ = attn.append(('neutral', ov.dell.rows | length ~ ' driver pack' ~ ('s have' if ov.dell.rows | length != 1 else ' has') ~ ' a newer Dell release (last check ' ~ (ov.dell.checked | local_dt) ~ ').', url_for('dell_updates.page'), 'Dell updates')) %}{% endif %}
|
||||
{% if ov.models.no_entry %}{% set _ = attn.append(('neutral', ov.models.no_entry | length ~ ' hardware model selection' ~ ('s name' if ov.models.no_entry | length != 1 else ' names') ~ ' a driver family with no entry in HardwareDriver.json (media build selection only; imaging does not use it).', os_url, 'OS and Packages')) %}{% endif %}
|
||||
{% set kind_label = {'err': 'Error', 'warn': 'Warning', 'neutral': 'Note'} %}
|
||||
{% set n_err = attn | selectattr(0, 'equalto', 'err') | list | length %}
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="d-flex align-items-center gap-2"><i class="bi bi-clipboard-pulse"></i> Needs attention</span>
|
||||
<span class="badge {{ 'badge-danger' if attn | selectattr(0, 'equalto', 'x-octagon') | list else ('badge-warning' if attn else 'badge-success') }}">{{ attn | length or 'All clear' }}</span>
|
||||
<div class="ui-stack">
|
||||
|
||||
<section class="panel" aria-labelledby="ov-attn-h">
|
||||
<div class="ov-phead">
|
||||
<h2 id="ov-attn-h">Needs attention</h2>
|
||||
{% if n_err %}{{ ui.chip(n_err ~ ' error' ~ ('s' if n_err != 1), 'err') }}{% endif %}
|
||||
{% if attn | length > n_err %}{{ ui.chip((attn | length - n_err) ~ ' other', 'neutral') }}{% endif %}
|
||||
</div>
|
||||
{% if attn %}
|
||||
<ul class="ov-attn">
|
||||
{% for icon, text, link, label in attn %}
|
||||
<li><i class="bi bi-{{ icon }}"></i><span>{{ text }}</span><a href="{{ link }}">{{ label }} <i class="bi bi-chevron-right"></i></a></li>
|
||||
{% for kind, text, link, label in attn %}
|
||||
<li>{{ ui.chip(kind_label[kind], kind) }}<span>{{ text }}</span><a href="{{ link }}">Open {{ label }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<div class="card-body text-muted">Driver zips, install.wim and packages are all on disk and no seen model is missing drivers.</div>
|
||||
<div class="ov-clear">{{ ui.chip('All clear', 'ok') }} Driver zips, install.wim and packages are all on disk and no seen model is missing drivers.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{# -------- summary counts -------- #}
|
||||
<section class="panel" aria-labelledby="ov-sum-h">
|
||||
<div class="ov-phead"><h2 id="ov-sum-h">At a glance</h2></div>
|
||||
<div class="ov-stats-wrap"><div class="ov-stats">
|
||||
{% for osn in ('Win11', 'Win10') %}
|
||||
<a class="ov-stat" href="{{ image_tab_url(image_type, 'drivers', os=osn) }}">
|
||||
<span class="lbl">{{ osn }} driver entries</span>
|
||||
<span class="val">{{ d.by_os[osn] }}</span>
|
||||
<span class="st">{{ ui.chip(osn, 'w11' if osn == 'Win11' else 'w10') }}{% if not d.total and osn == 'Win11' %} {{ ui.chip('Map empty', 'err') }}{% endif %}</span>
|
||||
<span class="det">of {{ d.total }} entries{% if d.by_os.Other and osn == 'Win10' %}, {{ d.by_os.Other }} other{% endif %}</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
<a class="ov-stat" href="{{ image_tab_url(image_type, 'drivers', missing=1) if d.missing else drv_url }}">
|
||||
<span class="lbl">Zips missing</span>
|
||||
<span class="val">{{ d.missing | length }}</span>
|
||||
<span class="st">{{ ui.chip('Missing', 'err') if d.missing else ui.chip('OK', 'ok') }}</span>
|
||||
<span class="det">{% if d.missing %}{{ d.missing | selectattr('os', 'equalto', 'Win11') | list | length }} of them Win11{% else %}every zip is on the share{% endif %}</span>
|
||||
</a>
|
||||
<a class="ov-stat" href="{{ image_tab_url(image_type, 'drivers', findings=1) if lint.available and lint.total else drv_url }}">
|
||||
{% if lint.available %}
|
||||
<span class="lbl">Map lint</span>
|
||||
<span class="val">{{ lint.total }}</span>
|
||||
<span class="st">{{ ui.chip('Errors', 'err') if bad_lint else (ui.chip('Findings', 'warn') if lint.total else ui.chip('Clean', 'ok')) }}</span>
|
||||
<span class="det">{% for lv, n in lint.by_level | dictsort %}{{ n }} {{ lv | lower }}{% if not loop.last %}, {% endif %}{% else %}no findings{% endfor %}</span>
|
||||
{% else %}
|
||||
<span class="lbl">Map lint</span>
|
||||
<span class="st">{{ ui.chip('Not available', 'off') }}</span>
|
||||
<span class="det">Shown once the Drivers tab lint is installed</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
<a class="ov-stat" href="{{ os_url }}">
|
||||
{% if os_row %}
|
||||
<span class="lbl">Operating system</span>
|
||||
<span class="val txt">{{ os_row.osv.productName or os_row.osv.marketingName }} {{ os_row.osv.versionNumber }}</span>
|
||||
<span class="st">{{ ui.chip('On disk', 'ok') if os_row.wim.exists else ui.chip('wim missing', 'err') }}</span>
|
||||
<span class="det">osId {{ os_row.osv.id }}{% if os_row.wim.exists %}, install.wim {{ (os_row.wim.size / 1073741824) | round(1) }} GB{% endif %}</span>
|
||||
{% else %}
|
||||
<span class="lbl">Operating system</span>
|
||||
<span class="st">{{ ui.chip('None', 'err') }}</span>
|
||||
<span class="det">OperatingSystem.json is empty or missing</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
<a class="ov-stat" href="{{ os_url }}">
|
||||
<span class="lbl">Packages</span>
|
||||
<span class="val">{{ ov.packages.enabled }} <span class="det">of {{ ov.packages.total }} enabled</span></span>
|
||||
<span class="st">{{ ui.chip('File missing', 'warn') if ov.packages.missing else ui.chip('OK', 'ok') }}</span>
|
||||
<span class="det">{% if ov.packages.missing %}{{ ov.packages.missing | length }} enabled file{{ 's' if ov.packages.missing | length != 1 }} missing{% else %}all enabled files on disk{% endif %}</span>
|
||||
</a>
|
||||
<a class="ov-stat" href="{{ url_for('hardware_page') }}">
|
||||
{% if ov.gaps_checked %}
|
||||
<span class="lbl">Hardware gaps</span>
|
||||
<span class="val">{{ ov.gaps | length }}</span>
|
||||
<span class="st">{{ ui.chip('Gaps', 'warn') if ov.gaps else ui.chip('OK', 'ok') }}</span>
|
||||
<span class="det">seen models with no working driver entry</span>
|
||||
{% else %}
|
||||
<span class="lbl">Hardware gaps</span>
|
||||
<span class="st">{{ ui.chip('Not tracked', 'off') }}</span>
|
||||
<span class="det">Hardware coverage checks the three GE images only</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
<a class="ov-stat" href="{{ url_for('dell_updates.page') }}">
|
||||
{% if ov.dell.checked %}
|
||||
<span class="lbl">Dell updates</span>
|
||||
<span class="val">{{ ov.dell.rows | length }}</span>
|
||||
<span class="st">{{ ui.chip('Newer packs', 'neutral') if ov.dell.rows else ui.chip('Current', 'ok') }}</span>
|
||||
<span class="det">checked {{ ov.dell.checked | local_dt }}</span>
|
||||
{% else %}
|
||||
<span class="lbl">Dell updates</span>
|
||||
<span class="st">{{ ui.chip('Not checked', 'off') }}</span>
|
||||
<span class="det">Run a check on Updates, Dell drivers and BIOS</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
</div></div>
|
||||
</section>
|
||||
|
||||
{# -------- control files -------- #}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="d-flex align-items-center gap-2"><i class="bi bi-folder2-open"></i> Control files</span>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<table class="table mb-0 ov-table">
|
||||
<thead><tr><th>File</th><th>Last modified</th><th class="text-end">Size</th><th class="text-end">Backups</th></tr></thead>
|
||||
<tbody>
|
||||
{% for f in ov.files %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{{ image_tab_url(image_type, f.tab) }}" class="mono">{{ f.name }}</a>
|
||||
<div class="small text-muted">{{ f.what }}</div>
|
||||
<div class="small text-muted mono file-cell">{{ f.rel }}</div>
|
||||
</td>
|
||||
<td class="text-nowrap">
|
||||
{% if f.exists %}{{ f.mtime | local_dt }}{% else %}<span class="badge badge-danger">Missing</span>{% endif %}
|
||||
</td>
|
||||
<td class="text-end text-nowrap mono">{% if f.exists %}{{ f.size | filesizeformat }}{% else %}-{% endif %}</td>
|
||||
<td class="text-end mono">{{ f.backups or '-' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<section class="panel ui-list" aria-labelledby="ov-cf-h">
|
||||
<div class="ov-phead"><h2 id="ov-cf-h">Control files</h2></div>
|
||||
<div class="cf-grid cf-head" aria-hidden="true"><span>File</span><span>Last modified</span><span class="r c-size">Size</span><span class="r c-bak">Backups</span><span></span></div>
|
||||
{% for f in ov.files %}
|
||||
<div class="ui-row">
|
||||
<details>
|
||||
<summary class="cf-grid">
|
||||
<span class="tip" data-tip="{{ f.rel }}"><span class="trunc mono">{{ f.name }}</span></span>
|
||||
<span class="when">{% if f.exists %}{{ f.mtime | local_dt }}{% else %}{{ ui.chip('Missing', 'err') }}{% endif %}</span>
|
||||
<span class="r mono c-size">{% if f.exists %}{{ f.size | filesizeformat }}{% else %}-{% endif %}</span>
|
||||
<span class="r mono c-bak">{{ f.backups or '-' }}</span>
|
||||
<span class="chev" aria-hidden="true">▾</span>
|
||||
</summary>
|
||||
<div class="cf-body">
|
||||
<span>{{ f.what }}</span>
|
||||
<span class="mono">{{ f.rel }}</span>
|
||||
<span>{% if f.exists %}{{ f.size | filesizeformat }}, {% endif %}{{ f.backups }} backup{{ 's' if f.backups != 1 }} next to the file</span>
|
||||
</div>
|
||||
</details>
|
||||
<div class="act"><a class="ui-btn sm" href="{{ image_tab_url(image_type, f.tab) }}" aria-label="Open {{ f.name }}">Open</a></div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% set saves = ov.last_saves | dictsort | selectattr(1) | list %}
|
||||
{% if saves %}
|
||||
<div class="card-footer small text-muted">
|
||||
Last saves from this app:
|
||||
{% for scope, rec in saves %}{{ {'drivers': 'Drivers', 'unattend': 'Unattend', 'os': 'OS and Packages'}[scope] }} {{ rec.ts | local_dt }} ({{ rec.files | map(attribute='name') | join(', ') }}){% if not loop.last %}; {% endif %}{% endfor %}
|
||||
<div class="hidden-bar">
|
||||
<span>Last saves from this app:
|
||||
{% for scope, rec in saves %}{{ {'drivers': 'Drivers', 'unattend': 'Unattend', 'os': 'OS and Packages'}[scope] }} {{ rec.ts | local_dt }} ({{ rec.files | map(attribute='name') | join(', ') }}){% if not loop.last %}; {% endif %}{% endfor %}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<div class="ov-cols">
|
||||
{# -------- coverage gaps -------- #}
|
||||
<section class="panel" aria-labelledby="ov-gap-h">
|
||||
<div class="ov-phead">
|
||||
<h2 id="ov-gap-h">Hardware coverage gaps</h2>
|
||||
{% if ov.gaps %}{{ ui.chip(ov.gaps | length, 'warn') }}{% endif %}
|
||||
<a class="sp" href="{{ url_for('hardware_page') }}">Hardware coverage</a>
|
||||
</div>
|
||||
{% if not ov.gaps_checked %}
|
||||
<div class="cl-empty">This image is not one of the images Hardware coverage checks.</div>
|
||||
{% elif ov.gaps %}
|
||||
<ul class="cl">
|
||||
{% for g in ov.gaps[:8] %}
|
||||
<li>
|
||||
<a class="rowlink tip" href="{{ image_tab_url(image_type, 'drivers', q=g.model) }}" data-tip="{{ g.model }}{% if g.skus %} (SKU {{ g.skus | join(', ') }}){% endif %}{% if g.file %}: {{ g.file }}{% endif %}">
|
||||
<span class="trunc mono">{{ g.model }}</span></a>
|
||||
{% if g.status == 'no-entry' %}{{ ui.chip('No entry', 'err') }}{% else %}{{ ui.chip('Zip missing', 'err') }}{% endif %}
|
||||
<span class="when">{{ g.last_seen | local_dt }}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% if ov.gaps | length > 8 %}<div class="cl-more"><a href="{{ url_for('hardware_page') }}">{{ ov.gaps | length - 8 }} more on Hardware coverage</a></div>{% endif %}
|
||||
{% else %}
|
||||
<div class="cl-empty">Every model that has booted WinPE here matches a driver entry with its zip on disk.</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{# -------- dell hints -------- #}
|
||||
<section class="panel" aria-labelledby="ov-dell-h">
|
||||
<div class="ov-phead">
|
||||
<h2 id="ov-dell-h">Newer Dell driver packs</h2>
|
||||
{% if ov.dell.rows %}{{ ui.chip(ov.dell.rows | length, 'neutral') }}{% endif %}
|
||||
<a class="sp" href="{{ url_for('dell_updates.page') }}">Dell updates</a>
|
||||
</div>
|
||||
{% if ov.dell.rows %}
|
||||
<ul class="cl">
|
||||
{% for r in ov.dell.rows[:8] %}
|
||||
<li>
|
||||
<a class="rowlink tip" href="{{ image_tab_url(image_type, 'drivers', q=r.file) }}" data-tip="Entry {{ r.pos }}: {{ r.file }}{% if r.models %} ({{ r.models }}){% endif %}">
|
||||
<span class="trunc"><span class="pos">{{ r.pos }}</span> <span class="mono">{{ r.file }}</span></span></a>
|
||||
{{ ui.chip(r.ver or 'newer', 'neutral') }}
|
||||
<span></span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% if ov.dell.rows | length > 8 %}<div class="cl-more"><a href="{{ url_for('dell_updates.page') }}">{{ ov.dell.rows | length - 8 }} more on Dell updates</a></div>{% endif %}
|
||||
{% elif ov.dell.checked %}
|
||||
<div class="cl-empty">Every driver pack in this map is the newest Dell has (checked {{ ov.dell.checked | local_dt }}).</div>
|
||||
{% else %}
|
||||
<div class="cl-empty">Not checked yet. Run a check on Updates, Dell drivers and BIOS.</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{# -------- missing zips -------- #}
|
||||
{% if d.missing %}
|
||||
<section class="panel" aria-labelledby="ov-miss-h">
|
||||
<div class="ov-phead">
|
||||
<h2 id="ov-miss-h">Zips not on the share</h2>
|
||||
{{ ui.chip(d.missing | length, 'err') }}
|
||||
<a class="sp" href="{{ image_tab_url(image_type, 'drivers', missing=1) }}">Drivers</a>
|
||||
</div>
|
||||
<ul class="cl">
|
||||
{% for m in d.missing[:8] %}
|
||||
<li>
|
||||
<a class="rowlink tip" href="{{ image_tab_url(image_type, 'drivers', q=m.file) if m.file else image_tab_url(image_type, 'drivers', missing=1) }}" data-tip="Entry {{ m.pos }}: {{ m.file or '(no file name)' }}{% if m.models %} ({{ m.models }}){% endif %}">
|
||||
<span class="trunc"><span class="pos">{{ m.pos }}</span> <span class="mono">{{ m.file or '(no file name)' }}</span></span></a>
|
||||
{{ ui.chip(m.os, 'w11' if m.os == 'Win11' else 'w10') }}
|
||||
<span></span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% if d.missing | length > 8 %}<div class="cl-more"><a href="{{ image_tab_url(image_type, 'drivers', missing=1) }}">{{ d.missing | length - 8 }} more on the Drivers tab</a></div>{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="row gx-4 gy-0">
|
||||
<div class="col-12 col-xl-6">
|
||||
{# -------- coverage gaps -------- #}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="d-flex align-items-center gap-2"><i class="bi bi-cpu"></i> Hardware coverage gaps</span>
|
||||
<a href="{{ url_for('hardware_page') }}" class="small fw-normal">Hardware Coverage</a>
|
||||
</div>
|
||||
{% if not ov.gaps_checked %}
|
||||
<div class="card-body text-muted small">This image is not one of the images Hardware Coverage checks.</div>
|
||||
{% elif ov.gaps %}
|
||||
<div class="table-container">
|
||||
<table class="table mb-0 ov-table">
|
||||
<thead><tr><th>Model (WMI)</th><th>Problem</th><th>Last seen</th></tr></thead>
|
||||
<tbody>
|
||||
{% for g in ov.gaps[:8] %}
|
||||
<tr>
|
||||
<td class="mono">{{ g.model }}{% if g.skus %}<div class="small text-muted">SKU {{ g.skus | join(', ') }}</div>{% endif %}</td>
|
||||
<td>{% if g.status == 'no-entry' %}<span class="badge badge-danger">No entry</span>{% else %}<span class="badge badge-danger" title="{{ g.file }}">Zip missing</span>{% endif %}</td>
|
||||
<td class="text-nowrap small">{{ g.last_seen | local_dt }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% if ov.gaps | length > 8 %}<div class="ov-list-more">and {{ ov.gaps | length - 8 }} more on Hardware Coverage</div>{% endif %}
|
||||
{% else %}
|
||||
<div class="card-body text-muted small">Every model that has booted WinPE here matches a driver entry with its zip on disk.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# -------- dell hints -------- #}
|
||||
{% if ov.dell.rows %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="d-flex align-items-center gap-2"><i class="bi bi-motherboard"></i> Newer Dell driver packs</span>
|
||||
<a href="{{ url_for('dell_updates.page') }}" class="small fw-normal">Dell Updates</a>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<table class="table mb-0 ov-table">
|
||||
<thead><tr><th class="text-end">#</th><th>Current file</th><th>Dell has</th></tr></thead>
|
||||
<tbody>
|
||||
{% for r in ov.dell.rows[:8] %}
|
||||
<tr>
|
||||
<td class="text-end mono text-muted">{{ r.pos }}</td>
|
||||
<td class="mono file-cell">{{ r.file }}<div class="small text-muted file-cell">{{ r.models }}</div></td>
|
||||
<td><span class="badge badge-info">{{ r.ver or 'newer' }}</span></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% if ov.dell.rows | length > 8 %}<div class="ov-list-more">and {{ ov.dell.rows | length - 8 }} more on Dell Updates</div>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-12 col-xl-6">
|
||||
{# -------- missing zips -------- #}
|
||||
{% if d.missing %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="d-flex align-items-center gap-2"><i class="bi bi-file-earmark-x"></i> Zips not on the share</span>
|
||||
<a href="{{ image_tab_url(image_type, 'drivers') }}" class="small fw-normal">Drivers</a>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<table class="table mb-0 ov-table">
|
||||
<thead><tr><th class="text-end">#</th><th>File</th><th>OS</th></tr></thead>
|
||||
<tbody>
|
||||
{% for m in d.missing[:8] %}
|
||||
<tr>
|
||||
<td class="text-end mono text-muted">{{ m.pos }}</td>
|
||||
<td class="mono file-cell">{{ m.file or '(no file name)' }}<div class="small text-muted file-cell">{{ m.models }}</div></td>
|
||||
<td>{{ m.os }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% if d.missing | length > 8 %}<div class="ov-list-more">and {{ d.missing | length - 8 }} more on the Drivers tab</div>{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,189 +1,200 @@
|
||||
{% extends "base.html" %}
|
||||
{% import '_ui.html' as ui %}
|
||||
{% block title %}{{ session.serial }} - Imaging Session{% endblock %}
|
||||
|
||||
{% set st = session.status or 'in_progress' %}
|
||||
{% set st_chip = {'succeeded': ('Succeeded', 'ok'), 'failed': ('Failed', 'fail')}.get(st, ('In progress', 'run')) %}
|
||||
{% set st_kind = {'succeeded': 'ok', 'failed': 'fail'}.get(st, 'run') %}
|
||||
{% set stage_idx = session.stage_index | int(0) %}
|
||||
{% set stage_total = session.stage_total | int(0) %}
|
||||
{% set history = session.stage_history or [] %}
|
||||
|
||||
{# Support summary: built here so the copy button carries it (static/app.js
|
||||
handles .copy-btn[data-copy-text]). Same fields as the metadata list. #}
|
||||
{% set summary_lines = [
|
||||
'Serial: ' ~ session.serial,
|
||||
'Status: ' ~ st,
|
||||
'Hostname: ' ~ (session.hostname_target or '-'),
|
||||
'IP: ' ~ (session.ip or '-'),
|
||||
'MAC: ' ~ (session.mac or '-'),
|
||||
'PC type: ' ~ (session.pctype or '-'),
|
||||
'Machine #: ' ~ (session.machinenumber or '-'),
|
||||
'Intune ID: ' ~ (session.intune_device_id or '-'),
|
||||
'Started: ' ~ (session.started_at | local_dt(true)),
|
||||
'Last update: ' ~ (session.last_updated | local_dt(true)),
|
||||
'Stage: ' ~ stage_idx ~ '/' ~ (session.stage_total or '?') ~ ((' - ' ~ session.current_stage) if session.current_stage else ''),
|
||||
] %}
|
||||
{% if session.previous_run_at %}{% set _ = summary_lines.append('Prior run: ' ~ (session.previous_run_at | local_dt(true))) %}{% endif %}
|
||||
{% if session.error %}{% set _ = summary_lines.append('Error: ' ~ session.error) %}{% endif %}
|
||||
{% if history %}
|
||||
{% set _ = summary_lines.append('') %}{% set _ = summary_lines.append('Stage timeline:') %}
|
||||
{% for h in history %}{% set _ = summary_lines.append(' - ' ~ (h.ts | local_dt(true)) ~ ' stage ' ~ h.stage_index ~ ' ' ~ h.status ~ ' ' ~ (h.current_stage or '')) %}{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% block extra_head %}
|
||||
<style>
|
||||
.id-hero { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(170px, 100%), 1fr)); gap: 12px; }
|
||||
.id-stat { background: var(--surface); border-radius: 12px; padding: 14px 16px; box-shadow: 0 1px 2px var(--shadow); min-width: 0; }
|
||||
.id-stat .k { font-size: 12px; color: var(--muted); }
|
||||
.id-stat .v { font-size: 18px; font-weight: 600; margin-top: 4px; overflow-wrap: anywhere; }
|
||||
.id-stat .v.sm { font-size: 13px; font-weight: 400; line-height: 1.45; }
|
||||
.id-cols { display: grid; grid-template-columns: minmax(0, 1.7fr) minmax(280px, 1fr); grid-template-areas: "log side"; gap: 16px; align-items: start; }
|
||||
.id-cols > .id-main { grid-area: log; display: flex; flex-direction: column; gap: 16px; min-width: 0; }
|
||||
.id-cols > .id-side { grid-area: side; display: flex; flex-direction: column; min-width: 0; }
|
||||
@media (max-width: 1100px) { .id-cols { grid-template-columns: minmax(0, 1fr); grid-template-areas: "side" "log"; } }
|
||||
.id-side section { padding: 16px 18px; }
|
||||
.id-side section + section { border-top: 1px solid var(--line); }
|
||||
.id-side h2, .id-log h2 { margin: 0; font-size: 15px; font-weight: 600; }
|
||||
.kv { margin: 0; display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 6px 16px; font-size: 13px; }
|
||||
.kv dt { color: var(--muted); font-weight: 400; } .kv dd { margin: 0; overflow-wrap: anywhere; }
|
||||
/* Stage history: vertical track, one entry per transition. */
|
||||
.sh-list { list-style: none; margin: 12px 0 0; padding: 0; }
|
||||
.sh-item { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 2px 12px; padding: 0 0 14px 24px; font-size: 13px; }
|
||||
.sh-item::before { content: ""; position: absolute; left: 5px; top: 14px; bottom: -2px; width: 2px; background: var(--line-2); }
|
||||
.sh-item:last-child { padding-bottom: 0; }
|
||||
.sh-item:last-child::before { display: none; }
|
||||
.sh-dot { position: absolute; left: 0; top: 4px; width: 12px; height: 12px; border-radius: 6px; background: var(--track-done); box-shadow: 0 0 0 3px var(--surface); }
|
||||
.sh-dot.run { background: var(--run); } .sh-dot.fail { background: var(--fail); } .sh-dot.ok { background: var(--ok); }
|
||||
.sh-name { font-weight: 600; min-width: 0; overflow-wrap: anywhere; }
|
||||
.sh-when { color: var(--muted); font-size: 12px; white-space: nowrap; }
|
||||
.sh-meta { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 6px; align-items: center; color: var(--muted); font-size: 12px; }
|
||||
/* Logs: contained scroll panels on a token background. */
|
||||
.id-log { padding: 16px 18px; display: flex; flex-direction: column; gap: 10px; min-width: 0; }
|
||||
.id-loghead { display: flex; flex-wrap: wrap; gap: 8px 12px; align-items: center; }
|
||||
.id-loghead .grow { margin-right: auto; display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
|
||||
.id-pre { margin: 0; padding: 10px 12px; background: var(--surface-2); color: var(--text); border: 1px solid var(--line); border-radius: 8px;
|
||||
font-family: var(--font-mono); font-size: 12px; line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere; overflow: auto; }
|
||||
#full-log { max-height: 36rem; }
|
||||
.id-tail { max-height: 12rem; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>{{ session.serial }}
|
||||
{% if session.hostname_target %}<span class="text-light" style="font-size:14px; font-weight:400;">{{ session.hostname_target }}</span>{% endif %}
|
||||
<header class="ui-head">
|
||||
<div style="min-width: 0">
|
||||
<a href="{{ url_for('imaging_dashboard') }}" class="eyebrow-t" style="text-decoration: none">Imaging</a>
|
||||
<h1 style="display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px 14px">
|
||||
<span class="mono">{{ session.serial }}</span>
|
||||
{{ ui.chip(st_chip[0], st_chip[1]) }}
|
||||
</h1>
|
||||
<div style="margin-top:0.5rem; display:flex; gap:0.4rem; flex-wrap:wrap;">
|
||||
{% if session.pctype %}<span class="badge badge-info">{{ session.pctype }}</span>{% endif %}
|
||||
{% if session.machinenumber %}<span class="badge badge-secondary">#{{ session.machinenumber }}</span>{% endif %}
|
||||
<span class="badge badge-{{ 'success' if session.status == 'succeeded' else ('danger' if session.status == 'failed' else 'primary') }}">{{ session.status or 'in_progress' }}</span>
|
||||
<div class="sub" style="margin-top: 4px; font-size: 13px">
|
||||
Stage {{ stage_idx }} of {{ session.stage_total or '?' }}{% if session.current_stage %}: <span class="mono">{{ session.current_stage }}</span>{% endif %}
|
||||
<span class="muted"> | updated {{ session.last_updated | local_dt(true) }}</span>
|
||||
</div>
|
||||
{% if stage_total %}<div style="max-width: 360px">{{ ui.track(stage_total, stage_idx, st_kind) }}</div>{% endif %}
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<a href="{{ url_for('imaging_dashboard') }}" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-arrow-left"></i> Back to dashboard
|
||||
</a>
|
||||
<button type="button" class="btn btn-primary btn-sm copy-summary-btn">
|
||||
<i class="bi bi-clipboard"></i> Copy support summary
|
||||
</button>
|
||||
<div class="actions">
|
||||
<a href="{{ url_for('imaging_dashboard') }}" class="ui-btn">Back to imaging</a>
|
||||
<button type="button" class="ui-btn pri copy-btn copy-summary-btn" data-copy-text="{{ summary_lines | join('\n') }}">Copy support summary</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="ui-stack">
|
||||
{% if session.error %}
|
||||
<div class="errbox" role="alert"><strong>Error.</strong> {{ session.error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="id-hero">
|
||||
<div class="id-stat"><div class="k">Machine number</div><div class="v mono">{{ session.machinenumber or '-' }}</div></div>
|
||||
<div class="id-stat"><div class="k">Hostname</div><div class="v mono">{{ session.hostname_target or '-' }}</div></div>
|
||||
<div class="id-stat"><div class="k">IP</div><div class="v mono">{{ session.ip or '-' }}</div>{% if session.mac %}<div class="sub mono">{{ session.mac }}</div>{% endif %}</div>
|
||||
<div class="id-stat"><div class="k">PC type</div><div class="v" style="font-size: 15px">{{ session.pctype or '-' }}</div></div>
|
||||
<div class="id-stat"><div class="k">Intune ID</div>
|
||||
{% if session.intune_device_id %}<div class="v sm mono">{{ session.intune_device_id }}</div>
|
||||
<button type="button" class="ui-btn sm ghost copy-btn" data-copy-text="{{ session.intune_device_id }}" style="margin-top: 4px">Copy ID</button>
|
||||
{% else %}<div class="v">-</div><div class="sub">Not enrolled yet</div>{% endif %}</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-5">
|
||||
|
||||
<div class="section-card">
|
||||
<div class="section-title">Session metadata</div>
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-4 text-light">Serial</dt><dd class="col-8"><span class="mono">{{ session.serial }}</span></dd>
|
||||
<dt class="col-4 text-light">Hostname</dt><dd class="col-8"><span class="mono">{{ session.hostname_target or '-' }}</span></dd>
|
||||
<dt class="col-4 text-light">MAC</dt><dd class="col-8"><span class="mono">{{ session.mac or '-' }}</span></dd>
|
||||
<dt class="col-4 text-light">PC type</dt><dd class="col-8">{{ session.pctype or '-' }}</dd>
|
||||
<dt class="col-4 text-light">Machine #</dt><dd class="col-8">{{ session.machinenumber or '-' }}</dd>
|
||||
<dt class="col-4 text-light">Intune ID</dt><dd class="col-8">
|
||||
{% if session.intune_device_id %}
|
||||
<span class="mono" style="word-break: break-all;">{{ session.intune_device_id }}</span>
|
||||
{% else %}-{% endif %}
|
||||
</dd>
|
||||
<dt class="col-4 text-light">Started</dt><dd class="col-8"><span class="mono">{{ session.started_at | local_dt(true) }}</span></dd>
|
||||
<dt class="col-4 text-light">Last update</dt><dd class="col-8"><span class="mono">{{ session.last_updated | local_dt(true) }}</span></dd>
|
||||
<dt class="col-4 text-light">Stage</dt><dd class="col-8">
|
||||
{{ session.stage_index or 0 }}/{{ session.stage_total or '?' }}
|
||||
{% if session.current_stage %} - <span class="mono">{{ session.current_stage }}</span>{% endif %}
|
||||
</dd>
|
||||
{% if session.previous_run_at %}
|
||||
<dt class="col-4 text-light">Prior run</dt><dd class="col-8"><span class="mono">{{ session.previous_run_at | local_dt(true) }}</span></dd>
|
||||
{% endif %}
|
||||
{% if session.error %}
|
||||
<dt class="col-4 text-danger">Error</dt>
|
||||
<dd class="col-8 text-danger">{{ session.error }}</dd>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<div class="section-title">Stage timeline</div>
|
||||
{% if session.stage_history %}
|
||||
<div class="table-container">
|
||||
<table class="data-table mb-0">
|
||||
<thead>
|
||||
<tr><th>Time</th><th>Stage</th><th>Status</th><th>Detail</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for h in session.stage_history %}
|
||||
<tr>
|
||||
<td><span class="mono">{{ h.ts | local_dt(true) }}</span></td>
|
||||
<td><span class="badge badge-secondary">{{ h.stage_index }}</span></td>
|
||||
<td>
|
||||
<span class="badge badge-{{ 'success' if h.status == 'succeeded' else ('danger' if h.status == 'failed' else 'primary') }}">
|
||||
{{ h.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td><span class="mono">{{ h.current_stage or '' }}</span></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="id-cols">
|
||||
<div class="panel id-side">
|
||||
<section aria-labelledby="shHead">
|
||||
<h2 id="shHead">Stage history</h2>
|
||||
{% if history %}
|
||||
<ol class="sh-list">
|
||||
{% for h in history %}
|
||||
{% set hk = {'succeeded': 'ok', 'failed': 'fail'}.get(h.status, 'run' if loop.last else '') %}
|
||||
<li class="sh-item">
|
||||
<span class="sh-dot {{ hk }}" aria-hidden="true"></span>
|
||||
<span class="sh-name">{{ h.current_stage or ('Stage ' ~ h.stage_index) }}</span>
|
||||
<span class="sh-when mono">{{ h.ts | local_dt(true) }}</span>
|
||||
<span class="sh-meta">Stage {{ h.stage_index }}{% if stage_total %} of {{ stage_total }}{% endif %}
|
||||
{% if h.status == 'failed' %}{{ ui.chip('Failed', 'fail') }}{% elif h.status == 'succeeded' %}{{ ui.chip('Succeeded', 'ok') }}{% endif %}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
{% else %}
|
||||
<div class="status-indicator">
|
||||
<i class="bi bi-info-circle"></i>
|
||||
<span>No stage transitions recorded yet. The client only logs a row when
|
||||
stage_index advances or status flips to succeeded/failed.</span>
|
||||
</div>
|
||||
<p class="sub" style="margin: 8px 0 0">No stage transitions recorded yet. The client only logs a row when
|
||||
stage_index advances or status flips to succeeded or failed.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</section>
|
||||
<section aria-labelledby="metaHead">
|
||||
<h2 id="metaHead" style="margin-bottom: 10px">Session</h2>
|
||||
<dl class="kv" id="sessionMeta">
|
||||
<dt>Serial</dt><dd class="mono">{{ session.serial }}</dd>
|
||||
<dt>MAC</dt><dd class="mono">{{ session.mac or '-' }}</dd>
|
||||
<dt>Started</dt><dd class="mono">{{ session.started_at | local_dt(true) }}</dd>
|
||||
<dt>Last update</dt><dd class="mono">{{ session.last_updated | local_dt(true) }}</dd>
|
||||
{% if session.previous_run_at %}<dt>Prior run</dt><dd class="mono">{{ session.previous_run_at | local_dt(true) }}</dd>{% endif %}
|
||||
{% if session.source %}<dt>Source</dt><dd>{{ session.source }}</dd>{% endif %}
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-7">
|
||||
|
||||
<div class="section-card">
|
||||
<div class="section-title" style="display:flex; justify-content:space-between; align-items:center;">
|
||||
<span>Full log
|
||||
{% if full_log_truncated %}
|
||||
<span class="badge badge-warning" style="margin-left:0.5rem;">truncated (last 1 MB)</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm copy-log-btn"><i class="bi bi-clipboard"></i> Copy</button>
|
||||
<div class="id-main">
|
||||
<section class="panel id-log" aria-labelledby="logHead">
|
||||
<div class="id-loghead">
|
||||
<div class="grow"><h2 id="logHead">Full log</h2>
|
||||
{% if full_log_truncated %}{{ ui.chip('Truncated: last 1 MB', 'warn') }}{% endif %}</div>
|
||||
{% if full_log %}<button type="button" class="ui-btn sm copy-log-btn">Copy log</button>{% endif %}
|
||||
</div>
|
||||
{% if full_log %}
|
||||
<pre id="full-log" class="mono mb-0" style="max-height: 36rem; overflow: auto; white-space: pre-wrap; background: var(--bg); padding: 0.75rem; border-radius: 0.25rem;">{{ full_log }}</pre>
|
||||
<pre id="full-log" class="id-pre" tabindex="0" aria-label="Full log">{{ full_log }}</pre>
|
||||
{% else %}
|
||||
<div class="status-indicator">
|
||||
<i class="bi bi-info-circle"></i>
|
||||
<span>No log content. The client has not pushed any <code>log_lines</code>
|
||||
entries yet, or the sidecar file was cleared.</span>
|
||||
</div>
|
||||
<div class="notice info">No log content. The client has not pushed any log_lines entries yet, or the sidecar file was cleared.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% if session.log_tail %}
|
||||
<div class="section-card">
|
||||
<div class="section-title">Recent tail ({{ session.log_tail | length }} line{{ 's' if session.log_tail | length != 1 }})</div>
|
||||
<pre class="mono mb-0" style="max-height: 12rem; overflow: auto; background: var(--bg); padding: 0.75rem; border-radius: 0.25rem;">{% for line in session.log_tail %}{{ line }}
|
||||
<section class="panel id-log" aria-labelledby="tailHead">
|
||||
<div class="id-loghead"><h2 id="tailHead">Recent tail</h2><span class="sub">{{ session.log_tail | length }} line{{ 's' if session.log_tail | length != 1 }}</span></div>
|
||||
<pre class="id-pre id-tail" tabindex="0" aria-label="Recent log tail">{% for line in session.log_tail %}{{ line }}
|
||||
{% endfor %}</pre>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
function copyText(text) {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
return navigator.clipboard.writeText(text);
|
||||
}
|
||||
return new Promise(function(resolve, reject) {
|
||||
var ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.position = 'fixed'; ta.style.left = '-9999px';
|
||||
document.body.appendChild(ta);
|
||||
ta.focus(); ta.select();
|
||||
try {
|
||||
var ok = document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
if (ok) resolve(); else reject(new Error('execCommand returned false'));
|
||||
} catch (err) {
|
||||
document.body.removeChild(ta);
|
||||
reject(err);
|
||||
}
|
||||
// Copy log: the log can be up to 1 MB, so it is read from the <pre> rather
|
||||
// than carried in a data-copy-text attribute. Same fallback as static/app.js
|
||||
// (the LAN is plain http, so navigator.clipboard is often unavailable).
|
||||
(function () {
|
||||
function copyText(text) {
|
||||
if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.writeText(text);
|
||||
return new Promise(function (resolve, reject) {
|
||||
var ta = document.createElement('textarea');
|
||||
ta.value = text; ta.setAttribute('readonly', ''); ta.style.position = 'fixed'; ta.style.opacity = '0';
|
||||
document.body.appendChild(ta); ta.select();
|
||||
var ok = false;
|
||||
try { ok = document.execCommand('copy'); } catch (err) { ok = false; }
|
||||
document.body.removeChild(ta);
|
||||
if (ok) resolve(); else reject(new Error('copy failed'));
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('click', function(e) {
|
||||
var logBtn = e.target.closest('.copy-log-btn');
|
||||
var summaryBtn = e.target.closest('.copy-summary-btn');
|
||||
if (logBtn) {
|
||||
var pre = document.getElementById('full-log');
|
||||
if (pre) {
|
||||
copyText(pre.textContent).then(function() {
|
||||
logBtn.innerHTML = '<i class="bi bi-check2"></i> copied!';
|
||||
setTimeout(function() { logBtn.innerHTML = '<i class="bi bi-clipboard"></i> Copy'; }, 1200);
|
||||
});
|
||||
}
|
||||
} else if (summaryBtn) {
|
||||
var lines = [];
|
||||
document.querySelectorAll('dl dt').forEach(function(dt) {
|
||||
var dd = dt.nextElementSibling;
|
||||
if (dd) {
|
||||
lines.push(dt.textContent.trim() + ': ' + dd.textContent.trim().replace(/\s+/g, ' '));
|
||||
}
|
||||
});
|
||||
var rows = document.querySelectorAll('table tbody tr');
|
||||
if (rows.length) {
|
||||
lines.push('');
|
||||
lines.push('Stage timeline:');
|
||||
rows.forEach(function(tr) {
|
||||
var cells = tr.querySelectorAll('td');
|
||||
if (cells.length === 4) {
|
||||
lines.push(' - ' + cells[0].textContent.trim() + ' stage ' + cells[1].textContent.trim()
|
||||
+ ' ' + cells[2].textContent.trim() + ' ' + cells[3].textContent.trim());
|
||||
}
|
||||
});
|
||||
}
|
||||
copyText(lines.join('\n')).then(function() {
|
||||
summaryBtn.innerHTML = '<i class="bi bi-check2"></i> copied!';
|
||||
setTimeout(function() { summaryBtn.innerHTML = '<i class="bi bi-clipboard"></i> Copy support summary'; }, 1200);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
document.addEventListener('click', function (e) {
|
||||
var btn = e.target.closest && e.target.closest('.copy-log-btn');
|
||||
if (!btn) return;
|
||||
var pre = document.getElementById('full-log');
|
||||
if (!pre) return;
|
||||
var done = function (ok) {
|
||||
btn.textContent = ok ? 'Copied' : 'Copy failed';
|
||||
setTimeout(function () { btn.textContent = 'Copy log'; }, 1400);
|
||||
};
|
||||
copyText(pre.textContent).then(function () { done(true); }, function () { done(false); });
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,164 +1,151 @@
|
||||
{% extends "base.html" %}
|
||||
{% import '_ui.html' as ui %}
|
||||
{% block title %}Image Import - PXE Server Manager{% endblock %}
|
||||
|
||||
{# Image import: two panels (network upload folder, USB drive), then the
|
||||
images one line each. Submitting asks for confirmation (upload-folder
|
||||
sources are MOVED, same-name files are overwritten) and then disables the
|
||||
button with a spinner while the import runs. #}
|
||||
|
||||
{% block extra_head %}
|
||||
<style>
|
||||
.imp-cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(420px, 100%), 1fr)); gap: 22px; align-items: start; }
|
||||
.imp-panel { padding: 20px 24px 22px; display: flex; flex-direction: column; gap: 16px; }
|
||||
.imp-panel h2 { margin: 0; font-size: 16px; font-weight: 600; }
|
||||
.imp-panel .lead { margin: 2px 0 0; font-size: 13px; color: var(--muted); }
|
||||
.imp-panel form { display: flex; flex-direction: column; gap: 16px; margin: 0; }
|
||||
.imp-field { display: flex; flex-direction: column; gap: 6px; }
|
||||
.imp-field label { font-size: 13px; font-weight: 600; color: var(--text-2); }
|
||||
.imp-field .field { width: 100%; }
|
||||
.imp-field .sub { line-height: 1.5; }
|
||||
.imp-actions { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
|
||||
.imp-empty { display: flex; flex-direction: column; gap: 10px; align-items: flex-start; font-size: 14px; color: var(--text-2); line-height: 1.6; }
|
||||
.imp-empty p { margin: 0; }
|
||||
|
||||
.ig { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.3fr) 92px 112px auto; align-items: center; gap: 0 14px;
|
||||
padding: 11px 24px; font-size: 14px; border-top: 1px solid var(--line); }
|
||||
.ig.ihead { font-size: 12px; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); padding-top: 6px; padding-bottom: 6px; border-top: 0; }
|
||||
.ig .name { font-weight: 600; }
|
||||
.ig a.ui-btn { justify-self: end; }
|
||||
.imp-phead { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; padding: 16px 24px 10px; }
|
||||
.imp-phead h2 { margin: 0; font-size: 16px; font-weight: 600; }
|
||||
@container (max-width: 720px) {
|
||||
.ig { grid-template-columns: minmax(0, 1fr) auto auto; padding: 11px 16px; gap: 6px 10px; }
|
||||
.ig .c-path { grid-column: 1 / -1; grid-row: 2; }
|
||||
.ig .c-un { display: none; }
|
||||
.ig.ihead { display: none; }
|
||||
.imp-phead { padding-left: 16px; padding-right: 16px; }
|
||||
}
|
||||
@media (max-width: 600px) { .imp-panel { padding: 16px; } }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1><i class="bi bi-box-arrow-in-down"></i> Image Import</h1>
|
||||
{{ ui.head('Image import', 'Images', "Copy WinPE deployment content into an image's Deploy folder, from the network upload share or a USB drive.") }}
|
||||
|
||||
<div class="ui-stack">
|
||||
<div class="imp-cols">
|
||||
|
||||
<!-- Network upload import -->
|
||||
<section class="panel imp-panel" aria-labelledby="imp-up-h">
|
||||
<div>
|
||||
<h2 id="imp-up-h">From the network upload share</h2>
|
||||
<p class="lead">Files copied over SMB to <span class="mono">\\172.16.9.1\image-upload</span>. They are moved, not copied.</p>
|
||||
</div>
|
||||
{% if upload_sources %}
|
||||
<form method="POST" id="uploadImportForm">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="imp-field">
|
||||
<label for="uploadSource">Source</label>
|
||||
<select class="field" name="source" id="uploadSource" required>
|
||||
<option value="">Select an upload folder</option>
|
||||
{% for src in upload_sources %}
|
||||
<option value="{{ src }}">{{ src }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="imp-field">
|
||||
<label for="uploadTarget">Target image</label>
|
||||
<select class="field" name="target" id="uploadTarget" required>
|
||||
<option value="">Select the image to import into</option>
|
||||
{% for it in image_types %}
|
||||
<option value="{{ it }}">{{ friendly_names[it] }} ({{ it }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<span class="sub">Content goes into the image's Deploy folder. Out-of-box Drivers are pooled once in a shared folder and linked from each image to save disk space.</span>
|
||||
</div>
|
||||
<div class="notice"><span><strong>The files are moved out of the upload folder</strong>, so the source is used up.
|
||||
Files with the same name in the image are overwritten. Large images take several minutes.</span></div>
|
||||
<div class="imp-actions">
|
||||
<button type="submit" class="ui-btn pri" id="uploadImportBtn">Start import</button>
|
||||
</div>
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="imp-empty">
|
||||
{{ ui.chip('Nothing uploaded', 'off') }}
|
||||
<p>Map <span class="mono">\\172.16.9.1\image-upload</span> on your Windows PC and copy the Deploy folder contents there, then refresh.</p>
|
||||
<button type="button" class="ui-btn" onclick="location.reload()">Refresh</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<!-- USB import -->
|
||||
<section class="panel imp-panel" aria-labelledby="imp-usb-h">
|
||||
<div>
|
||||
<h2 id="imp-usb-h">From a USB drive</h2>
|
||||
<p class="lead">A drive mounted under <span class="mono">/mnt/</span> or <span class="mono">/media/</span>. Files are copied.</p>
|
||||
</div>
|
||||
{% if usb_mounts %}
|
||||
<form method="POST" id="importForm">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="imp-field">
|
||||
<label for="source">Source (USB mount point)</label>
|
||||
<select class="field" name="source" id="source" required>
|
||||
<option value="">Select a mounted USB drive</option>
|
||||
{% for mount in usb_mounts %}
|
||||
<option value="{{ mount }}">{{ mount }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<span class="sub">The drive holding the WinPE deployment content.</span>
|
||||
</div>
|
||||
<div class="imp-field">
|
||||
<label for="target">Target image</label>
|
||||
<select class="field" name="target" id="target" required>
|
||||
<option value="">Select the image to import into</option>
|
||||
{% for it in image_types %}
|
||||
<option value="{{ it }}">{{ friendly_names[it] }} ({{ it }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<span class="sub">Content goes into the image's Deploy folder.</span>
|
||||
</div>
|
||||
<div class="notice"><span>Files with the same name in the image are overwritten. Large images take several minutes.</span></div>
|
||||
<div class="imp-actions">
|
||||
<button type="submit" class="ui-btn pri" id="importBtn">Start import</button>
|
||||
</div>
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="imp-empty">
|
||||
{{ ui.chip('No USB drive', 'off') }}
|
||||
<p>No mounted USB drive was found under <span class="mono">/mnt/</span> or <span class="mono">/media/</span>. Mount one and refresh.</p>
|
||||
<button type="button" class="ui-btn" onclick="location.reload()">Refresh</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-lg-8">
|
||||
|
||||
<!-- Network Upload Import -->
|
||||
<div class="section-card">
|
||||
<div class="section-title"><i class="bi bi-hdd-network"></i> Import from Network Upload</div>
|
||||
{% if upload_sources %}
|
||||
<form method="POST" id="uploadImportForm">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="form-field">
|
||||
<label for="uploadSource" class="form-label">Source</label>
|
||||
<select class="form-select" name="source" id="uploadSource" required>
|
||||
<option value="">-- Select upload source --</option>
|
||||
{% for src in upload_sources %}
|
||||
<option value="{{ src }}">{{ src }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="form-text">
|
||||
Files uploaded via SMB to <code class="mono">\\172.16.9.1\image-upload</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label for="uploadTarget" class="form-label">Target Image Type</label>
|
||||
<select class="form-select" name="target" id="uploadTarget" required>
|
||||
<option value="">-- Select target image --</option>
|
||||
{% for it in image_types %}
|
||||
<option value="{{ it }}">{{ friendly_names[it] }} ({{ it }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="form-text">
|
||||
Content will be copied into the Deploy directory. Shared resources
|
||||
(Out-of-box Drivers) are stored once and linked across all image types.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info" role="alert">
|
||||
<strong>Shared Drivers:</strong> Out-of-box Drivers are automatically pooled
|
||||
into a shared directory and symlinked for each image type to save disk space.
|
||||
</div>
|
||||
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<strong>Warning:</strong> Existing files in the target Deploy directory with the
|
||||
same names will be overwritten. This operation may take several minutes for large
|
||||
images.
|
||||
</div>
|
||||
|
||||
<button type="submit" class="pxe-btn pxe-btn-primary" id="uploadImportBtn">
|
||||
<i class="bi bi-play-fill"></i> Start Import
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="text-center py-4">
|
||||
<i class="bi bi-cloud-slash" style="font-size:2rem;color:var(--text-light);"></i>
|
||||
<h5 class="mt-3" style="color:var(--text-light);">No Upload Content Found</h5>
|
||||
<p class="mb-0" style="color:var(--text-light);">
|
||||
Map <code class="mono">\\172.16.9.1\image-upload</code> on your Windows PC and copy
|
||||
the Deploy directory contents there.
|
||||
</p>
|
||||
<button class="pxe-btn pxe-btn-ghost pxe-btn-sm mt-3" onclick="location.reload()">
|
||||
<i class="bi bi-arrow-clockwise"></i> Refresh
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- USB Import -->
|
||||
<div class="section-card">
|
||||
<div class="section-title"><i class="bi bi-usb-drive"></i> Import from USB Drive</div>
|
||||
{% if usb_mounts %}
|
||||
<form method="POST" id="importForm">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="form-field">
|
||||
<label for="source" class="form-label">Source (USB Mount Point)</label>
|
||||
<select class="form-select" name="source" id="source" required>
|
||||
<option value="">-- Select a mounted USB drive --</option>
|
||||
{% for mount in usb_mounts %}
|
||||
<option value="{{ mount }}">{{ mount }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="form-text">
|
||||
Select the mounted USB drive containing the WinPE deployment content.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label for="target" class="form-label">Target Image Type</label>
|
||||
<select class="form-select" name="target" id="target" required>
|
||||
<option value="">-- Select target image --</option>
|
||||
{% for it in image_types %}
|
||||
<option value="{{ it }}">{{ friendly_names[it] }} ({{ it }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="form-text">
|
||||
Content will be copied into the Deploy directory for this image type.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<strong>Warning:</strong> Existing files in the target Deploy directory with the
|
||||
same names will be overwritten. This operation may take several minutes for large
|
||||
images.
|
||||
</div>
|
||||
|
||||
<button type="submit" class="pxe-btn pxe-btn-primary" id="importBtn">
|
||||
<i class="bi bi-play-fill"></i> Start Import
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="text-center py-4">
|
||||
<i class="bi bi-usb-drive" style="font-size:2rem;color:var(--text-light);"></i>
|
||||
<h5 class="mt-3" style="color:var(--text-light);">No USB Drives Detected</h5>
|
||||
<p class="mb-0" style="color:var(--text-light);">
|
||||
No mounted USB drives were found under <code class="mono">/mnt/</code> or <code class="mono">/media/</code>.<br>
|
||||
Mount a USB drive and refresh this page.
|
||||
</p>
|
||||
<button class="pxe-btn pxe-btn-ghost pxe-btn-sm mt-3" onclick="location.reload()">
|
||||
<i class="bi bi-arrow-clockwise"></i> Refresh
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4">
|
||||
<div class="section-card">
|
||||
<div class="section-title"><i class="bi bi-list-check"></i> Current Image Status</div>
|
||||
<div class="table-container">
|
||||
<table class="data-table mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Image</th>
|
||||
<th>Content</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for img in images %}
|
||||
<tr>
|
||||
<td>{{ img.friendly_name }}</td>
|
||||
<td>
|
||||
{% if img.has_content %}
|
||||
<span class="badge badge-success">Present</span>
|
||||
{% else %}
|
||||
<span class="badge badge-secondary">Empty</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<section class="panel ui-list" aria-labelledby="imp-st-h">
|
||||
<div class="imp-phead"><h2 id="imp-st-h">Images</h2>{{ ui.chip(images | length, 'neutral') }}</div>
|
||||
<div class="ig ihead" aria-hidden="true"><span>Image</span><span class="c-path">Deploy folder</span><span>Content</span><span class="c-un">Unattend</span><span></span></div>
|
||||
{% for img in images %}
|
||||
<div class="ig">
|
||||
<span class="tip" data-tip="{{ img.friendly_name }}"><span class="trunc name">{{ img.friendly_name }}</span></span>
|
||||
<span class="tip c-path" data-tip="{{ img.deploy_path }}"><span class="trunc mono sub">{{ img.image_type }}/Deploy</span></span>
|
||||
<span>{% if img.has_content %}{{ ui.chip('Present', 'ok') }}{% else %}{{ ui.chip('Empty', 'off') }}{% endif %}</span>
|
||||
<span class="c-un">{% if img.has_unattend %}{{ ui.chip('Unattend', 'neutral') }}{% else %}<span class="sub">No unattend</span>{% endif %}</span>
|
||||
{% if img.image_type in image_types %}<a class="ui-btn sm" href="{{ image_tab_url(img.image_type, 'overview') }}" aria-label="Open {{ img.friendly_name }}">Open</a>{% else %}<span></span>{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -181,7 +168,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
if (!confirm(msg)) { e.preventDefault(); return; }
|
||||
var btn = form.querySelector('button[type="submit"]');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span> Importing...';
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm" aria-hidden="true"></span> Importing...';
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,190 +1,176 @@
|
||||
{% extends "base.html" %}
|
||||
{% import '_ui.html' as ui %}
|
||||
{% block title %}{{ friendly_name }} - Unattend{% endblock %}
|
||||
|
||||
{#
|
||||
Unattend tab. Rows carry data-orig = their index in the file as parsed, so a
|
||||
save can tell an edited or moved item from a new one (services/unattend.py,
|
||||
"Form edits"). All behaviour is in static/unattend-editor.js; class names
|
||||
are una-* so the old handlers in static/app.js never fire here.
|
||||
are una-* so the old handlers in static/app.js never fire here. Command
|
||||
rows keep Move up / Move down / Remove (.una-up .una-down .una-remove) in
|
||||
their "..." menu; the save bar (Review changes, #unaReview) shows once
|
||||
something is edited.
|
||||
#}
|
||||
|
||||
{% block extra_head %}
|
||||
<style>
|
||||
.una-bar {
|
||||
display: flex; flex-wrap: wrap; gap: 0.5rem 1rem; align-items: center;
|
||||
justify-content: space-between; margin-bottom: 1rem;
|
||||
}
|
||||
.una-bar .nav-pills .nav-link {
|
||||
font-size: 12px; padding: 0.4rem 0.9rem; display: flex; align-items: center; gap: 0.4rem;
|
||||
color: var(--text-light); border: 1px solid var(--border); background: transparent;
|
||||
}
|
||||
.una-bar .nav-pills .nav-item + .nav-item .nav-link { margin-left: 0.4rem; }
|
||||
.una-bar .nav-pills .nav-link.active { background: var(--primary); border-color: var(--primary); color: #fff; }
|
||||
.una-meta { font-size: 12.5px; color: var(--text-light); display: flex; flex-wrap: wrap; gap: 0.25rem 0.75rem; align-items: center; }
|
||||
.una-meta .badge { font-size: 10.5px; }
|
||||
.una-lint-list { font-size: 13px; margin: 0.5rem 0 0; padding-left: 1.1rem; }
|
||||
.una-lint-list li { margin-bottom: 0.25rem; }
|
||||
.una-bar { display: flex; flex-wrap: wrap; gap: 10px 16px; align-items: center; justify-content: space-between; }
|
||||
/* Form / Raw XML switch: Bootstrap pills underneath, drawn as a segmented control */
|
||||
.una-seg { display: flex; margin: 0; padding: 0; list-style: none; border: 1px solid var(--control); border-radius: 8px; overflow: hidden; flex: none; }
|
||||
.una-seg .nav-link { display: flex; align-items: center; height: 34px; padding: 0 14px; border: 0; border-radius: 0; background: var(--surface);
|
||||
font-size: 13px; color: var(--text); text-decoration: none; }
|
||||
.una-seg .nav-item + .nav-item .nav-link { border-left: 1px solid var(--control); }
|
||||
.una-seg .nav-link.active { background: var(--text); color: var(--surface); font-weight: 600; }
|
||||
.una-meta { font-size: 13px; color: var(--muted); display: flex; flex-wrap: wrap; gap: 6px 14px; align-items: center; }
|
||||
.una-meta a.chip { text-decoration: none; }
|
||||
.una-lint-list { font-size: 13px; margin: 8px 0 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 6px; }
|
||||
.una-lint-list .chip { font-size: 11px; padding: 1px 6px; margin-right: 6px; }
|
||||
.una-lintbox { flex-direction: column; gap: 0; }
|
||||
|
||||
/* Form view: section index beside the sections (wide), or a sticky row of
|
||||
links above them (narrow). */
|
||||
.una-layout { display: block; }
|
||||
.una-index {
|
||||
position: sticky; top: 0; z-index: 20; background: var(--bg);
|
||||
margin: 0 -10px 1rem; padding: 0.5rem 10px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.una-index ul { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 0.3rem; }
|
||||
.una-index { position: sticky; top: 0; z-index: 20; background: var(--bg); margin: 0 0 14px; padding: 8px 0; }
|
||||
.una-index ul { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: nowrap; gap: 6px; overflow-x: auto; scrollbar-width: none; }
|
||||
.una-index .ix-item:not(.ix-keep), .una-index .ix-wide, .una-index .ix-long { display: none; }
|
||||
.una-index a {
|
||||
display: flex; align-items: center; gap: 0.4rem; text-decoration: none;
|
||||
font-size: 12.5px; color: var(--text-light); padding: 0.25rem 0.6rem;
|
||||
border: 1px solid var(--border); border-radius: 999px; white-space: nowrap;
|
||||
}
|
||||
.una-index a:hover { color: var(--text); border-color: var(--primary); }
|
||||
.una-index a.active { color: var(--primary); border-color: var(--primary); }
|
||||
.una-index .count { font-size: 10px; font-weight: 600; color: var(--text-light); }
|
||||
.una-index a.has-edits::after {
|
||||
content: ""; width: 6px; height: 6px; border-radius: 50%; background: var(--warning);
|
||||
}
|
||||
.una-index a { display: flex; align-items: center; gap: 6px; text-decoration: none; font-size: 13px; color: var(--text-2);
|
||||
padding: 0 12px; min-height: 30px; border: 1px solid var(--line-2); border-radius: 15px; white-space: nowrap; background: var(--surface-3); }
|
||||
.una-index a:hover { color: var(--text); }
|
||||
.una-index a.active { background: var(--text); color: var(--surface); border-color: var(--text); }
|
||||
.una-index .count { font-family: var(--font-mono); font-size: 11px; color: inherit; opacity: .8; }
|
||||
.una-index a.has-edits::after { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--act); flex: none; }
|
||||
@media (min-width: 1280px) {
|
||||
.una-layout { display: grid; grid-template-columns: 200px minmax(0, 1fr); gap: 1.5rem; align-items: start; }
|
||||
.una-index {
|
||||
top: 1rem; margin: 0; padding: 0; border-bottom: 0; background: transparent;
|
||||
max-height: calc(100vh - 2rem); overflow-y: auto;
|
||||
}
|
||||
.una-layout { display: grid; grid-template-columns: 210px minmax(0, 1fr); gap: 24px; align-items: start; }
|
||||
.una-index { top: 16px; margin: 0; padding: 0; background: transparent; max-height: calc(100vh - 32px); overflow-y: auto; }
|
||||
.una-index ul { display: block; }
|
||||
.una-index .ix-item:not(.ix-keep), .una-index .ix-wide { display: flex; }
|
||||
.una-index .ix-long { display: inline; }
|
||||
.una-index .ix-short { display: none; }
|
||||
.una-index a.ix-group {
|
||||
font-size: 11px; font-weight: 600; letter-spacing: 1px; text-transform: uppercase;
|
||||
margin-top: 0.9rem; border-left-color: transparent !important; background: transparent !important;
|
||||
}
|
||||
.una-index a { border: 0; border-radius: 7px; background: transparent; min-height: 32px; padding: 0 10px; justify-content: flex-start; }
|
||||
.una-index a.ix-group { font-size: 12px; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); margin-top: 14px; }
|
||||
.una-index li:first-child a.ix-group { margin-top: 0; }
|
||||
.una-index a {
|
||||
border: 0; border-left: 2px solid transparent; border-radius: 0;
|
||||
padding: 0.3rem 0.6rem; justify-content: flex-start;
|
||||
}
|
||||
.una-index a.active { border-left-color: var(--primary); background: rgba(65, 129, 255, 0.08); }
|
||||
.una-index a.has-edits::after { margin-left: 0.4rem; flex: none; }
|
||||
.una-index a.active { background: var(--surface); color: var(--text); font-weight: 600; box-shadow: inset 3px 0 0 var(--accent), 0 1px 2px var(--shadow); }
|
||||
.una-index a.ix-group.active { background: transparent; box-shadow: none; color: var(--text); }
|
||||
.una-index a .count { margin-left: auto; }
|
||||
}
|
||||
.una-main { min-width: 0; }
|
||||
.una-main { min-width: 0; display: flex; flex-direction: column; gap: 18px; }
|
||||
.una-section { scroll-margin-top: 4.5rem; }
|
||||
@media (min-width: 1280px) { .una-section { scroll-margin-top: 1rem; } }
|
||||
|
||||
.group-heading {
|
||||
font-size: 12px; text-transform: uppercase; letter-spacing: 1.5px; font-weight: 600;
|
||||
color: var(--text-light); margin: 1.75rem 0 0.9rem; padding-bottom: 0.4rem;
|
||||
border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 0.5rem;
|
||||
}
|
||||
.group-heading { margin: 14px 0 0; font-size: 18px; font-weight: 600; }
|
||||
.una-main > .group-heading:first-child { margin-top: 0; }
|
||||
.group-heading .bi { color: var(--primary); }
|
||||
.card-header .title-group { display: flex; align-items: center; gap: 0.5rem; font-weight: 600; }
|
||||
.card-header .title-group .bi { color: var(--primary); }
|
||||
.count-pill {
|
||||
font-size: 11px; font-weight: 600; background: var(--bg); color: var(--text-light);
|
||||
border: 1px solid var(--border); border-radius: 999px; padding: 1px 9px;
|
||||
}
|
||||
.una-hint { font-size: 12.5px; color: var(--text-light); }
|
||||
.una-empty { text-align: center; color: var(--text-light); padding: 1rem; font-size: 13.5px; }
|
||||
.una-phead { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; padding: 16px 24px 12px; }
|
||||
.una-phead h3 { margin: 0; font-size: 16px; font-weight: 600; }
|
||||
.una-phead .sp { margin-left: auto; }
|
||||
.una-pbody { padding: 0 24px 20px; }
|
||||
.una-pbody .form-label { font-size: 13px; color: var(--text-2); margin-bottom: 4px; }
|
||||
.una-pbody .form-text { font-size: 12px; color: var(--muted); }
|
||||
.una-pbody .form-control, .una-pbody .form-select { min-height: 38px; height: 38px; padding-top: 6px; padding-bottom: 6px; font-size: 14px; }
|
||||
.una-hint { font-size: 13px; color: var(--muted); }
|
||||
.una-intro { padding: 0 24px 12px; font-size: 13px; color: var(--text-2); }
|
||||
.una-empty { text-align: center; color: var(--muted); padding: 16px; font-size: 14px; border-top: 1px solid var(--line); }
|
||||
.una-foot-note { padding: 10px 24px; font-size: 13px; color: var(--text-2); border-top: 1px solid var(--line); background: var(--surface-2); border-radius: 0 0 14px 14px; }
|
||||
|
||||
/* Command filter: stays in view while the lists scroll (wide layout). */
|
||||
.una-filter {
|
||||
display: flex; flex-wrap: wrap; gap: 0.5rem 0.75rem; align-items: center;
|
||||
background: var(--bg); padding: 0.5rem 0; margin-bottom: 0.75rem;
|
||||
}
|
||||
.una-filter { display: flex; flex-wrap: wrap; gap: 8px 14px; align-items: center; background: var(--bg); padding: 8px 0; }
|
||||
@media (min-width: 1280px) { .una-filter { position: sticky; top: 0; z-index: 15; } }
|
||||
.una-filter .input-wrap { position: relative; flex: 1 1 18rem; max-width: 34rem; }
|
||||
.una-filter .input-wrap .bi { position: absolute; left: 0.7rem; top: 50%; transform: translateY(-50%); color: var(--text-light); }
|
||||
.una-filter input { padding-left: 2rem; }
|
||||
.una-filter .input-wrap { flex: 1 1 18rem; max-width: 34rem; display: flex; }
|
||||
.una-filter .field { flex: 1; min-width: 0; }
|
||||
.una-filter .una-hint strong { color: var(--text); }
|
||||
|
||||
/* One command: number + grip | description, full command, counters | actions */
|
||||
.una-list { border-top: 1px solid var(--border); }
|
||||
/* One command: number + grip | description, full command, counters | menu */
|
||||
.una-list { border-top: 1px solid var(--line); }
|
||||
.una-list:empty { border-top: 0; }
|
||||
.una-row {
|
||||
display: grid; grid-template-columns: 2.4rem minmax(0, 1fr) auto; gap: 0.6rem;
|
||||
padding: 0.6rem 0.9rem 0.6rem 0.4rem; border-bottom: 1px solid var(--border);
|
||||
border-left: 3px solid transparent; transition: background-color 0.15s;
|
||||
}
|
||||
.una-row { display: grid; grid-template-columns: 44px minmax(0, 1fr) auto; gap: 10px; padding: 10px 16px 10px 8px;
|
||||
border-bottom: 1px solid var(--line); box-shadow: inset 3px 0 0 transparent; transition: background-color .15s; }
|
||||
.una-row:last-child { border-bottom: 0; }
|
||||
.una-row.is-edited { border-left-color: var(--warning); }
|
||||
.una-row.is-new { border-left-color: var(--success); }
|
||||
.una-row.dragging { opacity: 0.45; }
|
||||
.una-row.drop-before { box-shadow: inset 0 2px 0 var(--primary); }
|
||||
.una-row.drop-after { box-shadow: inset 0 -2px 0 var(--primary); }
|
||||
.una-side { display: flex; flex-direction: column; align-items: center; gap: 0.35rem; padding-top: 0.2rem; }
|
||||
.una-pos { font-weight: 600; color: var(--text-light); font-size: 13px; line-height: 1; }
|
||||
.una-grip { color: var(--text-light); cursor: grab; padding: 0.1rem 0.3rem; border-radius: 4px; }
|
||||
.una-grip:hover { color: var(--text); background: var(--bg); }
|
||||
.una-body { min-width: 0; display: flex; flex-direction: column; gap: 0.4rem; }
|
||||
.una-note {
|
||||
font-size: 12.5px; color: var(--text-light); font-style: italic;
|
||||
display: flex; gap: 0.4rem; align-items: baseline;
|
||||
}
|
||||
.una-row.is-edited, .una-simple.is-edited { background: var(--edit-soft); box-shadow: inset 3px 0 0 var(--accent); }
|
||||
.una-row.is-new, .una-simple.is-new { background: var(--edit-soft); box-shadow: inset 3px 0 0 var(--ok); }
|
||||
.una-row.dragging { opacity: .45; }
|
||||
.una-row.drop-before { box-shadow: inset 0 2px 0 var(--accent); }
|
||||
.una-row.drop-after { box-shadow: inset 0 -2px 0 var(--accent); }
|
||||
.una-side { display: flex; flex-direction: column; align-items: center; gap: 6px; padding-top: 6px; }
|
||||
.una-pos { font-size: 13px; color: var(--muted); line-height: 1; }
|
||||
.una-grip { color: var(--muted); cursor: grab; padding: 2px 5px; border-radius: 4px; }
|
||||
.una-grip:hover { color: var(--text); background: var(--surface-3); }
|
||||
.una-body { min-width: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||
.una-note { font-size: 13px; color: var(--muted); font-style: italic; display: flex; gap: 6px; align-items: baseline; }
|
||||
.una-note .bi { font-style: normal; flex: none; }
|
||||
.una-head { display: flex; gap: 0.5rem; align-items: center; }
|
||||
.una-head .una-desc { flex: 1 1 auto; font-weight: 500; }
|
||||
.una-tag { font-size: 11px; color: var(--text-light); white-space: nowrap; display: flex; flex-direction: column; align-items: flex-end; line-height: 1.35; min-width: 5.5rem; }
|
||||
textarea.una-cmd {
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace; font-size: 12.5px; line-height: 1.5;
|
||||
resize: none; overflow: hidden; white-space: pre-wrap; overflow-wrap: anywhere; word-break: normal;
|
||||
min-height: 2.1rem;
|
||||
}
|
||||
.una-foot { display: flex; flex-wrap: wrap; gap: 0.25rem 1rem; font-size: 11.5px; color: var(--text-light); }
|
||||
.una-head { display: flex; gap: 10px; align-items: center; }
|
||||
.una-head .una-desc { flex: 1 1 auto; font-weight: 600; }
|
||||
.una-tag { font-size: 12px; color: var(--muted); white-space: nowrap; display: flex; flex-direction: column; align-items: flex-end; line-height: 1.35; min-width: 5.5rem; }
|
||||
.una-tag .chip { font-size: 11px; padding: 1px 6px; }
|
||||
textarea.una-cmd { font-family: var(--font-mono); font-size: 13px; line-height: 1.5; resize: none; overflow: hidden; white-space: pre-wrap;
|
||||
overflow-wrap: anywhere; word-break: normal; min-height: 2.1rem; }
|
||||
.una-foot { display: flex; flex-wrap: wrap; gap: 4px 16px; font-size: 12px; color: var(--muted); }
|
||||
.una-foot[hidden] { display: none; }
|
||||
.una-inner { font-style: italic; }
|
||||
.una-count.near { color: var(--warning-dark); }
|
||||
[data-theme="dark"] .una-count.near { color: var(--warning); }
|
||||
.una-count.over { color: var(--danger); font-weight: 600; }
|
||||
.una-actions { display: flex; flex-direction: column; gap: 0.3rem; }
|
||||
.una-actions .btn { padding: 6px 9px; }
|
||||
.filtering .una-grip, .filtering .una-up, .filtering .una-down { opacity: 0.3; pointer-events: none; }
|
||||
.una-count.near { color: var(--warn-text); }
|
||||
.una-count.over { color: var(--fail-text); font-weight: 600; }
|
||||
.una-actions { display: flex; align-items: flex-start; padding-top: 2px; }
|
||||
.filtering .una-grip, .filtering .una-up, .filtering .una-down { opacity: .3; pointer-events: none; }
|
||||
.una-row[hidden] { display: none; }
|
||||
|
||||
/* Short rows (driver paths, accounts) */
|
||||
.una-simple { display: grid; grid-template-columns: 2.2rem minmax(0, 1fr) auto; gap: 0.6rem; align-items: center; padding: 0.55rem 1rem 0.55rem 0.5rem; border-bottom: 1px solid var(--border); border-left: 3px solid transparent; }
|
||||
.una-simple { display: grid; grid-template-columns: 36px minmax(0, 1fr) auto; gap: 10px; align-items: center; padding: 8px 16px 8px 8px; border-bottom: 1px solid var(--line); }
|
||||
.una-simple:last-child { border-bottom: 0; }
|
||||
.una-simple.is-edited { border-left-color: var(--warning); }
|
||||
.una-simple.is-new { border-left-color: var(--success); }
|
||||
.una-simple .una-pos { text-align: center; }
|
||||
.una-acct-fields { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 0.5rem; }
|
||||
.una-acct-fields { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; }
|
||||
@media (max-width: 1100px) { .una-acct-fields { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
.una-acct-fields label { font-size: 11px; color: var(--text-light); margin-bottom: 0.15rem; display: block; }
|
||||
.una-acct-fields label { font-size: 12px; color: var(--muted); margin-bottom: 2px; display: block; }
|
||||
|
||||
.una-field-changed { border-color: var(--warning) !important; }
|
||||
.una-field-changed { border-color: var(--accent) !important; box-shadow: inset 0 0 0 1px var(--accent); }
|
||||
|
||||
.raw-xml-editor {
|
||||
font-family: 'SF Mono', 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||
font-size: 0.85rem; min-height: 600px; height: 70vh; tab-size: 4; white-space: pre; resize: vertical;
|
||||
}
|
||||
.raw-xml-editor { font-family: var(--font-mono); font-size: 13px; min-height: 600px; height: 70vh; tab-size: 4; white-space: pre; resize: vertical; }
|
||||
|
||||
/* Review modal additions (the modal itself is in _image_header.html) */
|
||||
.una-review-lint { margin-top: 1rem; }
|
||||
#imageReviewBody > .notice { margin-bottom: 12px; }
|
||||
.una-review-lint .alert { font-size: 13.5px; }
|
||||
.una-lint-ok { font-size: 13px; color: var(--text-light); display: flex; gap: 0.4rem; align-items: center; }
|
||||
.una-lint-ok .bi { color: var(--success-dark); }
|
||||
[data-theme="dark"] .una-lint-ok .bi { color: var(--success); }
|
||||
.una-review-xml summary { cursor: pointer; font-size: 13px; color: var(--text-light); margin-top: 1rem; }
|
||||
.una-review-xml pre {
|
||||
max-height: 50vh; overflow: auto; font-size: 12px; padding: 0.75rem; margin: 0.5rem 0 0;
|
||||
background: var(--bg-card-solid); border: 1px solid var(--border); border-radius: 0.25rem; white-space: pre;
|
||||
.una-lint-ok { font-size: 13px; color: var(--muted); display: flex; gap: 6px; align-items: center; }
|
||||
.una-lint-ok .bi { color: var(--ok); }
|
||||
.una-review-xml summary { cursor: pointer; font-size: 13px; color: var(--accent); font-weight: 600; margin-top: 1rem; }
|
||||
.una-review-xml pre { max-height: 50vh; overflow: auto; font-size: 12px; padding: 12px; margin: 8px 0 0; background: var(--surface-2);
|
||||
border: 1px solid var(--line-2); border-radius: 8px; white-space: pre; }
|
||||
|
||||
.savebar { position: sticky; bottom: 12px; z-index: 50; display: none; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px;
|
||||
padding: 12px 12px 12px 20px; border-radius: 12px; background: var(--savebar-bg); border: 1px solid var(--savebar-edge);
|
||||
color: var(--savebar-text); box-shadow: 0 8px 24px rgba(0,0,0,.25); }
|
||||
.dirty .savebar { display: flex; }
|
||||
.savebar .ui-btn { background: transparent; color: var(--savebar-text); border-color: #8E9AAB; }
|
||||
.savebar .ui-btn.pri { background: var(--savebar-pri); border-color: var(--savebar-pri); color: var(--on-savebar-pri); }
|
||||
.savebar .dirty-flag { display: inline-flex; align-items: center; gap: 8px; font-size: 14px; }
|
||||
.savebar .dirty-flag .dot { background: var(--act); }
|
||||
.savebar .sub { color: var(--savebar-sub); }
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.una-phead, .una-intro, .una-foot-note { padding-left: 16px; padding-right: 16px; }
|
||||
.una-pbody { padding: 0 16px 16px; }
|
||||
.una-row { grid-template-columns: 30px minmax(0, 1fr) auto; padding-right: 8px; gap: 8px; }
|
||||
.una-head { flex-wrap: wrap; }
|
||||
.una-tag { flex-direction: row; gap: 8px; min-width: 0; }
|
||||
}
|
||||
.dirty-flag { font-size: 12px; color: var(--warning-dark); display: none; align-items: center; gap: 0.35rem; }
|
||||
[data-theme="dark"] .dirty-flag { color: var(--warning); }
|
||||
.dirty .dirty-flag { display: inline-flex; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% macro note(item) -%}
|
||||
{% if item.note %}<div class="una-note"><i class="bi bi-chat-left-text"></i><span>{{ item.note | join(' | ') }}</span></div>{% endif %}
|
||||
{% if item.note %}<div class="una-note"><i class="bi bi-chat-left-text" aria-hidden="true"></i><span>{{ item.note | join(' | ') }}</span></div>{% endif %}
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro row_menu(label) -%}
|
||||
{% call ui.more(label) %}
|
||||
<button type="button" class="una-up">Move up</button>
|
||||
<button type="button" class="una-down">Move down</button>
|
||||
<div class="sep"></div>
|
||||
<button type="button" class="una-remove del">Remove</button>
|
||||
{% endcall %}
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro cmd_row(cmd, idx, field, limit) -%}
|
||||
<div class="una-row" data-orig="{{ idx }}">
|
||||
<div class="una-side">
|
||||
<span class="una-pos">{{ idx + 1 }}</span>
|
||||
<span class="una-grip" title="Drag to reorder"><i class="bi bi-grip-vertical"></i></span>
|
||||
<span class="una-grip" title="Drag to reorder" aria-hidden="true"><i class="bi bi-grip-vertical"></i></span>
|
||||
</div>
|
||||
<div class="una-body">
|
||||
{{ note(cmd) }}
|
||||
@@ -197,63 +183,51 @@
|
||||
data-limit="{{ limit }}">{{ cmd[field] }}</textarea>
|
||||
<div class="una-foot">
|
||||
<span class="una-count" data-for="desc"></span>
|
||||
{% if cmd.inner_note %}<span class="una-inner" title="A comment inside this command; it is kept"><i class="bi bi-chat-left-text"></i> {{ cmd.inner_note | join(' | ') | truncate(160) }}</span>{% endif %}
|
||||
{% if cmd.inner_note %}<span class="una-inner" title="A comment inside this command; it is kept"><i class="bi bi-chat-left-text" aria-hidden="true"></i> {{ cmd.inner_note | join(' | ') | truncate(160) }}</span>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="una-actions">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm una-up" title="Move up"><i class="bi bi-arrow-up"></i></button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm una-down" title="Move down"><i class="bi bi-arrow-down"></i></button>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm una-remove" title="Remove"><i class="bi bi-trash"></i></button>
|
||||
</div>
|
||||
<div class="una-actions">{{ row_menu('command ' ~ (idx + 1)) }}</div>
|
||||
</div>
|
||||
{%- endmacro %}
|
||||
|
||||
{% block content %}
|
||||
{% set header_subtitle = 'Deploy/FlatUnattendW10.xml' %}
|
||||
{% set header_actions %}
|
||||
<span class="dirty-flag" id="unaDirty"><i class="bi bi-circle-fill" style="font-size:8px;"></i> Unsaved changes</span>
|
||||
{% if last_save %}
|
||||
<form method="POST" action="{{ url_for('images_unattend.undo', image_type=image_type) }}" class="d-inline"
|
||||
onsubmit="return confirm('Put FlatUnattendW10.xml back as it was before the save of {{ last_save.ts | local_dt }}?');">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-outline-secondary btn-sm" title="Last save: {{ last_save.ts | local_dt }} ({{ last_save.action }})">
|
||||
<i class="bi bi-arrow-counterclockwise"></i> Undo last save
|
||||
</button>
|
||||
<button type="submit" class="ui-btn" title="Last save: {{ last_save.ts | local_dt }} ({{ last_save.action }})">Undo last save</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<span class="sub">Undo last save: nothing saved from this page yet</span>
|
||||
{% endif %}
|
||||
<button type="button" class="btn btn-success btn-sm" id="unaReview" disabled title="Ctrl+S">
|
||||
<i class="bi bi-file-earmark-diff"></i> Review changes
|
||||
</button>
|
||||
{% endset %}
|
||||
{% include "_image_header.html" %}
|
||||
|
||||
<div class="ui-stack" id="unaPage">
|
||||
{% if unattend_override %}
|
||||
<div class="alert alert-warning">
|
||||
<i class="bi bi-exclamation-triangle"></i>
|
||||
<div class="notice"><i class="bi bi-exclamation-triangle" aria-hidden="true"></i><span>
|
||||
<span class="mono">{{ unattend_override }}</span> exists in this image. PESetup loads that file first,
|
||||
so edits to <span class="mono">Deploy/FlatUnattendW10.xml</span> here do not reach the PCs until it is removed.
|
||||
so edits to <span class="mono">Deploy/FlatUnattendW10.xml</span> here do not reach the PCs until it is removed.</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if not file_exists %}
|
||||
<div class="alert alert-info">
|
||||
<i class="bi bi-info-circle"></i>
|
||||
<div class="notice info"><i class="bi bi-info-circle" aria-hidden="true"></i><span>
|
||||
This image has no <span class="mono">Deploy/FlatUnattendW10.xml</span> yet. The form starts from an empty
|
||||
answer file; saving creates it.
|
||||
answer file; saving creates it.</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% set lint_errors = lint | selectattr('level', 'equalto', 'ERROR') | list %}
|
||||
<div>
|
||||
<div class="una-bar">
|
||||
<ul class="nav nav-pills" role="tablist">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link active" id="unaFormTab" data-bs-toggle="pill" href="#unaFormView" role="tab">
|
||||
<i class="bi bi-ui-checks-grid"></i> Form
|
||||
</a>
|
||||
<ul class="nav nav-pills una-seg" role="tablist" aria-label="Editor view">
|
||||
<li class="nav-item" role="presentation">
|
||||
<a class="nav-link active" id="unaFormTab" data-bs-toggle="pill" href="#unaFormView" role="tab" aria-controls="unaFormView" aria-selected="true">Form</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" id="unaRawTab" data-bs-toggle="pill" href="#unaRawView" role="tab">
|
||||
<i class="bi bi-code-slash"></i> Raw XML
|
||||
</a>
|
||||
<li class="nav-item" role="presentation">
|
||||
<a class="nav-link" id="unaRawTab" data-bs-toggle="pill" href="#unaRawView" role="tab" aria-controls="unaRawView" aria-selected="false">Raw XML</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="una-meta">
|
||||
@@ -263,25 +237,26 @@
|
||||
<span title="FlatUnattendW10.xml.bak-* next to the file">{{ backup_count }} backup{{ '' if backup_count == 1 else 's' }}</span>
|
||||
{% endif %}
|
||||
{% if not lint %}
|
||||
<span class="badge badge-success" title="The checks of scripts/lint-unattend.py">Lint clean</span>
|
||||
<span class="chip c-ok" title="The checks of scripts/lint-unattend.py">Lint clean</span>
|
||||
{% elif lint_errors %}
|
||||
<a href="#unaLint" data-bs-toggle="collapse" class="badge badge-danger text-decoration-none">{{ lint_errors | length }} lint error{{ '' if lint_errors | length == 1 else 's' }}</a>
|
||||
<a href="#unaLint" data-bs-toggle="collapse" class="chip c-err" aria-expanded="true">{{ lint_errors | length }} lint error{{ '' if lint_errors | length == 1 else 's' }}</a>
|
||||
{% else %}
|
||||
<a href="#unaLint" data-bs-toggle="collapse" class="badge badge-warning text-decoration-none">{{ lint | length }} lint warning{{ '' if lint | length == 1 else 's' }}</a>
|
||||
<a href="#unaLint" data-bs-toggle="collapse" class="chip c-warn" aria-expanded="false">{{ lint | length }} lint warning{{ '' if lint | length == 1 else 's' }}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if lint %}
|
||||
<div class="collapse {% if lint_errors %}show{% endif %}" id="unaLint">
|
||||
<div class="alert {% if lint_errors %}alert-danger{% else %}alert-warning{% endif %}">
|
||||
<strong>The file as it is now:</strong> checks from <span class="mono">scripts/lint-unattend.py</span>.
|
||||
{% if lint_errors %}One ERROR invalidates the whole answer file for its pass; fix it before imaging.{% endif %}
|
||||
<div class="collapse {% if lint_errors %}show{% endif %}" id="unaLint" style="margin-top: 12px">
|
||||
<div class="notice una-lintbox {{ 'bad' if lint_errors }}">
|
||||
<div><strong>The file as it is now:</strong> checks from <span class="mono">scripts/lint-unattend.py</span>.
|
||||
{% if lint_errors %}One ERROR invalidates the whole answer file for its pass; fix it before imaging.{% endif %}</div>
|
||||
<ul class="una-lint-list">
|
||||
{% for f in lint %}<li><span class="badge {{ 'badge-danger' if f.level == 'ERROR' else 'badge-warning' }}">{{ f.level }}</span> {{ f.message }}</li>{% endfor %}
|
||||
{% for f in lint %}<li>{{ ui.chip(f.level, 'err' if f.level == 'ERROR' else 'warn') }}{{ f.message }}</li>{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="tab-content">
|
||||
|
||||
@@ -312,14 +287,14 @@
|
||||
<div class="una-main" id="unaMain">
|
||||
|
||||
<!-- ============= SYSTEM AND REGIONAL ============= -->
|
||||
<div class="group-heading una-section" id="grp-system" data-group="system"><i class="bi bi-pc-display-horizontal"></i> System and regional</div>
|
||||
<h2 class="group-heading una-section" id="grp-system" data-group="system">System and regional</h2>
|
||||
|
||||
<section class="card una-section" id="sec-machine" data-section="machine" data-group="system">
|
||||
<div class="card-header">
|
||||
<span class="title-group"><i class="bi bi-hdd-network"></i> Machine settings</span>
|
||||
<section class="panel una-section" id="sec-machine" data-section="machine" data-group="system" aria-labelledby="h-machine">
|
||||
<div class="una-phead">
|
||||
<h3 id="h-machine">Machine settings</h3>
|
||||
<span class="una-hint">specialize pass, Microsoft-Windows-Shell-Setup</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="una-pbody">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="unaComputerName">Computer name</label>
|
||||
@@ -346,12 +321,12 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card una-section" id="sec-intl" data-section="intl" data-group="system">
|
||||
<div class="card-header">
|
||||
<span class="title-group"><i class="bi bi-globe2"></i> International</span>
|
||||
<section class="panel una-section" id="sec-intl" data-section="intl" data-group="system" aria-labelledby="h-intl">
|
||||
<div class="una-phead">
|
||||
<h3 id="h-intl">International</h3>
|
||||
<span class="una-hint">oobeSystem pass, Microsoft-Windows-International-Core</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="una-pbody">
|
||||
<div class="row g-3">
|
||||
{% for key, label in [("input_locale", "Input locale"), ("system_locale", "System locale"), ("ui_language", "UI language"), ("user_locale", "User locale")] %}
|
||||
<div class="col-md-3 col-6">
|
||||
@@ -364,17 +339,13 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card una-section" id="sec-driverpaths" data-section="driverpaths" data-group="system">
|
||||
<div class="card-header">
|
||||
<span class="title-group">
|
||||
<i class="bi bi-usb-drive"></i> Driver paths
|
||||
<span class="count-pill" data-count="driverpaths">{{ data.driver_paths | length }}</span>
|
||||
</span>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary una-add" data-list="driverpaths">
|
||||
<i class="bi bi-plus-lg"></i> Add
|
||||
</button>
|
||||
<section class="panel una-section" id="sec-driverpaths" data-section="driverpaths" data-group="system" aria-labelledby="h-dp">
|
||||
<div class="una-phead">
|
||||
<h3 id="h-dp">Driver paths</h3>
|
||||
<span class="chip c-neutral" data-count="driverpaths">{{ data.driver_paths | length }}</span>
|
||||
<button type="button" class="ui-btn sp una-add" data-list="driverpaths">Add path</button>
|
||||
</div>
|
||||
<div class="una-hint px-3 pt-2 pb-2">
|
||||
<div class="una-intro">
|
||||
Folders DISM searches for drivers while servicing the applied image (offlineServicing pass).
|
||||
<span class="mono">W:\Drivers</span> is where PESetup unpacks this model's driver pack.
|
||||
</div>
|
||||
@@ -382,8 +353,8 @@
|
||||
{% for dp in data.driver_paths %}
|
||||
<div class="una-simple" data-orig="{{ loop.index0 }}">
|
||||
<span class="una-pos">{{ loop.index }}</span>
|
||||
<input type="text" class="form-control form-control-sm mono una-dp" value="{{ dp }}" aria-label="Driver path">
|
||||
<button type="button" class="btn btn-outline-danger btn-sm una-remove" title="Remove"><i class="bi bi-trash"></i></button>
|
||||
<input type="text" class="form-control form-control-sm mono una-dp" value="{{ dp }}" aria-label="Driver path {{ loop.index }}">
|
||||
<button type="button" class="ui-btn ghost sm una-remove" aria-label="Remove driver path {{ loop.index }}">Remove</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -391,44 +362,38 @@
|
||||
</section>
|
||||
|
||||
<!-- ============= ACCOUNTS AND SIGN-IN ============= -->
|
||||
<div class="group-heading una-section" id="grp-accounts" data-group="accounts"><i class="bi bi-people"></i> Accounts and sign-in</div>
|
||||
<h2 class="group-heading una-section" id="grp-accounts" data-group="accounts">Accounts and sign-in</h2>
|
||||
|
||||
<section class="card una-section" id="sec-accounts" data-section="accounts" data-group="accounts">
|
||||
<div class="card-header">
|
||||
<span class="title-group">
|
||||
<i class="bi bi-person-badge"></i> User accounts (local)
|
||||
<span class="count-pill" data-count="accounts">{{ data.user_accounts | length }}</span>
|
||||
</span>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary una-add" data-list="accounts">
|
||||
<i class="bi bi-plus-lg"></i> Add
|
||||
</button>
|
||||
<section class="panel una-section" id="sec-accounts" data-section="accounts" data-group="accounts" aria-labelledby="h-acct">
|
||||
<div class="una-phead">
|
||||
<h3 id="h-acct">User accounts (local)</h3>
|
||||
<span class="chip c-neutral" data-count="accounts">{{ data.user_accounts | length }}</span>
|
||||
<button type="button" class="ui-btn sp una-add" data-list="accounts">Add account</button>
|
||||
</div>
|
||||
<div class="una-list" id="unaAccounts" data-list="accounts">
|
||||
{% for acct in data.user_accounts %}
|
||||
<div class="una-simple" data-orig="{{ loop.index0 }}" data-plain="{{ acct.plain_text }}">
|
||||
<span class="una-pos">{{ loop.index }}</span>
|
||||
<div class="una-acct-fields">
|
||||
<div><label>Name</label><input type="text" class="form-control form-control-sm" data-f="name" value="{{ acct.name }}"></div>
|
||||
<div><label>Password{% if acct.plain_text == 'false' %} <span title="PlainText is false in the file: this is the encoded value. A password typed here is stored as plain text.">(encoded)</span>{% endif %}</label><input type="text" class="form-control form-control-sm mono" data-f="password" value="{{ acct.password }}" autocomplete="off"></div>
|
||||
<div><label>Group</label><input type="text" class="form-control form-control-sm" data-f="group" value="{{ acct.group }}"></div>
|
||||
<div><label>Display name</label><input type="text" class="form-control form-control-sm" data-f="display_name" value="{{ acct.display_name }}"></div>
|
||||
<div><label for="unaAcct{{ loop.index }}n">Name</label><input type="text" class="form-control form-control-sm" id="unaAcct{{ loop.index }}n" data-f="name" value="{{ acct.name }}"></div>
|
||||
<div><label for="unaAcct{{ loop.index }}p">Password{% if acct.plain_text == 'false' %} <span title="PlainText is false in the file: this is the encoded value. A password typed here is stored as plain text.">(encoded)</span>{% endif %}</label><input type="text" class="form-control form-control-sm mono" id="unaAcct{{ loop.index }}p" data-f="password" value="{{ acct.password }}" autocomplete="off"></div>
|
||||
<div><label for="unaAcct{{ loop.index }}g">Group</label><input type="text" class="form-control form-control-sm" id="unaAcct{{ loop.index }}g" data-f="group" value="{{ acct.group }}"></div>
|
||||
<div><label for="unaAcct{{ loop.index }}d">Display name</label><input type="text" class="form-control form-control-sm" id="unaAcct{{ loop.index }}d" data-f="display_name" value="{{ acct.display_name }}"></div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm una-remove" title="Remove"><i class="bi bi-trash"></i></button>
|
||||
<button type="button" class="ui-btn ghost sm una-remove" aria-label="Remove account {{ acct.name }}">Remove</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="una-empty" data-empty="accounts" {% if data.user_accounts %}hidden{% endif %}>No local accounts.</div>
|
||||
<div class="card-footer una-hint">
|
||||
<i class="bi bi-shield-lock"></i> Passwords sit in the answer file as plain text. Treat this file as sensitive.
|
||||
</div>
|
||||
<div class="una-foot-note">Passwords sit in the answer file as plain text. Treat this file as sensitive.</div>
|
||||
</section>
|
||||
|
||||
<section class="card una-section" id="sec-autologon" data-section="autologon" data-group="accounts">
|
||||
<div class="card-header">
|
||||
<span class="title-group"><i class="bi bi-box-arrow-in-right"></i> AutoLogon</span>
|
||||
<section class="panel una-section" id="sec-autologon" data-section="autologon" data-group="accounts" aria-labelledby="h-al">
|
||||
<div class="una-phead">
|
||||
<h3 id="h-al">AutoLogon</h3>
|
||||
<span class="una-hint">clear the user name to remove AutoLogon</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="una-pbody">
|
||||
<div class="row g-3 align-items-end">
|
||||
<div class="col-md-3 col-6">
|
||||
<label class="form-label" for="unaAlEnabled">Enabled</label>
|
||||
@@ -460,14 +425,14 @@
|
||||
</section>
|
||||
|
||||
<!-- ============= OUT-OF-BOX EXPERIENCE ============= -->
|
||||
<div class="group-heading una-section" id="grp-oobe" data-group="oobe"><i class="bi bi-window-desktop"></i> Out-of-box experience (OOBE)</div>
|
||||
<h2 class="group-heading una-section" id="grp-oobe" data-group="oobe">Out-of-box experience (OOBE)</h2>
|
||||
|
||||
<section class="card una-section" id="sec-oobe" data-section="oobe" data-group="oobe">
|
||||
<div class="card-header">
|
||||
<span class="title-group"><i class="bi bi-toggles"></i> OOBE settings</span>
|
||||
<section class="panel una-section" id="sec-oobe" data-section="oobe" data-group="oobe" aria-labelledby="h-oobe">
|
||||
<div class="una-phead">
|
||||
<h3 id="h-oobe">OOBE settings</h3>
|
||||
<span class="una-hint">"Not set" = not in the file (Windows default)</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="una-pbody">
|
||||
<div class="row g-3">
|
||||
{% set oobe_fields = [
|
||||
("HideEULAPage", "Hide EULA page", ["true", "false"]),
|
||||
@@ -495,12 +460,12 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card una-section" id="sec-oobetz" data-section="oobetz" data-group="oobe">
|
||||
<div class="card-header">
|
||||
<span class="title-group"><i class="bi bi-clock-history"></i> OOBE time zone</span>
|
||||
<section class="panel una-section" id="sec-oobetz" data-section="oobetz" data-group="oobe" aria-labelledby="h-oobetz">
|
||||
<div class="una-phead">
|
||||
<h3 id="h-oobetz">OOBE time zone</h3>
|
||||
<span class="una-hint">oobeSystem pass; separate from the specialize time zone above</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="una-pbody">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="unaOobeTz">Time zone</label>
|
||||
@@ -512,37 +477,38 @@
|
||||
</section>
|
||||
|
||||
<!-- ============= SETUP COMMANDS ============= -->
|
||||
<div class="group-heading una-section" id="grp-commands" data-group="commands"><i class="bi bi-terminal"></i> Setup commands</div>
|
||||
<h2 class="group-heading una-section" id="grp-commands" data-group="commands">Setup commands</h2>
|
||||
|
||||
<details class="panel" style="border-radius: 10px">
|
||||
<summary class="disc" style="padding: 0 16px; min-height: 44px; color: var(--text); font-weight: 400">
|
||||
<i class="bi bi-info-circle" aria-hidden="true" style="color: var(--accent)"></i>
|
||||
<span class="trunc" style="flex: 1 1 auto"><strong>Order matters.</strong> <span class="muted">Each list runs top to bottom as a tested sequence.</span></span>
|
||||
<span class="chev" aria-hidden="true">▾</span>
|
||||
</summary>
|
||||
<p style="margin: 0; padding: 0 16px 14px 42px; font-size: 14px; line-height: 1.6; color: var(--text-2)">
|
||||
Reorder with Move up and Move down in a command's menu, or by dragging its <i class="bi bi-grip-vertical" aria-label="grip"></i> handle.
|
||||
Comments shown in italics are XML comments from the file; they move with the command below them.
|
||||
Limits (one over invalidates the whole pass): specialize command {{ limits.path }} characters,
|
||||
first logon command {{ limits.commandline }}, description {{ limits.description }}.
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<div class="una-filter" id="unaFilterBar">
|
||||
<div class="input-wrap">
|
||||
<i class="bi bi-search"></i>
|
||||
<input type="search" class="form-control form-control-sm" id="unaFilter"
|
||||
placeholder="Filter commands (text, description or position)" aria-label="Filter commands">
|
||||
<div class="input-wrap" role="search">
|
||||
<label for="unaFilter" class="vh">Filter commands</label>
|
||||
<input type="search" class="field" id="unaFilter"
|
||||
placeholder="Filter commands (text, description or position)">
|
||||
</div>
|
||||
<span class="una-hint" id="unaFilterCount"></span>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-warning">
|
||||
<i class="bi bi-exclamation-triangle"></i>
|
||||
Each list runs top to bottom as a tested sequence; <strong>order matters</strong>. Reorder with the arrows
|
||||
or by dragging the <i class="bi bi-grip-vertical"></i> handle. Comments shown in italics are XML comments
|
||||
from the file; they move with the command below them.
|
||||
Limits (one over invalidates the whole pass): specialize command {{ limits.path }} characters,
|
||||
first logon command {{ limits.commandline }}, description {{ limits.description }}.
|
||||
</div>
|
||||
|
||||
<section class="card una-section" id="sec-spec" data-section="spec" data-group="commands">
|
||||
<div class="card-header">
|
||||
<span class="title-group">
|
||||
<i class="bi bi-gear-wide-connected"></i> Specialize commands
|
||||
<span class="count-pill" data-count="spec">{{ data.specialize_commands | length }}</span>
|
||||
</span>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary una-add" data-list="spec">
|
||||
<i class="bi bi-plus-lg"></i> Add
|
||||
</button>
|
||||
<section class="panel una-section" id="sec-spec" data-section="spec" data-group="commands" aria-labelledby="h-spec">
|
||||
<div class="una-phead">
|
||||
<h3 id="h-spec">Specialize commands</h3>
|
||||
<span class="chip c-neutral" data-count="spec">{{ data.specialize_commands | length }}</span>
|
||||
<button type="button" class="ui-btn sp una-add" data-list="spec">Add command</button>
|
||||
</div>
|
||||
<div class="una-hint px-3 pt-2 pb-2">RunSynchronous: run as SYSTEM during the specialize pass, before anyone signs in.</div>
|
||||
<div class="una-intro">RunSynchronous: run as SYSTEM during the specialize pass, before anyone signs in.</div>
|
||||
<div class="una-list una-cmds" id="unaSpec" data-list="spec" data-limit="{{ limits.path }}">
|
||||
{% for cmd in data.specialize_commands %}{{ cmd_row(cmd, loop.index0, 'path', limits.path) }}{% endfor %}
|
||||
</div>
|
||||
@@ -550,17 +516,13 @@
|
||||
<div class="una-empty" data-nomatch="spec" hidden>No specialize command matches the filter.</div>
|
||||
</section>
|
||||
|
||||
<section class="card una-section" id="sec-fl" data-section="fl" data-group="commands">
|
||||
<div class="card-header">
|
||||
<span class="title-group">
|
||||
<i class="bi bi-play-circle"></i> First logon commands
|
||||
<span class="count-pill" data-count="fl">{{ data.firstlogon_commands | length }}</span>
|
||||
</span>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary una-add" data-list="fl">
|
||||
<i class="bi bi-plus-lg"></i> Add
|
||||
</button>
|
||||
<section class="panel una-section" id="sec-fl" data-section="fl" data-group="commands" aria-labelledby="h-fl">
|
||||
<div class="una-phead">
|
||||
<h3 id="h-fl">First logon commands</h3>
|
||||
<span class="chip c-neutral" data-count="fl">{{ data.firstlogon_commands | length }}</span>
|
||||
<button type="button" class="ui-btn sp una-add" data-list="fl">Add command</button>
|
||||
</div>
|
||||
<div class="una-hint px-3 pt-2 pb-2">FirstLogonCommands: run in the first user session (the AutoLogon account), oobeSystem pass.</div>
|
||||
<div class="una-intro">FirstLogonCommands: run in the first user session (the AutoLogon account), oobeSystem pass.</div>
|
||||
<div class="una-list una-cmds" id="unaFl" data-list="fl" data-limit="{{ limits.commandline }}">
|
||||
{% for cmd in data.firstlogon_commands %}{{ cmd_row(cmd, loop.index0, 'commandline', limits.commandline) }}{% endfor %}
|
||||
</div>
|
||||
@@ -574,63 +536,65 @@
|
||||
|
||||
<!-- ==================== RAW XML VIEW ==================== -->
|
||||
<div class="tab-pane fade" id="unaRawView" role="tabpanel" aria-labelledby="unaRawTab">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="title-group"><i class="bi bi-filetype-xml"></i> Raw XML</span>
|
||||
<section class="panel" aria-labelledby="h-raw">
|
||||
<div class="una-phead">
|
||||
<h3 id="h-raw">Raw XML</h3>
|
||||
<span class="una-hint">Review changes shows the diff of this text against the file</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="una-hint mb-2">
|
||||
<div class="una-pbody">
|
||||
<p class="una-hint" style="margin: 0 0 10px">
|
||||
For what the form does not cover. This text is written exactly as it stands; edits made on the
|
||||
Form view are not part of a Raw XML save (and the other way round).
|
||||
</p>
|
||||
<textarea class="form-control raw-xml-editor" id="unaRawXml" spellcheck="false"
|
||||
aria-label="Raw XML">{{ data.raw_xml }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
</div><!-- tab-content -->
|
||||
|
||||
<div class="savebar" role="region" aria-label="Unsaved changes">
|
||||
<span class="dirty-flag" id="unaDirty" role="status"><span class="dot" aria-hidden="true"></span> Unsaved changes <span class="sub">Ctrl+S reviews</span></span>
|
||||
<button type="button" class="ui-btn pri" id="unaReview" disabled title="Ctrl+S">Review changes</button>
|
||||
</div>
|
||||
</div><!-- unaPage -->
|
||||
|
||||
{# Row templates for Add #}
|
||||
<template id="unaTplCmd">
|
||||
<div class="una-row is-new">
|
||||
<div class="una-side">
|
||||
<span class="una-pos"></span>
|
||||
<span class="una-grip" title="Drag to reorder"><i class="bi bi-grip-vertical"></i></span>
|
||||
<span class="una-grip" title="Drag to reorder" aria-hidden="true"><i class="bi bi-grip-vertical"></i></span>
|
||||
</div>
|
||||
<div class="una-body">
|
||||
<div class="una-head">
|
||||
<input type="text" class="form-control form-control-sm una-desc" placeholder="Description" aria-label="Description" data-limit="{{ limits.description }}">
|
||||
<span class="una-tag"><span class="badge badge-success">new</span><span class="una-count" data-for="cmd"></span></span>
|
||||
<span class="una-tag"><span class="chip c-edit">New</span><span class="una-count" data-for="cmd"></span></span>
|
||||
</div>
|
||||
<textarea class="form-control una-cmd" rows="1" spellcheck="false" aria-label="Command" placeholder="Command"></textarea>
|
||||
<div class="una-foot"><span class="una-count" data-for="desc"></span></div>
|
||||
</div>
|
||||
<div class="una-actions">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm una-up" title="Move up"><i class="bi bi-arrow-up"></i></button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm una-down" title="Move down"><i class="bi bi-arrow-down"></i></button>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm una-remove" title="Remove"><i class="bi bi-trash"></i></button>
|
||||
</div>
|
||||
<div class="una-actions">{{ row_menu('new command') }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<template id="unaTplDp">
|
||||
<div class="una-simple is-new">
|
||||
<span class="una-pos"></span>
|
||||
<input type="text" class="form-control form-control-sm mono una-dp" placeholder="e.g. W:\Drivers" aria-label="Driver path">
|
||||
<button type="button" class="btn btn-outline-danger btn-sm una-remove" title="Remove"><i class="bi bi-trash"></i></button>
|
||||
<button type="button" class="ui-btn ghost sm una-remove" aria-label="Remove this driver path">Remove</button>
|
||||
</div>
|
||||
</template>
|
||||
<template id="unaTplAcct">
|
||||
<div class="una-simple is-new" data-plain="true">
|
||||
<span class="una-pos"></span>
|
||||
<div class="una-acct-fields">
|
||||
<div><label>Name</label><input type="text" class="form-control form-control-sm" data-f="name"></div>
|
||||
<div><label>Password</label><input type="text" class="form-control form-control-sm mono" data-f="password" autocomplete="off"></div>
|
||||
<div><label>Group</label><input type="text" class="form-control form-control-sm" data-f="group" value="Administrators"></div>
|
||||
<div><label>Display name</label><input type="text" class="form-control form-control-sm" data-f="display_name"></div>
|
||||
<div><label>Name <input type="text" class="form-control form-control-sm" data-f="name"></label></div>
|
||||
<div><label>Password <input type="text" class="form-control form-control-sm mono" data-f="password" autocomplete="off"></label></div>
|
||||
<div><label>Group <input type="text" class="form-control form-control-sm" data-f="group" value="Administrators"></label></div>
|
||||
<div><label>Display name <input type="text" class="form-control form-control-sm" data-f="display_name"></label></div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm una-remove" title="Remove"><i class="bi bi-trash"></i></button>
|
||||
<button type="button" class="ui-btn ghost sm una-remove" aria-label="Remove this account">Remove</button>
|
||||
</div>
|
||||
</template>
|
||||
{% endblock %}
|
||||
|
||||
@@ -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">▾</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"><version></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">▾</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"><version></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/<version></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-<time>-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">▾</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/<version></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-<time>-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">▾</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">▾</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"><version></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">▾</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"><version></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 %}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}BPRT Sync - PXE Server Manager{% endblock %}
|
||||
{% 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 -%}
|
||||
@@ -10,484 +11,593 @@
|
||||
{%- 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 %}
|
||||
{% 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="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 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="alert alert-danger">
|
||||
<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 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 %}
|
||||
{% 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="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>
|
||||
<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="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>
|
||||
<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>
|
||||
<a href="{{ url_for('imaging_dashboard') }}">Imaging progress</a></span>
|
||||
</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; }
|
||||
.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 %}
|
||||
<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>
|
||||
<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>
|
||||
<div class="actions"><a href="{{ url_for('bprt_sync.page') }}" class="ui-btn">Back to BPRT sync</a></div>
|
||||
</header>
|
||||
|
||||
<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 %}
|
||||
<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 %}
|
||||
<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
|
||||
<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>
|
||||
<form method="post" action="{{ url_for('bprt_sync.validate', name=b.name) }}">
|
||||
<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="pxe-btn pxe-btn-primary" {% if job.running %}disabled{% endif %}><i class="bi bi-clipboard-check"></i> Validate</button>
|
||||
<button type="submit" class="ui-btn pri" {% if job.running %}disabled{% endif %}>Validate</button>
|
||||
</form>
|
||||
</div>
|
||||
<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 %}
|
||||
<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>
|
||||
<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="alert alert-warning">{{ res.stale }}: validate again before activating.</div>{% endif %}
|
||||
{% if res.stale %}<div class="notice">{{ 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>
|
||||
<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="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 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>
|
||||
|
||||
<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>
|
||||
<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 %}<p class="text-muted mb-0">No package changes.</p>{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="hidden-bar">No package changes.</div>
|
||||
{% endfor %}
|
||||
</section>
|
||||
|
||||
{% 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>
|
||||
<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 %}
|
||||
{% 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>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<div class="section-card">
|
||||
<div class="section-title"><i class="bi bi-list-ul"></i> Files</div>
|
||||
<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 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 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 %}
|
||||
<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>
|
||||
<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 %}
|
||||
|
||||
<div class="section-card">
|
||||
<div class="section-title"><i class="bi bi-rocket-takeoff"></i> Activate</div>
|
||||
<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) }}
|
||||
<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">
|
||||
<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 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.');">
|
||||
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="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>
|
||||
<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="pxe-btn pxe-btn-ghost" {% if job.running %}disabled{% endif %}><i class="bi bi-clipboard-check"></i> Validate again</button>
|
||||
<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>
|
||||
<span class="small text-muted align-self-center">Validated {{ res.validated | local_dt }}.</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% 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>
|
||||
</div>
|
||||
|
||||
{% 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>
|
||||
{% 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>
|
||||
|
||||
<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>
|
||||
<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 --------------------------------------------------------- #}
|
||||
<div class="section-card">
|
||||
<div class="section-title"><i class="bi bi-folder-check"></i> Live BPRT</div>
|
||||
<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="alert alert-danger mb-0"><span class="mono">{{ live.path }}</span> does not exist.</div>
|
||||
<div class="pad"><div class="notice bad"><span><span class="mono">{{ live.path }}</span> does not exist.</span></div></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 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 %}
|
||||
{% 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>
|
||||
<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 %}
|
||||
{% 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>
|
||||
</section>
|
||||
|
||||
{# ---- 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>
|
||||
<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 %}
|
||||
<p class="text-muted mb-0">Nothing waiting. Run Sync-BPRT.ps1 on the work PC (below), or upload a zip.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<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 --------------------------------------------------------- #}
|
||||
<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
|
||||
<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">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>
|
||||
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 ---------------------------------------------------------- #}
|
||||
<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">
|
||||
<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="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 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="pxe-btn pxe-btn-primary" {% if job.running %}disabled{% endif %}><i class="bi bi-upload"></i> Upload and validate</button>
|
||||
<button type="submit" class="ui-btn pri" {% if job.running %}disabled{% endif %}>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 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>
|
||||
{% 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>
|
||||
{% 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>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% if zips %}<div class="hidden-bar">Uploaded zips, not unpacked yet.</div>{% endif %}
|
||||
</section>
|
||||
|
||||
{# ---- 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>
|
||||
{# ---- 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 %}
|
||||
<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 %}
|
||||
<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 %}
|
||||
|
||||
@@ -1,411 +1,500 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Dell Drivers and BIOS - PXE Server Manager{% endblock %}
|
||||
{% import '_ui.html' as ui %}
|
||||
{% block title %}Dell drivers and BIOS - PXE Manager{% endblock %}
|
||||
|
||||
{% macro size(n) -%}
|
||||
{%- if not n -%}-{%- elif n >= 1000000000 -%}{{ '%.2f' | format(n / 1000000000) }} GB{%- elif n >= 1000000 -%}{{ '%.0f' | format(n / 1000000) }} MB{%- else -%}{{ '%.0f' | format(n / 1000) }} KB{%- endif -%}
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro drv_badge(s) -%}
|
||||
{% if s == 'update' %}<span class="badge badge-warning">Update</span>
|
||||
{% elif s == 'current' %}<span class="badge badge-success">Current</span>
|
||||
{% elif s == 'no-match' %}<span class="badge badge-secondary" title="No Win11 pack in Dell's catalog matches these model names">No match</span>
|
||||
{% elif s == 'unknown-version' %}<span class="badge badge-info" title="The current file name has no A-number to compare">Unknown version</span>
|
||||
{% else %}<span class="badge badge-secondary">{{ s }}</span>{% endif %}
|
||||
{% macro drv_chip(r) -%}
|
||||
{% if r.status == 'update' and r.warnings %}{{ ui.chip('Update: decide', 'warn') }}
|
||||
{% elif r.status == 'update' %}{{ ui.chip('Update', 'act') }}
|
||||
{% elif r.status == 'current' %}{{ ui.chip('Current', 'ok') }}
|
||||
{% elif r.status == 'no-match' %}{{ ui.chip('No match', 'off') }}
|
||||
{% elif r.status == 'unknown-version' %}{{ ui.chip('Unknown version', 'neutral') }}
|
||||
{% else %}{{ ui.chip(r.status, 'neutral') }}{% endif %}
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro bios_badge(s) -%}
|
||||
{% if s == 'update' %}<span class="badge badge-warning">Update</span>
|
||||
{% elif s == 'current' %}<span class="badge badge-success">Current</span>
|
||||
{% elif s == 'different-file-family' %}<span class="badge badge-danger" title="Dell's latest BIOS for these models is a different file than ours">Different file</span>
|
||||
{% elif s == 'ambiguous' %}<span class="badge badge-danger" title="Several BIOS file families match this line">Ambiguous</span>
|
||||
{% elif s == 'no-match' %}<span class="badge badge-secondary" title="No Dell model in the catalog index matches this line">No match</span>
|
||||
{% elif s == 'error' %}<span class="badge badge-danger">Catalog error</span>
|
||||
{% else %}<span class="badge badge-secondary">{{ s }}</span>{% endif %}
|
||||
{% macro bios_chip(s) -%}
|
||||
{% if s == 'update' %}{{ ui.chip('Update', 'act') }}
|
||||
{% elif s == 'current' %}{{ ui.chip('Current', 'ok') }}
|
||||
{% elif s == 'different-file-family' %}{{ ui.chip('Different file', 'err') }}
|
||||
{% elif s == 'ambiguous' %}{{ ui.chip('Ambiguous', 'err') }}
|
||||
{% elif s == 'no-match' %}{{ ui.chip('No match', 'off') }}
|
||||
{% elif s == 'error' %}{{ ui.chip('Catalog error', 'err') }}
|
||||
{% else %}{{ ui.chip(s, 'neutral') }}{% endif %}
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro stage_badge(s) -%}
|
||||
{% if s == 'ready' %}<span class="badge badge-success">Ready</span>
|
||||
{% elif s == 'applied' %}<span class="badge badge-primary">Applied</span>
|
||||
{% elif s in ('error', 'interrupted', 'cancelled') %}<span class="badge badge-danger">{{ s | capitalize }}</span>
|
||||
{% else %}<span class="badge badge-info">{{ s | capitalize }}</span>{% endif %}
|
||||
{% macro stage_chip(s) -%}
|
||||
{% if s == 'ready' %}{{ ui.chip('Ready', 'ok') }}
|
||||
{% elif s == 'applied' %}{{ ui.chip('Applied', 'neutral') }}
|
||||
{% elif s in ('error', 'interrupted', 'cancelled') %}{{ ui.chip(s | capitalize, 'err') }}
|
||||
{% else %}{{ ui.chip(s | capitalize, 'run') }}{% endif %}
|
||||
{%- endmacro %}
|
||||
|
||||
{# One driver-pack row. The checkbox sits outside <summary> so ticking it
|
||||
never toggles the row. #}
|
||||
{% macro drv_row(r) -%}
|
||||
<div class="ui-row dl-row" data-status="{{ r.status }}">
|
||||
{% if r.pack and r.status != 'current' or r.pack and r.warnings %}
|
||||
<div class="pick"><input type="checkbox" class="form-check-input" name="drv" value="{{ r.id }}" id="pick-{{ r.id }}" {% if r.default %}checked{% endif %}>
|
||||
<label for="pick-{{ r.id }}" class="vh">Download the pack for {{ r.tokens | join(', ') }}</label></div>
|
||||
{% endif %}
|
||||
<details>
|
||||
<summary class="dgrid">
|
||||
<span class="tip" data-tip="{{ r.tokens | join(', ') }}"><span class="trunc">{{ r.tokens | join(', ') }}</span></span>
|
||||
<span class="mono c-chg">{% if r.pack %}{{ r.cur_ver or '?' }} to {{ r.pack.ver }}{% else %}{{ r.cur_ver or '-' }}{% endif %}</span>
|
||||
{% set imgs = r.images | map('replace', 'gea-', '') | join(', ') %}
|
||||
<span class="tip c-img" data-tip="{{ imgs }}"><span class="trunc sub">{{ imgs }}</span></span>
|
||||
<span>{{ drv_chip(r) }}</span>
|
||||
<span class="chev" aria-hidden="true">▾</span>
|
||||
</summary>
|
||||
<div class="dbody">
|
||||
{% for w in r.warnings %}<div class="notice">{{ w }}</div>{% endfor %}
|
||||
<dl class="kv">
|
||||
<dt>Current file</dt><dd class="mono">{{ r.current }}</dd>
|
||||
{% if r.pack %}
|
||||
<dt>Dell pack</dt><dd><span class="mono">{{ r.pack.name }}</span><div class="sub">{{ r.pack.ver }}, {{ r.pack.date }}, {{ size(r.pack.size) }}</div></dd>
|
||||
<dt>Stages as</dt><dd class="mono">{{ r.pack.zip }}</dd>
|
||||
<dt>Pack covers</dt><dd>{{ r.pack.models | join(', ') }}</dd>
|
||||
{% endif %}
|
||||
<dt>Models (WMI)</dt><dd>{{ r.tokens | join(', ') }}</dd>
|
||||
<dt>Images</dt><dd>{{ r.images | join(', ') }}</dd>
|
||||
</dl>
|
||||
{% for n in r.notes %}<div class="sub">{{ n }}</div>{% endfor %}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro bios_row(r) -%}
|
||||
<div class="ui-row dl-row" data-status="{{ r.status }}">
|
||||
{% if r.candidates and (r.status != 'current') %}
|
||||
<div class="pick"><input type="checkbox" class="form-check-input" name="bios" value="{{ r.id }}" id="pick-{{ r.id }}" {% if r.default %}checked{% endif %}>
|
||||
<label for="pick-{{ r.id }}" class="vh">Download the BIOS for line {{ r.sub }}</label></div>
|
||||
{% endif %}
|
||||
<details>
|
||||
<summary class="dgrid">
|
||||
<span class="tip" data-tip="{{ r.line }}"><span class="trunc mono">{{ r.sub }}</span></span>
|
||||
<span class="mono c-chg">{% if r.candidates %}{{ r.cur_ver }} to {{ r.candidates[0].version }}{% if r.candidates | length > 1 %} (+{{ r.candidates | length - 1 }}){% endif %}{% else %}{{ r.cur_ver or '-' }}{% endif %}</span>
|
||||
<span class="tip c-img" data-tip="{{ r.current }}"><span class="trunc sub mono">{{ r.current }}</span></span>
|
||||
<span>{{ bios_chip(r.status) }}</span>
|
||||
<span class="chev" aria-hidden="true">▾</span>
|
||||
</summary>
|
||||
<div class="dbody">
|
||||
{% for w in r.warnings %}<div class="notice">{{ w }}</div>{% endfor %}
|
||||
<dl class="kv">
|
||||
<dt>Manifest line</dt><dd class="mono">{{ r.line }}</dd>
|
||||
<dt>Current file</dt><dd><span class="mono">{{ r.current }}</span> <span class="sub">{{ r.cur_ver }}</span></dd>
|
||||
<dt>Dell latest</dt>
|
||||
<dd>
|
||||
{% if r.candidates | length > 1 %}
|
||||
<label for="pick_{{ r.id }}" class="vh">BIOS file for line {{ r.sub }}</label>
|
||||
<select class="field" id="pick_{{ r.id }}" name="pick_{{ r.id }}" style="max-width: 100%">
|
||||
{% for c in r.candidates %}<option value="{{ loop.index0 }}">{{ c.file }} ({{ c.version }}, {{ c.date }}) - {{ c.models | join(', ') | truncate(70) }}</option>{% endfor %}
|
||||
</select>
|
||||
{% elif r.candidates %}
|
||||
{% set c = r.candidates[0] %}
|
||||
<span class="mono">{{ c.file }}</span>
|
||||
<div class="sub">{{ c.version }}, {{ c.date }}, {{ size(c.size) }}; {{ c.models | join(', ') }}</div>
|
||||
<div class="sub mono" style="overflow-wrap: anywhere">sha256 {{ c.sha256 }}</div>
|
||||
{% else %}<span class="muted">none found</span>{% endif %}
|
||||
</dd>
|
||||
</dl>
|
||||
{% for n in r.notes %}<div class="sub">{{ n }}</div>{% endfor %}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
{%- endmacro %}
|
||||
|
||||
{% block extra_head %}
|
||||
<style>
|
||||
/* Long Dell file names wrap instead of pushing Status off the page. */
|
||||
.dell-updates td.wrap { min-width: 9rem; max-width: 22rem; }
|
||||
.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; }
|
||||
.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; } }
|
||||
.dl-row > .pick { position: absolute; left: 20px; top: 13px; z-index: 1; }
|
||||
.dl-row > .pick input { width: 18px; height: 18px; margin: 0; }
|
||||
.dgrid { display: grid; grid-template-columns: minmax(0, 1fr) 150px minmax(0, 200px) 130px 14px; align-items: center; gap: 0 16px;
|
||||
padding: 12px 24px 12px 56px; font-size: 14px; min-height: 24px; }
|
||||
.dgrid .c-chg { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 13px; }
|
||||
.dhead { padding-top: 4px; padding-bottom: 6px; font-size: 12px; }
|
||||
.dbody { padding: 2px 24px 16px 56px; font-size: 13px; display: flex; flex-direction: column; gap: 8px; }
|
||||
.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; }
|
||||
details.fold { border-top: 1px solid var(--line); }
|
||||
details.fold > summary { padding: 0 24px; min-height: 44px; }
|
||||
.list-foot { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px; padding: 14px 24px; border-top: 1px solid var(--line); }
|
||||
.list-foot .sub { flex: 1 1 260px; }
|
||||
.sgrid { display: grid; grid-template-columns: minmax(0, 1fr) 150px 80px 170px 14px; align-items: center; gap: 0 16px;
|
||||
padding: 12px 24px 12px 56px; font-size: 14px; min-height: 24px; }
|
||||
.stat { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.bar { flex: 1 1 auto; height: 6px; border-radius: 3px; background: var(--track-empty); overflow: hidden; min-width: 30px; }
|
||||
.bar > i { display: block; height: 100%; background: var(--run); }
|
||||
.pv { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||
.pv li { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); gap: 4px 10px; align-items: baseline; padding: 8px 12px; border: 1px solid var(--line-2); border-radius: 8px; font-size: 13px; }
|
||||
.pv li .mono { overflow-wrap: anywhere; min-width: 0; }
|
||||
.pv .old { color: var(--del-text); } .pv .new { color: var(--add-text); }
|
||||
.pv .why { grid-column: 1 / -1; }
|
||||
.agrid { display: grid; grid-template-columns: 170px minmax(0, 1fr) 130px 14px; align-items: center; gap: 0 16px;
|
||||
padding: 12px 180px 12px 24px; font-size: 14px; min-height: 24px; }
|
||||
.abody { padding: 2px 24px 16px 210px; font-size: 13px; line-height: 1.55; display: flex; flex-direction: column; gap: 8px; }
|
||||
.agrid .when { white-space: nowrap; }
|
||||
.abody ul { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 4px; }
|
||||
.abody li { display: flex; flex-wrap: wrap; gap: 4px 12px; align-items: baseline; }
|
||||
.abody .mono { overflow-wrap: anywhere; min-width: 0; }
|
||||
.mgrid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr); gap: 4px 16px; padding: 10px 24px; border-top: 1px solid var(--line); font-size: 13px; }
|
||||
.mgrid .mono { overflow-wrap: anywhere; }
|
||||
@container (max-width: 860px) {
|
||||
.dgrid { grid-template-columns: minmax(0, 1fr) 150px auto 14px; }
|
||||
.dgrid .c-img, .dhead .c-img { display: none; }
|
||||
.sgrid { grid-template-columns: minmax(0, 1fr) 70px 190px 14px; }
|
||||
.sgrid .c-kind { display: none; }
|
||||
.mgrid { grid-template-columns: minmax(0, 1fr); }
|
||||
}
|
||||
@container (max-width: 560px) {
|
||||
.dgrid, .sgrid { grid-template-columns: minmax(0, 1fr) auto 14px; padding: 12px 16px 12px 50px; gap: 4px 10px; }
|
||||
.dgrid .c-chg { grid-column: 1 / 2; grid-row: 2; font-size: 12px; color: var(--muted); }
|
||||
.sgrid .c-size { display: none; }
|
||||
.sgrid .stat { grid-column: 1 / -1; grid-row: 2; }
|
||||
.dhead { display: none; }
|
||||
.dl-row > .pick { left: 16px; }
|
||||
.dbody { padding: 2px 16px 14px; }
|
||||
.kv { grid-template-columns: minmax(0, 1fr); }
|
||||
.kv dd { margin-bottom: 6px; }
|
||||
.agrid { grid-template-columns: minmax(0, 1fr) auto 14px; padding: 12px 16px; }
|
||||
.agrid .when { grid-column: 1 / -1; }
|
||||
.ui-row > .act { position: static; padding: 0 16px 10px; display: flex; justify-content: flex-end; }
|
||||
.abody { padding: 2px 16px 14px; }
|
||||
.sec-h, .list-foot { padding-left: 16px; padding-right: 16px; }
|
||||
.pv li { grid-template-columns: minmax(0, 1fr); }
|
||||
.mgrid { padding: 10px 16px; }
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="dell-updates">
|
||||
{% set chk = prog.check %}
|
||||
{% set dl = prog.download %}
|
||||
{% set n_drv = scan.drivers | selectattr('status', 'equalto', 'update') | list | length if scan else 0 %}
|
||||
{% set n_bios = scan.bios | selectattr('status', 'equalto', 'update') | list | length if scan else 0 %}
|
||||
<div class="page-header">
|
||||
<h1><i class="bi bi-motherboard"></i> Dell Drivers and BIOS</h1>
|
||||
<div class="header-actions">
|
||||
{% if scan %}
|
||||
{% if n_drv or n_bios %}<span class="badge badge-warning badge-lg">{{ n_drv }} driver / {{ n_bios }} BIOS update{{ 's' if n_drv + n_bios != 1 }}</span>
|
||||
{% else %}<span class="badge badge-success badge-lg">Up to date</span>{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<header class="ui-head">
|
||||
<div>
|
||||
<div class="eyebrow-t">Updates</div>
|
||||
<h1>Dell drivers and BIOS</h1>
|
||||
<div class="state-line">
|
||||
{% if prog.check_running %}{{ ui.chip('Checking', 'run') }}
|
||||
{% elif not scan %}{{ ui.chip('Not checked yet', 'off') }}
|
||||
{% elif n_drv or n_bios %}{{ ui.chip(n_drv ~ ' driver / ' ~ n_bios ~ ' BIOS update' ~ ('s' if n_drv + n_bios != 1), 'act') }}
|
||||
{% else %}{{ ui.chip('Up to date', 'ok') }}{% endif %}
|
||||
{% if scan %}<span class="sub">Checked {{ scan.checked | local_dt }}</span>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<details class="more">
|
||||
<summary class="fbtn" style="background: var(--surface); min-height: 36px">Speed cap
|
||||
<strong class="mono">{{ ('%g MB/s' | format(settings.max_mbps)) if settings.max_mbps else 'none' }}</strong><span class="chev" aria-hidden="true">▾</span></summary>
|
||||
<form class="menu" method="post" action="{{ url_for('dell_updates.save_settings') }}" style="width: min(300px, calc(100vw - 32px)); padding: 14px; gap: 10px">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<label for="maxMbps" style="font-size: 13px; font-weight: 600">Download speed cap (MB/s)</label>
|
||||
<div style="display: flex; gap: 8px"><input id="maxMbps" name="max_mbps" type="number" min="0" max="1000" step="0.5"
|
||||
value="{{ '%g' | format(settings.max_mbps) }}" aria-describedby="cap-help" class="field mono" style="width: 90px">
|
||||
<button type="submit" class="ui-btn pri">Save</button></div>
|
||||
<span id="cap-help" class="sub" style="white-space: normal">Shared by every download on the Updates pages; 0 means no cap. The site link is about 400 Mbps (50 MB/s).</span>
|
||||
</form>
|
||||
</details>
|
||||
{% if prog.check_running %}
|
||||
<form method="post" action="{{ url_for('dell_updates.cancel') }}" style="display:inline;">
|
||||
<form method="post" action="{{ url_for('dell_updates.cancel') }}" style="margin: 0">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="job" value="check">
|
||||
<button type="submit" class="pxe-btn pxe-btn-ghost"><i class="bi bi-x-circle"></i> Stop check</button>
|
||||
<button type="submit" class="ui-btn">Stop check</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form method="post" action="{{ url_for('dell_updates.check') }}" style="display:inline;">
|
||||
<form method="post" action="{{ url_for('dell_updates.check') }}" style="margin: 0">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="pxe-btn pxe-btn-secondary" {% if prog.download_running %}disabled title="A download is running"{% endif %}>
|
||||
<i class="bi bi-arrow-repeat"></i> Check for updates
|
||||
</button>
|
||||
<button type="submit" class="ui-btn pri" {% if prog.download_running %}disabled title="A download is running"{% endif %}>Check now</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="ui-stack">
|
||||
|
||||
{# ---- Progress and job outcomes ------------------------------------------ #}
|
||||
{% if prog.check_running %}
|
||||
<div class="panel strip" id="checkJob" role="status">
|
||||
<span class="pulse" aria-hidden="true"></span>
|
||||
<span>Checking Dell's catalogs: <span id="checkStep">{{ chk.step or 'Starting' }}...</span></span>
|
||||
</div>
|
||||
|
||||
<p class="text-muted">
|
||||
Compares every Win11 entry in the three images' <span class="mono">HardwareDriver.json</span> and every line of the WinPE
|
||||
BIOS manifest <span class="mono">models.txt</span> with Dell's current catalogs. Downloads are checked against Dell's sha256,
|
||||
driver packs are repacked into the zip PESetup expects, and nothing on the share changes until you preview and apply.
|
||||
Applying puts the new file next to the one it replaces, rewrites the maps and <span class="mono">models.txt</span> (backed
|
||||
up first, never reordered) and never deletes old zips or BIOS files.
|
||||
</p>
|
||||
|
||||
{# ---- Catalogs, check job, speed cap ------------------------------------- #}
|
||||
<div class="section-card">
|
||||
<div class="section-title"><i class="bi bi-cloud-arrow-down"></i> Dell catalogs</div>
|
||||
<div class="row g-3 align-items-end">
|
||||
<div class="col-lg-8">
|
||||
{% if prog.check_running %}
|
||||
<div id="checkJob">
|
||||
<div class="small mb-1"><span class="spinner-border spinner-border-sm"></span>
|
||||
<span id="checkStep">{{ chk.step or 'Starting' }}...</span></div>
|
||||
</div>
|
||||
{% elif chk.state == 'error' %}
|
||||
<div class="alert alert-danger py-2 mb-2">Last check failed: {{ chk.error }}</div>
|
||||
{% elif chk.state == 'interrupted' %}
|
||||
<div class="alert alert-warning py-2 mb-2">The last check stopped before it finished. Run it again.</div>
|
||||
{% endif %}
|
||||
{% if scan %}
|
||||
<div>Last check: <strong>{{ scan.checked | local_dt }}</strong></div>
|
||||
{% if history and history[0].at and scan.checked and history[0].at > scan.checked %}
|
||||
<div class="alert alert-warning py-2 my-2"><i class="bi bi-info-circle"></i>
|
||||
These results are from before the last apply ({{ history[0].at | local_dt }}), so rows it changed can still
|
||||
show as updates. Run <strong>Check for updates</strong> again to refresh them.</div>
|
||||
{% endif %}
|
||||
<div class="small text-muted">
|
||||
{% set cats = scan.catalogs or {} %}
|
||||
Driver pack catalog dated <span class="mono">{{ cats.drivers | local_dt }}</span>,
|
||||
catalog index dated <span class="mono">{{ cats.index | local_dt }}</span>.
|
||||
Maps read: {{ (scan.images or []) | join(', ') or 'none' }}.
|
||||
{% if scan.cab_errors %}<span class="text-danger">{{ scan.cab_errors | length }} per-model catalog(s) failed to load.</span>{% endif %}
|
||||
</div>
|
||||
{% for e in scan.map_errors %}<div class="small text-danger"><i class="bi bi-exclamation-triangle"></i> {{ e }}</div>{% endfor %}
|
||||
{% elif not prog.check_running %}
|
||||
<p class="text-muted mb-0">Not checked yet. <strong>Check for updates</strong> fetches Dell's two catalog files (under 1 MB)
|
||||
and the small per-model BIOS catalogs it needs, then compares.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-lg-4 d-flex justify-content-lg-end">
|
||||
<form method="post" action="{{ url_for('dell_updates.save_settings') }}" class="d-flex gap-2 align-items-end">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="form-field mb-0">
|
||||
<label for="maxMbps" class="form-label">Speed cap (MB/s)</label>
|
||||
<input type="number" class="form-control form-control-sm" id="maxMbps" name="max_mbps"
|
||||
min="0" max="1000" step="0.5" value="{{ '%g'|format(settings.max_mbps) }}" style="width: 7rem;">
|
||||
</div>
|
||||
<button type="submit" class="pxe-btn pxe-btn-ghost pxe-btn-sm" title="Shared by every download on the Updates pages; 0 = no cap. The site link is about 400 Mbps (50 MB/s).">Save</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% elif chk.state == 'error' %}
|
||||
<div class="errbox">Last check failed: {{ chk.error }}</div>
|
||||
{% elif chk.state == 'interrupted' %}
|
||||
<div class="notice">The last check stopped before it finished. Run it again.</div>
|
||||
{% endif %}
|
||||
{% if prog.download_running %}
|
||||
<div class="panel strip" role="status">
|
||||
<span class="pulse" aria-hidden="true"></span>
|
||||
<span>Downloading at {{ '%g MB/s' | format(settings.max_mbps) if settings.max_mbps else 'full speed' }}. Progress per item is under <a href="#staged">Staged downloads</a>; you can leave this page.</span>
|
||||
<form method="post" action="{{ url_for('dell_updates.cancel') }}"
|
||||
onsubmit="return confirm('Stop the download? Partial files are kept and resume next time.');">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="job" value="download">
|
||||
<button type="submit" class="ui-btn sm">Cancel download</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if scan and history and history[0].at and scan.checked and history[0].at > scan.checked %}
|
||||
<div class="notice">These results are from before the last apply ({{ history[0].at | local_dt }}), so rows it changed can still
|
||||
show as updates. Run Check now again to refresh them.</div>
|
||||
{% endif %}
|
||||
|
||||
{% if scan %}
|
||||
<form method="post" action="{{ url_for('dell_updates.download') }}" id="dlForm"
|
||||
<details>
|
||||
<summary class="disc"><span class="chev" aria-hidden="true">▾</span>How Dell updates work</summary>
|
||||
<p class="sub" style="margin: 4px 0 0; max-width: 900px; line-height: 1.6">
|
||||
Compares every Win11 entry in the three images' <span class="mono">HardwareDriver.json</span> and every line of the WinPE
|
||||
BIOS manifest <span class="mono">models.txt</span> with Dell's current catalogs. Downloads are checked against Dell's sha256,
|
||||
driver packs are repacked into the zip PESetup expects, and nothing on the share changes until you preview and apply.
|
||||
Applying puts the new file next to the one it replaces, rewrites the maps and <span class="mono">models.txt</span> (backed
|
||||
up first, never reordered) and never deletes old zips or BIOS files.
|
||||
</p>
|
||||
</details>
|
||||
|
||||
{% if not scan %}
|
||||
{% if not prog.check_running %}
|
||||
<section class="panel" style="padding: 22px 24px">
|
||||
<p class="muted" style="margin: 0">Not checked yet. Check now fetches Dell's two catalog files (under 1 MB) and the small
|
||||
per-model BIOS catalogs it needs, then compares.</p>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% set cats = scan.catalogs or {} %}
|
||||
{% for e in scan.map_errors %}<div class="notice bad">{{ e }}</div>{% endfor %}
|
||||
{% if scan.cab_errors %}<div class="notice bad">{{ scan.cab_errors | length }} per-model catalog(s) failed to load; their BIOS lines show as Catalog error.</div>{% endif %}
|
||||
|
||||
<form method="post" action="{{ url_for('dell_updates.download') }}" id="dlForm" class="ui-stack"
|
||||
onsubmit="var n=this.querySelectorAll('input[name=drv]:checked,input[name=bios]:checked').length; if(!n){alert('Select at least one row.');return false;} return confirm('Download ' + n + ' selected row(s) from downloads.dell.com at the speed cap? Driver packs are 1 to 4 GB each.');">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
{# ---- Driver packs ------------------------------------------------------ #}
|
||||
<div class="section-card">
|
||||
<div class="section-title d-flex flex-wrap align-items-center gap-2">
|
||||
<span><i class="bi bi-hdd-network"></i> Win11 driver packs</span>
|
||||
<span class="badge badge-secondary">{{ scan.drivers | length }}</span>
|
||||
<label class="small text-muted ms-auto"><input type="checkbox" class="form-check-input js-show-current" data-table="drvTable"> Show current rows</label>
|
||||
{% set cur_d = scan.drivers | selectattr('status', 'equalto', 'current') | list %}
|
||||
<section class="panel ui-list" id="drvTable" aria-labelledby="drv-h">
|
||||
<div class="sec-h">
|
||||
<div><h2 id="drv-h" class="sec">Win11 driver packs</h2>
|
||||
<div class="sub">One row per zip in use; standard and engineer share their zips. Updates are ticked unless a warning needs a decision.</div></div>
|
||||
<span class="sub">{{ scan.drivers | length }} pack{{ 's' if scan.drivers | length != 1 }}</span>
|
||||
</div>
|
||||
<p class="small text-muted">
|
||||
One row per zip in use; standard and engineer share their zips. Updates are ticked unless a warning needs a decision.
|
||||
An entry whose current pack carries NVIDIA drivers is skipped at apply time if the Dell pack has no NVIDIA INFs.
|
||||
</p>
|
||||
<div class="table-container">
|
||||
<table class="data-table" id="drvTable">
|
||||
<thead>
|
||||
<tr><th></th><th>Models (WMI)</th><th>Images</th><th>Current file</th><th>Dell pack</th><th>Status</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in scan.drivers %}
|
||||
<tr data-status="{{ r.status }}">
|
||||
<td>{% if r.pack and r.status != 'current' or r.pack and r.warnings %}<input type="checkbox" class="form-check-input" name="drv" value="{{ r.id }}" {% if r.default %}checked{% endif %}>{% endif %}</td>
|
||||
<td class="small wrap">{{ r.tokens | join(', ') }}</td>
|
||||
<td class="small" style="white-space: normal; min-width: 6rem;">{% for i in r.images %}{{ i | replace('gea-', '') }}{% if not loop.last %}, {% endif %}{% endfor %}</td>
|
||||
<td class="wrap"><span class="mono small">{{ r.current }}</span>{% if r.cur_ver %} <span class="badge badge-secondary">{{ r.cur_ver }}</span>{% endif %}</td>
|
||||
<td class="wrap">
|
||||
{% if r.pack %}
|
||||
<span class="mono small" title="{{ r.pack.models | join(', ') }}">{{ r.pack.name }}</span>
|
||||
<div class="small text-muted">{{ r.pack.ver }}, {{ r.pack.date }}, {{ size(r.pack.size) }}, stages as <span class="mono">{{ r.pack.zip }}</span></div>
|
||||
{% else %}<span class="text-muted">-</span>{% endif %}
|
||||
</td>
|
||||
<td class="wrap">{{ drv_badge(r.status) }}
|
||||
{% for w in r.warnings %}<div class="small text-danger mt-1"><i class="bi bi-exclamation-triangle"></i> {{ w }}</div>{% endfor %}
|
||||
{% for n in r.notes %}<div class="small text-muted mt-1">{{ n }}</div>{% endfor %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dgrid dhead ui-rowhead" aria-hidden="true"><span>Models</span><span>Version</span><span class="c-img">Images</span><span>Status</span><span></span></div>
|
||||
{% for r in scan.drivers if r.status != 'current' %}{{ drv_row(r) }}{% endfor %}
|
||||
{% if cur_d %}
|
||||
<details class="fold">
|
||||
<summary class="disc"><span class="chev" aria-hidden="true">▾</span>Show {{ cur_d | length }} current pack{{ 's' if cur_d | length != 1 }}</summary>
|
||||
{% for r in cur_d %}{{ drv_row(r) }}{% endfor %}
|
||||
</details>
|
||||
{% endif %}
|
||||
<div class="hidden-bar">An entry whose current pack carries NVIDIA drivers is skipped at apply time if the Dell pack has no NVIDIA INFs.</div>
|
||||
</section>
|
||||
|
||||
{# ---- BIOS -------------------------------------------------------------- #}
|
||||
<div class="section-card">
|
||||
<div class="section-title d-flex flex-wrap align-items-center gap-2">
|
||||
<span><i class="bi bi-cpu"></i> BIOS (models.txt)</span>
|
||||
<span class="badge badge-secondary">{{ scan.bios | length }}</span>
|
||||
<label class="small text-muted ms-auto"><input type="checkbox" class="form-check-input js-show-current" data-table="biosTable"> Show current rows</label>
|
||||
{% set cur_b = scan.bios | selectattr('status', 'equalto', 'current') | list %}
|
||||
<section class="panel ui-list" id="biosTable" aria-labelledby="bios-h">
|
||||
<div class="sec-h">
|
||||
<div><h2 id="bios-h" class="sec">BIOS (models.txt)</h2>
|
||||
<div class="sub">One row per manifest line. check-bios.cmd flashes the file of the first line whose text is in the machine's model name.</div></div>
|
||||
<span class="sub">{{ scan.bios | length }} line{{ 's' if scan.bios | length != 1 }}</span>
|
||||
</div>
|
||||
<p class="small text-muted">
|
||||
One row per manifest line. check-bios.cmd flashes the file of the FIRST line whose text is in the machine's model name,
|
||||
so each Dell model is credited to the first line that catches it. Applying changes only the file and version fields
|
||||
of the ticked lines; their order and the comments stay as they are.
|
||||
</p>
|
||||
<div class="table-container">
|
||||
<table class="data-table" id="biosTable">
|
||||
<thead>
|
||||
<tr><th></th><th>Line</th><th>Current file</th><th>Dell latest</th><th>Status</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in scan.bios %}
|
||||
<tr data-status="{{ r.status }}">
|
||||
<td>{% if r.candidates and (r.status != 'current') %}<input type="checkbox" class="form-check-input" name="bios" value="{{ r.id }}" {% if r.default %}checked{% endif %}>{% endif %}</td>
|
||||
<td class="mono small text-nowrap">{{ r.sub }}</td>
|
||||
<td class="wrap"><span class="mono small">{{ r.current }}</span> <span class="badge badge-secondary">{{ r.cur_ver }}</span></td>
|
||||
<td class="wrap">
|
||||
{% if r.candidates | length > 1 %}
|
||||
<select class="form-control form-control-sm" name="pick_{{ r.id }}" style="max-width: 28rem;">
|
||||
{% for c in r.candidates %}<option value="{{ loop.index0 }}">{{ c.file }} ({{ c.version }}, {{ c.date }}) - {{ c.models | join(', ') | truncate(70) }}</option>{% endfor %}
|
||||
</select>
|
||||
{% elif r.candidates %}
|
||||
{% set c = r.candidates[0] %}
|
||||
<span class="mono small" title="sha256 {{ c.sha256 }}">{{ c.file }}</span>
|
||||
<div class="small text-muted">{{ c.version }}, {{ c.date }}, {{ size(c.size) }}; {{ c.models | join(', ') | truncate(90) }}</div>
|
||||
{% else %}<span class="text-muted">-</span>{% endif %}
|
||||
</td>
|
||||
<td class="wrap">{{ bios_badge(r.status) }}
|
||||
{% for w in r.warnings %}<div class="small text-danger mt-1"><i class="bi bi-exclamation-triangle"></i> {{ w }}</div>{% endfor %}
|
||||
{% for n in r.notes %}<div class="small text-muted mt-1">{{ n }}</div>{% endfor %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="dgrid dhead ui-rowhead" aria-hidden="true"><span>Line</span><span>Version</span><span class="c-img">Current file</span><span>Status</span><span></span></div>
|
||||
{% for r in scan.bios if r.status != 'current' %}{{ bios_row(r) }}{% endfor %}
|
||||
{% if cur_b %}
|
||||
<details class="fold">
|
||||
<summary class="disc"><span class="chev" aria-hidden="true">▾</span>Show {{ cur_b | length }} current line{{ 's' if cur_b | length != 1 }}</summary>
|
||||
{% for r in cur_b %}{{ bios_row(r) }}{% endfor %}
|
||||
</details>
|
||||
{% endif %}
|
||||
<div class="hidden-bar">Each Dell model is credited to the first line that catches it. Applying changes only the file and version fields of the ticked lines; their order and the comments stay as they are.</div>
|
||||
<div class="list-foot">
|
||||
<button type="submit" class="ui-btn pri" {% if prog.download_running or prog.check_running %}disabled{% endif %}>Download selected</button>
|
||||
<span class="sub">Ticked driver packs and BIOS lines. Runs in the background at {{ '%g MB/s' | format(settings.max_mbps) if settings.max_mbps else 'full speed (no cap)' }};
|
||||
you can leave this page. Already staged items are not fetched again.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-wrap gap-2 align-items-center mb-4">
|
||||
<button type="submit" class="pxe-btn pxe-btn-primary" {% if prog.download_running or prog.check_running %}disabled{% endif %}>
|
||||
<i class="bi bi-download"></i> Download selected
|
||||
</button>
|
||||
<span class="small text-muted">Runs in the background at {{ '%g MB/s' | format(settings.max_mbps) if settings.max_mbps else 'full speed (no cap)' }};
|
||||
you can leave this page. Already staged items are not fetched again.</span>
|
||||
</div>
|
||||
</section>
|
||||
</form>
|
||||
|
||||
<div class="sub">Driver pack catalog dated <span class="mono">{{ cats.drivers | local_dt }}</span>, catalog index dated
|
||||
<span class="mono">{{ cats.index | local_dt }}</span>. Maps read: {{ (scan.images or []) | join(', ') or 'none' }}.</div>
|
||||
|
||||
{# ---- Missing coverage ---------------------------------------------------- #}
|
||||
{% if scan.missing %}
|
||||
<div class="section-card">
|
||||
<div class="section-title"><i class="bi bi-question-diamond"></i> Seen models without coverage <span class="badge badge-secondary">{{ scan.missing | length }}</span></div>
|
||||
<p class="small text-muted">Machines that booted WinPE here with no Win11 driver entry or no BIOS line. For information only:
|
||||
new entries are added by hand (see Hardware Coverage).</p>
|
||||
<div class="table-container">
|
||||
<table class="data-table">
|
||||
<thead><tr><th>Model</th><th>SKU</th><th>Missing</th><th>Dell pack that would cover it</th><th>Dell BIOS</th></tr></thead>
|
||||
<tbody>
|
||||
{% for m in scan.missing %}
|
||||
<tr>
|
||||
<td class="mono small">{{ m.model }}</td>
|
||||
<td class="mono small">{{ m.skus | join(', ') or '-' }}</td>
|
||||
<td class="small wrap">{% if m.no_drivers %}drivers ({{ m.no_drivers | map('replace', 'gea-', '') | join(', ') }}){% endif %}{% if m.no_drivers and m.no_bios %}, {% endif %}{% if m.no_bios %}BIOS line{% endif %}</td>
|
||||
<td class="small wrap">{% if m.pack %}<span class="mono">{{ m.pack.name }}</span> ({{ m.pack.ver }}, {{ size(m.pack.size) }}){% elif m.no_drivers %}<span class="text-muted">none found</span>{% else %}-{% endif %}</td>
|
||||
<td class="small wrap">{% if m.bios %}<span class="mono">{{ m.bios.file }}</span> ({{ m.bios.version }}){% elif m.no_bios %}<span class="text-muted">none found</span>{% else %}-{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<section class="panel ui-list" aria-labelledby="miss-h">
|
||||
<details>
|
||||
<summary class="sec-h" style="align-items: center">
|
||||
<div><h2 id="miss-h" class="sec" style="font-size: 15px">Seen models without coverage</h2>
|
||||
<div class="sub">For information only: new entries are added by hand (see Hardware coverage).</div></div>
|
||||
<span style="display: flex; align-items: center; gap: 10px">{{ ui.chip(scan.missing | length ~ ' model' ~ ('s' if scan.missing | length != 1), 'neutral') }}<span class="chev" aria-hidden="true">▾</span></span>
|
||||
</summary>
|
||||
{% for m in scan.missing %}
|
||||
<div class="mgrid">
|
||||
<div><span class="mono">{{ m.model }}</span><div class="sub">SKU {{ m.skus | join(', ') or '-' }}. Missing
|
||||
{% if m.no_drivers %}drivers ({{ m.no_drivers | map('replace', 'gea-', '') | join(', ') }}){% endif %}{% if m.no_drivers and m.no_bios %}, {% endif %}{% if m.no_bios %}BIOS line{% endif %}</div></div>
|
||||
<div><span class="sub">Dell pack</span><div>{% if m.pack %}<span class="mono">{{ m.pack.name }}</span> ({{ m.pack.ver }}, {{ size(m.pack.size) }}){% elif m.no_drivers %}<span class="muted">none found</span>{% else %}-{% endif %}</div></div>
|
||||
<div><span class="sub">Dell BIOS</span><div>{% if m.bios %}<span class="mono">{{ m.bios.file }}</span> ({{ m.bios.version }}){% elif m.no_bios %}<span class="muted">none found</span>{% else %}-{% endif %}</div></div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</details>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{# ---- Staged downloads ---------------------------------------------------- #}
|
||||
<div class="section-card" id="staged">
|
||||
<div class="section-title d-flex flex-wrap align-items-center gap-2">
|
||||
<span><i class="bi bi-box-seam"></i> Staged downloads</span>
|
||||
{% if dl.state %}<span class="small text-muted">last run {{ dl.state }}{% if dl.finished %} {{ dl.finished | local_dt }}{% endif %}{% if dl.error %}: {{ dl.error }}{% endif %}</span>{% endif %}
|
||||
{% if prog.download_running %}
|
||||
<form method="post" action="{{ url_for('dell_updates.cancel') }}" class="ms-auto"
|
||||
onsubmit="return confirm('Stop the download? Partial files are kept and resume next time.');">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="job" value="download">
|
||||
<button type="submit" class="pxe-btn pxe-btn-ghost pxe-btn-sm"><i class="bi bi-x-circle"></i> Cancel download</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<section class="panel ui-list" id="staged" aria-labelledby="st-h">
|
||||
<div class="sec-h">
|
||||
<div><h2 id="st-h" class="sec">Staged downloads</h2>
|
||||
<div class="sub">Tick what to apply, then preview. Nothing on the share changes until you apply.</div></div>
|
||||
{% if dl.state %}<span class="sub">Last run {{ dl.state }}{% if dl.finished %} {{ dl.finished | local_dt }}{% endif %}</span>{% endif %}
|
||||
</div>
|
||||
{% if dl.error %}<div class="errbox" style="margin: 0 24px 12px">{{ dl.error }}</div>{% endif %}
|
||||
{% if staged %}
|
||||
<form method="post" action="{{ url_for('dell_updates.preview') }}">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="table-container">
|
||||
<table class="data-table">
|
||||
<thead><tr><th></th><th>Item</th><th>Replaces</th><th>Size</th><th>Status</th></tr></thead>
|
||||
<tbody>
|
||||
{% for it in staged %}
|
||||
<tr>
|
||||
<td>{% if it.status in ('ready', 'error', 'cancelled', 'interrupted', 'applied') %}<input type="checkbox" class="form-check-input" name="key" value="{{ it.key }}" {% if it.status == 'ready' %}checked{% endif %}>{% endif %}</td>
|
||||
<td class="wrap">
|
||||
<span class="mono small">{{ it.zip if it.kind == 'driver' else it.name }}</span>
|
||||
<div class="small text-muted">{{ 'Driver pack' if it.kind == 'driver' else 'BIOS' }} {{ it.ver }}{% if it.kind == 'driver' %} from <span class="mono">{{ it.name }}</span>{% endif %}
|
||||
{% if it.infs %}; {{ it.infs }} INFs, {{ it.nvidia_infs }} NVIDIA{% endif %}</div>
|
||||
</td>
|
||||
<td class="small wrap">
|
||||
{% if it.kind == 'driver' %}{% for c in it.replaces %}<div class="mono">{{ c }}</div>{% endfor %}
|
||||
{% else %}{% for ln in it.lines %}<div class="mono">{{ ln.sub }}</div>{% endfor %}{% endif %}
|
||||
</td>
|
||||
<td class="small text-nowrap">{{ size(it.size_file or it.size) }}</td>
|
||||
<td id="st-{{ it.key }}" class="wrap" style="min-width: 10rem;">
|
||||
{{ stage_badge(it.status) }}
|
||||
{% if it.status in ('queued', 'downloading', 'verified', 'extracting', 'zipping') %}
|
||||
<div class="progress mt-1" style="height: 6px;"><div class="progress-bar" style="width: {{ it.percent or 0 }}%;"></div></div>
|
||||
<div class="small text-muted js-pct">{{ it.percent if it.percent is not none else 0 }}%</div>
|
||||
{% endif %}
|
||||
{% if it.error %}<div class="small text-danger">{{ it.error }}</div>{% endif %}
|
||||
{% if it.applied %}<div class="small text-muted">apply {{ it.applied }}</div>{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% for it in staged %}
|
||||
{% set label = it.zip if it.kind == 'driver' else it.name %}
|
||||
<div class="ui-row dl-row">
|
||||
{% if it.status in ('ready', 'error', 'cancelled', 'interrupted', 'applied') %}
|
||||
<div class="pick"><input type="checkbox" class="form-check-input" name="key" value="{{ it.key }}" id="key-{{ it.key }}" {% if it.status == 'ready' %}checked{% endif %}>
|
||||
<label for="key-{{ it.key }}" class="vh">Select {{ label }}</label></div>
|
||||
{% endif %}
|
||||
<details {% if it.error %}open{% endif %}>
|
||||
<summary class="sgrid">
|
||||
<span class="tip" data-tip="{{ label }}"><span class="trunc mono">{{ label }}</span></span>
|
||||
<span class="sub c-kind">{{ 'Driver pack' if it.kind == 'driver' else 'BIOS' }} {{ it.ver }}</span>
|
||||
<span class="sub c-size">{{ size(it.size_file or it.size) }}</span>
|
||||
<span id="st-{{ it.key }}" class="stat">
|
||||
{{ stage_chip(it.status) }}
|
||||
{% if it.status in ('queued', 'downloading', 'verified', 'extracting', 'zipping') %}
|
||||
<span class="bar" aria-hidden="true"><i style="width: {{ it.percent or 0 }}%"></i></span>
|
||||
<span class="sub js-pct">{{ it.percent if it.percent is not none else 0 }}%</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
<span class="chev" aria-hidden="true">▾</span>
|
||||
</summary>
|
||||
<div class="dbody">
|
||||
{% if it.error %}<div class="errbox">{{ it.error }}</div>{% endif %}
|
||||
<dl class="kv">
|
||||
{% if it.kind == 'driver' %}
|
||||
<dt>Dell file</dt><dd class="mono">{{ it.name }}</dd>
|
||||
<dt>Replaces</dt><dd>{% for c in it.replaces %}<div class="mono">{{ c }}</div>{% endfor %}</dd>
|
||||
{% if it.infs %}<dt>Contents</dt><dd>{{ it.infs }} INFs, {{ it.nvidia_infs }} NVIDIA</dd>{% endif %}
|
||||
{% else %}
|
||||
<dt>BIOS lines</dt><dd>{% for ln in it.lines %}<div class="mono">{{ ln.sub }}</div>{% endfor %}</dd>
|
||||
{% endif %}
|
||||
<dt>Version</dt><dd>{{ it.ver }}</dd>
|
||||
{% if it.applied %}<dt>Applied</dt><dd class="mono">apply {{ it.applied }}</dd>{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2 mt-2">
|
||||
<button type="submit" class="pxe-btn pxe-btn-primary" {% if prog.download_running %}disabled{% endif %}><i class="bi bi-eye"></i> Preview apply</button>
|
||||
<button type="submit" class="pxe-btn pxe-btn-ghost" formaction="{{ url_for('dell_updates.discard') }}"
|
||||
{% endfor %}
|
||||
<div class="list-foot">
|
||||
<button type="submit" class="ui-btn pri" {% if prog.download_running %}disabled{% endif %}>Preview apply</button>
|
||||
<button type="submit" class="ui-btn danger" formaction="{{ url_for('dell_updates.discard') }}"
|
||||
onclick="return confirm('Delete the ticked items from the staging area? Files on the share are not touched.');"
|
||||
{% if prog.download_running %}disabled{% endif %}><i class="bi bi-trash"></i> Discard selected</button>
|
||||
{% if prog.download_running %}disabled{% endif %}>Discard selected</button>
|
||||
</div>
|
||||
</form>
|
||||
{% else %}
|
||||
<p class="text-muted mb-0">Nothing staged. Tick rows above and use <strong>Download selected</strong>.</p>
|
||||
<div class="hidden-bar">Nothing staged. Tick rows above and use Download selected.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{# ---- Preview ------------------------------------------------------------- #}
|
||||
{% if preview %}
|
||||
<div class="section-card" id="preview">
|
||||
<div class="section-title"><i class="bi bi-file-diff"></i> Preview: what Apply will change</div>
|
||||
<section class="panel" id="preview" aria-labelledby="pv-h" style="padding: 18px 24px; display: flex; flex-direction: column; gap: 12px">
|
||||
<h2 id="pv-h" class="sec">Preview: what Apply will change</h2>
|
||||
{% for image, rows in preview.drivers.items() %}
|
||||
<h6 class="mt-2">{{ image }} <span class="text-muted small">HardwareDriver.json</span></h6>
|
||||
{% if rows %}
|
||||
<div class="table-container">
|
||||
<table class="table table-sm mb-2">
|
||||
<thead><tr><th>Entry models</th><th>Old file</th><th></th><th>New file</th><th>Folder</th></tr></thead>
|
||||
<tbody>
|
||||
{% for r in rows %}
|
||||
<tr><td class="small wrap">{{ r.models }}</td><td class="mono small wrap">{{ r.old }}</td><td class="text-muted">to</td>
|
||||
<td class="mono small wrap">{{ r.new }}</td><td class="mono small text-muted wrap">{{ r.dir }}</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div>
|
||||
<div style="font-weight: 600; margin-bottom: 6px">{{ image }} <span class="sub mono">HardwareDriver.json</span></div>
|
||||
{% if rows %}
|
||||
<ul class="pv">
|
||||
{% for r in rows %}
|
||||
<li><span class="mono old">{{ r.old }}</span><span class="sub">to</span><span class="mono new">{{ r.new }}</span>
|
||||
<span class="sub why">{{ r.models }} | <span class="mono">{{ r.dir }}</span></span></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}<div class="sub">No change.</div>{% endif %}
|
||||
</div>
|
||||
{% else %}<p class="small text-muted">No change.</p>{% endif %}
|
||||
{% endfor %}
|
||||
<h6 class="mt-3">models.txt</h6>
|
||||
{% if preview.bios %}
|
||||
<div class="table-container">
|
||||
<table class="table table-sm mb-2">
|
||||
<thead><tr><th>Old line</th><th></th><th>New line</th></tr></thead>
|
||||
<tbody>
|
||||
{% for b in preview.bios %}<tr><td class="mono small wrap">{{ b.old }}</td><td class="text-muted">to</td><td class="mono small wrap">{{ b.new }}</td></tr>{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div>
|
||||
<div style="font-weight: 600; margin-bottom: 6px">models.txt</div>
|
||||
{% if preview.bios %}
|
||||
<ul class="pv">{% for b in preview.bios %}<li><span class="mono old">{{ b.old }}</span><span class="sub">to</span><span class="mono new">{{ b.new }}</span></li>{% endfor %}</ul>
|
||||
{% else %}<div class="sub">No change.</div>{% endif %}
|
||||
</div>
|
||||
{% else %}<p class="small text-muted">No change.</p>{% endif %}
|
||||
{% for e in preview.errors %}<div class="small text-danger"><i class="bi bi-exclamation-triangle"></i> {{ e }}</div>{% endfor %}
|
||||
{% for s in preview.skipped %}<div class="small text-warning"><i class="bi bi-exclamation-triangle"></i> Will skip {{ s }}</div>{% endfor %}
|
||||
{% if preview.missing %}<div class="small text-muted">Not ready, ignored: {{ preview.missing | join(', ') }}</div>{% endif %}
|
||||
<form method="post" action="{{ url_for('dell_updates.apply') }}" class="mt-3"
|
||||
{% if preview.errors %}<div class="errbox">{{ preview.errors | join('; ') }}</div>{% endif %}
|
||||
{% for s in preview.skipped %}<div class="notice">Will skip {{ s }}</div>{% endfor %}
|
||||
{% if preview.missing %}<div class="sub">Not ready, ignored: {{ preview.missing | join(', ') }}</div>{% endif %}
|
||||
<form method="post" action="{{ url_for('dell_updates.apply') }}" style="display: flex; flex-wrap: wrap; gap: 10px; justify-content: flex-end; margin: 0"
|
||||
onsubmit="return confirm('Apply these changes to the live share? The maps and models.txt are backed up first; old files are kept.');">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
{% for k in preview_keys %}<input type="hidden" name="key" value="{{ k }}">{% endfor %}
|
||||
<button type="submit" class="pxe-btn pxe-btn-success"><i class="bi bi-check2-circle"></i> Apply</button>
|
||||
<a href="{{ url_for('dell_updates.page') }}" class="pxe-btn pxe-btn-ghost">Cancel</a>
|
||||
<a href="{{ url_for('dell_updates.page') }}" class="ui-btn">Cancel</a>
|
||||
<button type="submit" class="ui-btn pri">Apply</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{# ---- History ------------------------------------------------------------- #}
|
||||
{% if history %}
|
||||
<div class="section-card">
|
||||
<div class="section-title"><i class="bi bi-clock-history"></i> Applied</div>
|
||||
<div class="table-container">
|
||||
<table class="data-table">
|
||||
<thead><tr><th>When</th><th>Changes</th><th>Backups</th><th class="text-end"></th></tr></thead>
|
||||
<tbody>
|
||||
{% for h in history %}
|
||||
<tr>
|
||||
<td class="mono small text-nowrap">{{ h.at | local_dt }}<div class="text-muted">{{ h.id }}</div></td>
|
||||
<td class="small wrap">
|
||||
{% for d in h.drivers %}<div><span class="mono">{{ d.old }}</span> to <span class="mono">{{ d.new }}</span></div>{% endfor %}
|
||||
{% for b in h.bios %}<div><span class="mono">{{ b.new }}</span></div>{% endfor %}
|
||||
{% for s in h.skipped %}<div class="text-warning">skipped {{ s }}</div>{% endfor %}
|
||||
{% for e in h.errors %}<div class="text-danger">{{ e }}</div>{% endfor %}
|
||||
</td>
|
||||
<td class="small wrap">{% for p, b in h.backups.items() %}<div class="mono">{{ b.rsplit('/', 1)[-1] }}</div>{% endfor %}</td>
|
||||
<td class="text-end text-nowrap">
|
||||
{% if h.rolled_back %}<span class="badge badge-secondary">Rolled back</span>
|
||||
{% elif h.backups %}
|
||||
<form method="post" action="{{ url_for('dell_updates.rollback') }}" style="display:inline;"
|
||||
onsubmit="return confirm('Put the maps and models.txt back as they were before {{ h.id }}? Files changed again since are left alone. The new zips and BIOS files stay on the share.');">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="apply_id" value="{{ h.id }}">
|
||||
<button type="submit" class="pxe-btn pxe-btn-ghost btn-row-action"><i class="bi bi-arrow-counterclockwise"></i> Rollback</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<section class="panel ui-list" aria-labelledby="h-h">
|
||||
<div class="sec-h"><div><h2 id="h-h" class="sec">Applied</h2>
|
||||
<div class="sub">Rollback opens a review page first; it restores only files nobody has changed since the apply.</div></div></div>
|
||||
{% for h in history %}
|
||||
{% set can = not h.rolled_back and h.unchanged > 0 %}
|
||||
<div class="ui-row">
|
||||
<details>
|
||||
<summary class="agrid">
|
||||
<span class="mono sub when">{{ h.at | local_dt }}</span>
|
||||
<span class="tip" data-tip="{{ h.title }}"><span class="trunc">{{ h.title }}</span></span>
|
||||
<span>
|
||||
{% if h.rolled_back %}{{ ui.chip('Rolled back', 'neutral') }}
|
||||
{% elif h.errors and not h.total %}{{ ui.chip('Apply failed', 'err') }}
|
||||
{% elif not h.total %}{{ ui.chip('Nothing written', 'off') }}
|
||||
{% elif h.unchanged == h.total %}{{ ui.chip(h.unchanged ~ '/' ~ h.total ~ ' unchanged', 'ok') }}
|
||||
{% elif h.unchanged %}{{ ui.chip(h.unchanged ~ '/' ~ h.total ~ ' unchanged', 'warn') }}
|
||||
{% else %}{{ ui.chip('0/' ~ h.total ~ ' unchanged', 'off') }}{% endif %}
|
||||
</span>
|
||||
<span class="chev" aria-hidden="true">▾</span>
|
||||
</summary>
|
||||
<div class="abody">
|
||||
<ul>{% for c in h.changes %}<li>{{ c }}</li>{% endfor %}
|
||||
{% for d in h.drivers %}<li class="sub"><span class="mono">{{ d.old }}</span> to <span class="mono">{{ d.new }}</span></li>{% endfor %}</ul>
|
||||
{% if h.skipped %}<div class="notice">Skipped: {{ h.skipped | join('; ') }}</div>{% endif %}
|
||||
{% if h.errors %}<div class="errbox">{{ h.errors | join('; ') }}</div>{% endif %}
|
||||
{% if h.files %}
|
||||
<ul>
|
||||
{% for f in h.files %}
|
||||
<li><span class="mono">{{ f.label }}</span>
|
||||
{% if f.state == 'unchanged' %}<span class="muted">unchanged since this apply</span>
|
||||
{% elif f.state == 'changed' %}<span>content differs from what this apply wrote{% if f.mtime %} (last modified {{ f.mtime | local_dt }}){% endif %}</span>
|
||||
{% else %}<span style="color: var(--fail-text)">missing</span>{% endif %}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% if h.backups %}<div class="sub">Backups: {% for p, b in h.backups.items() %}<span class="mono">{{ b.rsplit('/', 1)[-1] }}</span>{{ ', ' if not loop.last }}{% endfor %}</div>{% endif %}
|
||||
{% if h.rolled_back %}<div class="sub">Rolled back {{ h.rolled_back | local_dt }}.</div>{% endif %}
|
||||
<div class="sub">Apply <span class="mono">{{ h.id }}</span>.</div>
|
||||
</div>
|
||||
</details>
|
||||
{% if can %}<div class="act"><a class="ui-btn" href="{{ url_for('dell_updates.rollback_review', apply_id=h.id) }}">Review rollback</a></div>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
// Hide rows that need nothing unless asked; without JS every row shows.
|
||||
(function () {
|
||||
var boxes = document.querySelectorAll('.js-show-current');
|
||||
function paint(box) {
|
||||
var t = document.getElementById(box.dataset.table);
|
||||
if (!t) return;
|
||||
t.querySelectorAll('tbody tr[data-status=current]').forEach(function (tr) {
|
||||
var tick = tr.querySelector('input[type=checkbox]');
|
||||
if (!tick || !tick.checked) tr.style.display = box.checked ? '' : 'none';
|
||||
});
|
||||
}
|
||||
boxes.forEach(function (b) { b.addEventListener('change', function () { paint(b); }); paint(b); });
|
||||
})();
|
||||
</script>
|
||||
|
||||
</div>
|
||||
|
||||
{% if prog.check_running or prog.download_running %}
|
||||
@@ -429,9 +518,9 @@
|
||||
Object.keys(j.download.items || {}).forEach(function (k) {
|
||||
var it = j.download.items[k], cell = document.getElementById('st-' + k);
|
||||
if (!cell) return;
|
||||
var badge = cell.querySelector('.badge');
|
||||
if (badge) badge.textContent = it.status.charAt(0).toUpperCase() + it.status.slice(1);
|
||||
var bar = cell.querySelector('.progress-bar'), pct = cell.querySelector('.js-pct');
|
||||
var chip = cell.querySelector('.chip');
|
||||
if (chip && it.status) chip.textContent = it.status.charAt(0).toUpperCase() + it.status.slice(1);
|
||||
var bar = cell.querySelector('.bar > i'), pct = cell.querySelector('.js-pct');
|
||||
if (bar && it.percent != null) bar.style.width = it.percent + '%';
|
||||
if (pct && it.percent != null) pct.textContent = it.percent + '%';
|
||||
});
|
||||
|
||||
@@ -1,322 +1,424 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}iPXE Boot Files - PXE Server Manager{% endblock %}
|
||||
{% import '_ui.html' as ui %}
|
||||
{% block title %}iPXE boot files - PXE Manager{% endblock %}
|
||||
|
||||
{% macro sig_badge(info, chain_ok=none) -%}
|
||||
{% if info.kind == 'microsoft-2011' %}<span class="badge badge-warning" title="Signed only under Microsoft Corporation UEFI CA 2011. That CA's signing period ended in 2026: machines that trust only the 2023 CA, or a DBX/SBAT revocation update, will refuse this file.">Microsoft UEFI CA 2011 only</span>
|
||||
{% elif info.kind == 'microsoft-2023' %}<span class="badge badge-success" title="Signed under Microsoft UEFI CA 2023">Microsoft UEFI CA 2023</span>
|
||||
{% elif info.kind == 'microsoft' %}<span class="badge badge-info">Microsoft (other CA)</span>
|
||||
{% elif info.kind == 'ipxe' %}<span class="badge badge-success" title="Chains to the iPXE Secure Boot CA, which the iPXE shim trusts">iPXE Secure Boot CA</span>
|
||||
{% elif info.kind == 'bios' %}<span class="badge badge-secondary" title="Legacy BIOS has no Secure Boot">Legacy BIOS, unsigned</span>
|
||||
{% elif info.kind == 'unsigned' %}<span class="badge badge-danger" title="Secure Boot firmware will refuse it">Unsigned</span>
|
||||
{% elif info.kind == 'error' %}<span class="badge badge-danger" title="{{ info.sig_error }}">Not checked</span>
|
||||
{% else %}<span class="badge badge-warning">Other signer</span>{% endif %}
|
||||
{% if chain_ok == false %}<span class="badge badge-danger" title="The installed shim trusts {{ info.trusts_needed }}; this file is not signed under it, so the shim will refuse to start it">Shim will refuse</span>{% endif %}
|
||||
{# Signer as a short chip; the long explanation goes in the row body (it is
|
||||
a safety warning, so never hover-only). #}
|
||||
{% macro sig_chip(info) -%}
|
||||
{% if info.kind == 'microsoft-2011' %}{{ ui.chip('MS UEFI CA 2011 only', 'warn') }}
|
||||
{% elif info.kind == 'microsoft-2023' %}{{ ui.chip('MS UEFI CA 2023', 'ok') }}
|
||||
{% elif info.kind == 'microsoft' %}{{ ui.chip('Microsoft, other CA', 'neutral') }}
|
||||
{% elif info.kind == 'ipxe' %}{{ ui.chip('iPXE Secure Boot CA', 'ok') }}
|
||||
{% elif info.kind == 'bios' %}{{ ui.chip('Legacy BIOS', 'neutral') }}
|
||||
{% elif info.kind == 'unsigned' %}{{ ui.chip('Unsigned', 'err') }}
|
||||
{% elif info.kind == 'error' %}{{ ui.chip('Not checked', 'err') }}
|
||||
{% else %}{{ ui.chip('Other signer', 'warn') }}{% endif %}
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro sig_text(info, chain_ok=none) -%}
|
||||
{% if info.kind == 'microsoft-2011' %}<div class="notice">Signed only under Microsoft Corporation UEFI CA 2011. That CA's signing period ended in 2026: machines that trust only the 2023 CA, or a DBX/SBAT revocation update, will refuse this file.</div>
|
||||
{% elif info.kind == 'microsoft-2023' %}<div class="sub">Signed under Microsoft UEFI CA 2023.</div>
|
||||
{% elif info.kind == 'ipxe' %}<div class="sub">Chains to the iPXE Secure Boot CA, which the iPXE shim trusts.</div>
|
||||
{% elif info.kind == 'bios' %}<div class="sub">Legacy BIOS has no Secure Boot; the file is unsigned by design.</div>
|
||||
{% elif info.kind == 'unsigned' %}<div class="notice bad">Unsigned: Secure Boot firmware will refuse it.</div>
|
||||
{% elif info.kind == 'error' %}<div class="notice bad">Signature not checked: {{ info.sig_error }}</div>
|
||||
{% elif info.kind == 'microsoft' %}<div class="sub">Signed by Microsoft under a CA other than UEFI CA 2011 or 2023.</div>
|
||||
{% elif info.kind %}<div class="notice">Signed by a signer that is neither Microsoft nor the iPXE CA.</div>{% endif %}
|
||||
{% if chain_ok == false %}<div class="notice bad">Shim will refuse: the installed shim trusts <span class="mono">{{ info.trusts_needed }}</span>; this file is not signed under it, so the shim will not start it.</div>{% endif %}
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro chain(info) -%}
|
||||
{% if info.signers %}
|
||||
<details class="mt-1">
|
||||
<summary class="small text-muted" style="cursor:pointer;">Signer chain ({{ info.signers | length }})</summary>
|
||||
<div><span class="sub">Signer chain ({{ info.signers | length }})</span>
|
||||
{% for subject, issuer in info.signers %}
|
||||
<div class="small"><span class="mono">{{ subject }}</span> <span class="text-muted">issued by</span> <span class="mono">{{ issuer }}</span></div>
|
||||
<div><span class="mono">{{ subject }}</span> <span class="muted">issued by</span> <span class="mono">{{ issuer }}</span></div>
|
||||
{% endfor %}
|
||||
</details>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if info.trusts %}<div class="small text-muted">Starts second stages signed by <span class="mono">{{ info.trusts }}</span></div>{% endif %}
|
||||
{% if info.sbat %}<div class="small text-muted">SBAT: <span class="mono">{{ info.sbat }}</span></div>{% endif %}
|
||||
{% if info.trusts %}<div class="sub">Starts second stages signed by <span class="mono">{{ info.trusts }}</span></div>{% endif %}
|
||||
{% if info.sbat %}<div class="sub">SBAT: <span class="mono">{{ info.sbat }}</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; }
|
||||
.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%; background: var(--run); }
|
||||
.igrid { display: grid; grid-template-columns: 190px minmax(0, 1fr) 190px 14px; align-items: center; gap: 0 16px;
|
||||
padding: 12px 24px; font-size: 14px; min-height: 24px; }
|
||||
.igrid.picked { padding-left: 56px; }
|
||||
.ihead { padding-top: 4px; padding-bottom: 6px; font-size: 12px; }
|
||||
.ibody { padding: 2px 24px 16px 24px; font-size: 13px; display: flex; flex-direction: column; gap: 8px; }
|
||||
.picked + .ibody, .ibody.picked { padding-left: 56px; }
|
||||
.kv { display: grid; grid-template-columns: 120px 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; }
|
||||
.i-row > .pick { position: absolute; left: 20px; top: 13px; z-index: 1; }
|
||||
.i-row > .pick input { width: 18px; height: 18px; margin: 0; }
|
||||
.name { display: flex; align-items: baseline; gap: 8px; min-width: 0; white-space: nowrap; }
|
||||
.igrid .name { overflow: hidden; }
|
||||
.rgrid { display: grid; grid-template-columns: 210px minmax(0, 1fr) 14px; align-items: center; gap: 0 16px; padding: 12px 24px; font-size: 14px; }
|
||||
.bgrid { display: grid; grid-template-columns: 160px minmax(0, 1fr) minmax(0, 1fr) 14px; align-items: center; gap: 0 16px;
|
||||
padding: 12px 80px 12px 24px; font-size: 14px; min-height: 24px; }
|
||||
.list-foot { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px; padding: 14px 24px; border-top: 1px solid var(--line); }
|
||||
.list-foot .sub { flex: 1 1 260px; }
|
||||
.rel-h { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; padding: 12px 24px; border-top: 1px solid var(--line-2); background: var(--surface-2); font-size: 13px; }
|
||||
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); }
|
||||
.howto .mono { overflow-wrap: anywhere; }
|
||||
.howto { margin: 0; padding-left: 20px; display: flex; flex-direction: column; gap: 14px; font-size: 13px; line-height: 1.6; }
|
||||
@container (max-width: 900px) {
|
||||
.igrid { grid-template-columns: 205px minmax(0, 1fr) 170px 14px; }
|
||||
.bgrid { grid-template-columns: 150px minmax(0, 1fr) 14px; }
|
||||
.bgrid > :nth-child(3) { display: none; }
|
||||
}
|
||||
@container (max-width: 560px) {
|
||||
.bgrid > :nth-child(3) { display: block; }
|
||||
.igrid { grid-template-columns: minmax(0, 1fr) auto 14px; padding: 12px 16px; gap: 4px 10px; }
|
||||
.igrid.picked { padding-left: 50px; }
|
||||
.igrid .c-ver { grid-column: 1 / 2; grid-row: 2; font-size: 12px; color: var(--muted); }
|
||||
.ihead { display: none; }
|
||||
.ibody, .picked + .ibody { padding: 2px 16px 14px; }
|
||||
.i-row > .pick { left: 16px; }
|
||||
.kv { grid-template-columns: minmax(0, 1fr); }
|
||||
.kv dd { margin-bottom: 6px; }
|
||||
.rgrid { grid-template-columns: minmax(0, 1fr) 14px; padding: 12px 16px; gap: 4px 10px; }
|
||||
.rgrid .c-who { grid-column: 1 / 2; grid-row: 2; }
|
||||
.bgrid { grid-template-columns: minmax(0, 1fr) 14px; padding: 12px 70px 12px 16px; gap: 2px 10px; }
|
||||
.bgrid > :nth-child(2), .bgrid > :nth-child(3) { grid-column: 1 / 2; }
|
||||
.bgrid > .chev { grid-column: 2; grid-row: 1; }
|
||||
.ui-row > .act { right: 12px; }
|
||||
.sec-h, .list-foot, .rel-h { padding-left: 16px; padding-right: 16px; }
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1><i class="bi bi-shield-lock"></i> iPXE Boot Files</h1>
|
||||
<div class="header-actions">
|
||||
{% if status == 'update' %}<span class="badge badge-warning badge-lg">Update available: {{ latest.tag }}</span>
|
||||
{% elif status == 'current' %}<span class="badge badge-success badge-lg">Up to date ({{ latest.tag }})</span>
|
||||
{% elif status == 'newer' %}<span class="badge badge-info badge-lg">Ahead of {{ latest.tag }}</span>
|
||||
{% elif latest %}<span class="badge badge-secondary badge-lg">Latest: {{ latest.tag }}</span>{% endif %}
|
||||
<form method="post" action="{{ url_for('ipxe.check') }}" style="display:inline;">
|
||||
<header class="ui-head">
|
||||
<div>
|
||||
<div class="eyebrow-t">Updates</div>
|
||||
<h1>iPXE boot files</h1>
|
||||
<div class="state-line">
|
||||
{% if status == 'update' %}{{ ui.chip('Update available: ' ~ latest.tag, 'act') }}
|
||||
{% elif status == 'current' %}{{ ui.chip('Up to date (' ~ latest.tag ~ ')', 'ok') }}
|
||||
{% elif status == 'newer' %}{{ ui.chip('Ahead of ' ~ latest.tag, 'neutral') }}
|
||||
{% elif latest %}{{ ui.chip('Latest: ' ~ latest.tag, 'neutral') }}
|
||||
{% else %}{{ ui.chip('Upstream not checked', 'off') }}{% endif %}
|
||||
{% if latest %}<span class="sub">Checked {{ latest.checked | local_dt }}</span>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<details class="more">
|
||||
<summary class="fbtn" style="background: var(--surface); min-height: 36px">Speed cap
|
||||
<strong class="mono">{{ ('%g MB/s' | format(settings.max_mbps)) if settings.max_mbps else 'none' }}</strong><span class="chev" aria-hidden="true">▾</span></summary>
|
||||
<form class="menu" method="post" action="{{ url_for('ipxe.save_settings') }}" style="width: min(300px, calc(100vw - 32px)); padding: 14px; gap: 10px">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<label for="maxMbps" style="font-size: 13px; font-weight: 600">Download speed cap (MB/s)</label>
|
||||
<div style="display: flex; gap: 8px"><input id="maxMbps" name="max_mbps" type="number" min="0" max="1000" step="0.5"
|
||||
value="{{ '%g' | format(settings.max_mbps) }}" aria-describedby="cap-help" class="field mono" style="width: 90px">
|
||||
<button type="submit" class="ui-btn pri">Save</button></div>
|
||||
<span id="cap-help" class="sub" style="white-space: normal">Applies to every download on the Updates pages (Dell and iPXE); 0 means no cap. The site link is about 400 Mbps (50 MB/s).</span>
|
||||
</form>
|
||||
</details>
|
||||
{% set can_stage = latest and latest.sha256 and job.state not in ('queued', 'running') %}
|
||||
<form method="post" action="{{ url_for('ipxe.check') }}" style="margin: 0">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="pxe-btn pxe-btn-secondary"><i class="bi bi-arrow-repeat"></i> Check for updates</button>
|
||||
<button type="submit" class="ui-btn{{ ' pri' if status != 'update' }}">Check now</button>
|
||||
</form>
|
||||
<form method="post" action="{{ url_for('ipxe.stage') }}" style="margin: 0">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="ui-btn{{ ' pri' if status == 'update' }}" {% if not can_stage %}disabled{% endif %}>Download and verify</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<p class="text-muted">
|
||||
The Secure Boot chain that dnsmasq hands out over TFTP: the Microsoft-signed shim (<span class="mono">ipxe-shim.efi</span>)
|
||||
starts <span class="mono">ipxe.efi</span>, which is signed by the iPXE Secure Boot CA. Updates come from the upstream
|
||||
iPXE release tarball on GitHub; its sha256 is checked against GitHub's published digest and each file's signer chain is
|
||||
read with <span class="mono">sbverify</span> before it can be installed. <span class="mono">ipxe-bcm.efi</span>,
|
||||
<span class="mono">dnsmasq.conf</span> and the Blancco files are never changed here.
|
||||
</p>
|
||||
<div class="ui-stack">
|
||||
|
||||
{# ---- Upstream ------------------------------------------------------------ #}
|
||||
<div class="section-card">
|
||||
<div class="section-title"><i class="bi bi-cloud-arrow-down"></i> Upstream release</div>
|
||||
<div class="row g-3 align-items-end">
|
||||
<div class="col-lg-7">
|
||||
{% if latest %}
|
||||
<div>
|
||||
<strong class="mono">{{ latest.tag }}</strong>
|
||||
{% if latest.name and latest.name != latest.tag %}<span class="text-muted">{{ latest.name }}</span>{% endif %}
|
||||
{% if status == 'update' %}<span class="badge badge-warning">newer than installed</span>
|
||||
{% elif status == 'current' %}<span class="badge badge-success">installed</span>
|
||||
{% elif status == 'newer' %}<span class="badge badge-info">installed build is newer</span>
|
||||
{% else %}<span class="badge badge-secondary">installed version unknown</span>{% endif %}
|
||||
</div>
|
||||
<div class="small text-muted">
|
||||
Published {{ latest.published | local_dt }} | checked {{ latest.checked | local_dt }}
|
||||
{% if latest.size %}| {{ "%.1f"|format(latest.size / 1048576) }} MB{% endif %}
|
||||
| <a href="{{ latest.page }}" target="_blank" rel="noopener">release page</a>
|
||||
</div>
|
||||
<div class="small">sha256 <span class="mono" style="white-space: normal; word-break: break-all;">{{ latest.sha256 or 'not published' }}</span></div>
|
||||
{% else %}
|
||||
<p class="text-muted mb-0">Not checked yet. <strong>Check for updates</strong> asks the GitHub API for the latest release (a few seconds).</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-lg-5 d-flex flex-wrap gap-2 justify-content-lg-end align-items-end">
|
||||
<form method="post" action="{{ url_for('ipxe.save_settings') }}" class="d-flex gap-2 align-items-end">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="form-field mb-0">
|
||||
<label for="maxMbps" class="form-label">Speed cap (MB/s)</label>
|
||||
<input type="number" class="form-control form-control-sm" id="maxMbps" name="max_mbps"
|
||||
min="0" max="1000" step="0.5" value="{{ '%g'|format(settings.max_mbps) }}" style="width: 7rem;">
|
||||
</div>
|
||||
<button type="submit" class="pxe-btn pxe-btn-ghost pxe-btn-sm" title="Shared by every download on the Updates pages; 0 = no cap. The site link is about 400 Mbps (50 MB/s).">Save</button>
|
||||
</form>
|
||||
<form method="post" action="{{ url_for('ipxe.stage') }}">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="pxe-btn pxe-btn-primary"
|
||||
{% if not latest or not latest.sha256 or job.state in ('queued', 'running') %}disabled{% endif %}>
|
||||
<i class="bi bi-download"></i> Download and verify
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-text">Speed cap applies to every download on the Updates pages (Dell and iPXE); 0 means no cap.</div>
|
||||
|
||||
{% if job.state %}
|
||||
<div id="ipxeJob" class="mt-3" data-state="{{ job.state }}">
|
||||
{% if job.state in ('queued', 'running') %}
|
||||
<div class="d-flex align-items-center gap-2 mb-1">
|
||||
<span class="spinner-border spinner-border-sm" role="status"></span>
|
||||
<span id="ipxeJobText">{{ 'Starting' if job.state == 'queued' else (job.step | capitalize) }} {{ job.tag }}...</span>
|
||||
</div>
|
||||
<div class="progress" style="height: 0.5rem;">
|
||||
<div id="ipxeJobBar" class="progress-bar" style="width: {{ job.percent or 0 }}%;"></div>
|
||||
</div>
|
||||
{% elif job.state == 'error' %}
|
||||
<div class="alert alert-danger mb-0"><strong>Download of {{ job.tag }} failed:</strong> <span class="mono">{{ job.error }}</span></div>
|
||||
{% elif job.state == 'interrupted' %}
|
||||
<div class="alert alert-warning mb-0">The download of {{ job.tag }} stopped without finishing (server restart?). Start it again; it resumes the partial file.</div>
|
||||
{% elif job.state == 'done' and job.failed %}
|
||||
<div class="alert alert-warning mb-0">{{ job.tag }} downloaded, but {{ job.failed | join(', ') }} failed verification; see below.</div>
|
||||
{% endif %}
|
||||
{# ---- Job ----------------------------------------------------------------- #}
|
||||
{% if job.state %}
|
||||
<div id="ipxeJob" data-state="{{ job.state }}">
|
||||
{% if job.state in ('queued', 'running') %}
|
||||
<div class="panel strip" role="status">
|
||||
<span class="pulse" aria-hidden="true"></span>
|
||||
<span id="ipxeJobText">{{ 'Starting' if job.state == 'queued' else (job.step | capitalize) }} {{ job.tag }}...</span>
|
||||
<div class="bar" aria-hidden="true"><div id="ipxeJobBar" style="width: {{ job.percent or 0 }}%"></div></div>
|
||||
</div>
|
||||
{% elif job.state == 'error' %}
|
||||
<div class="errbox"><strong>Download of {{ job.tag }} failed:</strong> <span class="mono">{{ job.error }}</span></div>
|
||||
{% elif job.state == 'interrupted' %}
|
||||
<div class="notice">The download of {{ job.tag }} stopped without finishing (server restart?). Start it again; it resumes the partial file.</div>
|
||||
{% elif job.state == 'done' and job.failed %}
|
||||
<div class="notice">{{ job.tag }} downloaded, but {{ job.failed | join(', ') }} failed verification; see Downloaded and verified below.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# ---- Installed ----------------------------------------------------------- #}
|
||||
<div class="section-card">
|
||||
<div class="section-title"><i class="bi bi-hdd"></i> Installed on the TFTP root</div>
|
||||
<div class="table-container">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr><th>File</th><th>Version</th><th>Size</th><th>Modified</th><th>sha256</th><th>Signature</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in rows %}
|
||||
{% set i = r.info %}
|
||||
<tr>
|
||||
<td class="text-nowrap">
|
||||
<span class="mono">{{ r.name }}</span>
|
||||
{% if not r.managed %}<div class="small text-muted">read-only here</div>{% endif %}
|
||||
</td>
|
||||
{% if i %}
|
||||
<td class="mono">{{ i.version or '-' }}{% if i.release %}<div class="small text-muted">from {{ i.release }}</div>{% endif %}</td>
|
||||
<td class="text-nowrap">{{ "{:,}".format(i.size) }} B</td>
|
||||
<td class="mono text-nowrap">{{ i.modified | timestamp_fmt }}</td>
|
||||
<td class="mono" title="{{ i.sha256 }}">{{ i.sha256[:12] }}</td>
|
||||
<td class="wrap">{{ sig_badge(i, i.chain_ok if i.chain_ok is defined else none) }}{{ chain(i) }}</td>
|
||||
{% else %}
|
||||
<td colspan="5" class="text-muted">Not present</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ---- Staged -------------------------------------------------------------- #}
|
||||
{% if staged %}
|
||||
<div class="section-card">
|
||||
<div class="section-title"><i class="bi bi-box-seam"></i> Downloaded and verified</div>
|
||||
{% for s in staged %}
|
||||
<form method="post" action="{{ url_for('ipxe.install') }}" class="{{ 'mt-4' if not loop.first }}" data-tag="{{ s.tag }}"
|
||||
onsubmit="var n=[].slice.call(this.querySelectorAll('input[name=names]:checked')).map(function(c){return c.value;}); if(!n.length){alert('Select at least one file.');return false;} return confirm('Install ' + n.join(', ') + ' from ' + this.dataset.tag + ' into the TFTP root? The current files are backed up first and every client that boots from now on gets the new ones.');">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="tag" value="{{ s.tag }}">
|
||||
<div class="d-flex flex-wrap align-items-center gap-2 mb-2">
|
||||
<strong class="mono">{{ s.tag }}</strong>
|
||||
<span class="small text-muted">verified {{ s.staged | local_dt }} | tarball sha256 <span class="mono" title="{{ s.tarball_sha256 }}">{{ (s.tarball_sha256 or '')[:12] }}</span> matches GitHub</span>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr><th style="width:2rem;"></th><th>File</th><th>From tarball</th><th>Version</th><th>sha256</th><th>Check</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for name, f in s.files.items() %}
|
||||
<tr>
|
||||
<td><input type="checkbox" class="form-check-input" name="names" value="{{ name }}" id="inst-{{ loop.index }}-{{ s.tag }}"
|
||||
{% if not f.ok %}disabled{% elif name in default_install %}checked{% endif %}></td>
|
||||
<td class="text-nowrap"><label for="inst-{{ loop.index }}-{{ s.tag }}" class="mono">{{ name }}</label>
|
||||
{% if name not in default_install %}<div class="small text-muted">optional</div>{% endif %}</td>
|
||||
<td class="mono small">{{ f.source or '-' }}</td>
|
||||
<td class="mono">{{ f.version or '-' }}</td>
|
||||
<td class="mono" title="{{ f.sha256 }}">{{ (f.sha256 or '')[:12] }}
|
||||
{% if f.sha256 and f.sha256 == installed_sha[name] %}<div class="small text-muted">same as installed</div>{% endif %}</td>
|
||||
<td class="wrap">
|
||||
{% if f.ok %}<span class="badge badge-success">OK</span> {% if f.kind %}{{ sig_badge(f) }}{% endif %}
|
||||
{% else %}<span class="badge badge-danger">Failed</span> <div class="small">{{ f.error }}</div>{% endif %}
|
||||
{% if f.signers is defined %}{{ chain(f) }}{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap align-items-center gap-2 mt-2">
|
||||
<button type="submit" class="pxe-btn pxe-btn-primary"><i class="bi bi-box-arrow-in-down"></i> Install selected</button>
|
||||
<span class="form-text">ipxe-shim.efi is only installed together with ipxe.efi from the same release. ipxe.pxe is for legacy BIOS clients.</span>
|
||||
</div>
|
||||
</form>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ---- dnsmasq routing ----------------------------------------------------- #}
|
||||
<div class="section-card">
|
||||
<div class="section-title"><i class="bi bi-diagram-3"></i> Who gets which file (dnsmasq, read-only)</div>
|
||||
{% if routing.boots %}
|
||||
<div class="table-container">
|
||||
<table class="data-table">
|
||||
<thead><tr><th>Boot file</th><th>Requires tag</th><th>Unless tagged</th><th>Clients</th></tr></thead>
|
||||
<tbody>
|
||||
{% for b in routing.boots %}
|
||||
<tr>
|
||||
<td class="mono">{{ b.file }}
|
||||
{% if b.managed %}<span class="badge badge-info">updated here</span>{% elif b.read_only %}<span class="badge badge-secondary">never touched</span>
|
||||
{% elif b.other_copy %}<span class="badge badge-warning" title="This page installs into the TFTP root only; this copy in a subfolder is not updated">other copy, not updated here</span>{% endif %}</td>
|
||||
<td class="mono">{{ b.need | join(', ') or '-' }}</td>
|
||||
<td class="mono">{{ b.exclude | join(', ') or '-' }}</td>
|
||||
<td class="wrap">
|
||||
{% if b.macs %}<span class="mono">{{ b.macs | join(', ') }}</span>
|
||||
{% elif b.need %}<span class="text-muted">clients matching {{ b.need | join(' and ') }}</span>
|
||||
{% else %}<span class="text-muted">every client not excluded</span>{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<details>
|
||||
<summary class="disc"><span class="chev" aria-hidden="true">▾</span>What this page manages</summary>
|
||||
<p class="sub" style="margin: 4px 0 0; max-width: 900px; line-height: 1.6">
|
||||
The Secure Boot chain that dnsmasq hands out over TFTP: the Microsoft-signed shim (<span class="mono">ipxe-shim.efi</span>)
|
||||
starts <span class="mono">ipxe.efi</span>, which is signed by the iPXE Secure Boot CA. Updates come from the upstream
|
||||
iPXE release tarball on GitHub; its sha256 is checked against GitHub's published digest and each file's signer chain is
|
||||
read with <span class="mono">sbverify</span> before it can be installed. <span class="mono">ipxe-bcm.efi</span>,
|
||||
<span class="mono">dnsmasq.conf</span> and the Blancco files are never changed here.
|
||||
</p>
|
||||
</details>
|
||||
|
||||
{# ---- Installed ----------------------------------------------------------- #}
|
||||
<section class="panel ui-list" aria-labelledby="inst-h">
|
||||
<div class="sec-h"><div><h2 id="inst-h" class="sec">Installed on the TFTP root</h2>
|
||||
<div class="sub">One line per boot file. Open a row for its signer chain, SBAT and hash.</div></div></div>
|
||||
<div class="igrid ihead ui-rowhead" aria-hidden="true"><span>File</span><span>Version</span><span>Signature</span><span></span></div>
|
||||
{% for r in rows %}
|
||||
{% set i = r.info %}
|
||||
{% set chain_ok = i.chain_ok if i and i.chain_ok is defined else none %}
|
||||
<div class="ui-row i-row">
|
||||
<details>
|
||||
<summary class="igrid">
|
||||
<span class="name"><span class="mono">{{ r.name }}</span>{% if not r.managed %}<span class="sub">read-only here</span>{% endif %}</span>
|
||||
<span class="tip c-ver" data-tip="{{ (i.version or '-') if i else 'Not present' }}"><span class="trunc mono">{% if i %}{{ i.version or '-' }}{% else %}<span class="muted">Not present</span>{% endif %}</span></span>
|
||||
<span>{% if not i %}{{ ui.chip('Missing', 'off') }}{% elif chain_ok == false %}{{ ui.chip('Shim will refuse', 'err') }}{% else %}{{ sig_chip(i) }}{% endif %}</span>
|
||||
<span class="chev" aria-hidden="true">▾</span>
|
||||
</summary>
|
||||
<div class="ibody">
|
||||
{% if i %}
|
||||
{{ sig_text(i, chain_ok) }}
|
||||
<dl class="kv">
|
||||
{% if i.release %}<dt>From release</dt><dd class="mono">{{ i.release }}</dd>{% endif %}
|
||||
<dt>Size</dt><dd>{{ "{:,}".format(i.size) }} B</dd>
|
||||
<dt>Modified</dt><dd class="mono">{{ i.modified | timestamp_fmt }}</dd>
|
||||
<dt>sha256</dt><dd class="mono">{{ i.sha256 }}</dd>
|
||||
</dl>
|
||||
{{ chain(i) }}
|
||||
{% else %}<div class="sub">Not present on the TFTP root.</div>{% endif %}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<details class="mt-2">
|
||||
<summary class="eyebrow" style="cursor:pointer;">Relevant dnsmasq lines ({{ routing.lines | length }})</summary>
|
||||
<pre class="mono mb-0 mt-2" style="white-space: pre-wrap; background: var(--bg); padding: 0.75rem; border-radius: 0.25rem;">{% for l in routing.lines %}{{ l }}
|
||||
{% endfor %}
|
||||
</section>
|
||||
|
||||
{# ---- Upstream ------------------------------------------------------------ #}
|
||||
<section class="panel ui-list" aria-labelledby="up-h">
|
||||
<div class="sec-h"><div><h2 id="up-h" class="sec">Upstream release</h2>
|
||||
<div class="sub">The latest GitHub release of iPXE, as last checked.</div></div></div>
|
||||
{% if latest %}
|
||||
<div class="ui-row">
|
||||
<details>
|
||||
<summary class="igrid">
|
||||
<span class="mono" style="font-weight: 600">{{ latest.tag }}</span>
|
||||
<span class="tip c-ver" data-tip="{{ latest.name }}"><span class="trunc sub">{% if latest.name and latest.name != latest.tag %}{{ latest.name }}, {% endif %}published {{ latest.published | local_dt }}</span></span>
|
||||
<span>{% if status == 'update' %}{{ ui.chip('Newer than installed', 'act') }}
|
||||
{% elif status == 'current' %}{{ ui.chip('Installed', 'ok') }}
|
||||
{% elif status == 'newer' %}{{ ui.chip('Installed is newer', 'neutral') }}
|
||||
{% else %}{{ ui.chip('Installed version unknown', 'off') }}{% endif %}</span>
|
||||
<span class="chev" aria-hidden="true">▾</span>
|
||||
</summary>
|
||||
<div class="ibody">
|
||||
<dl class="kv">
|
||||
<dt>Published</dt><dd>{{ latest.published | local_dt }}</dd>
|
||||
<dt>Checked</dt><dd>{{ latest.checked | local_dt }}</dd>
|
||||
{% if latest.size %}<dt>Tarball</dt><dd>{{ "%.1f"|format(latest.size / 1048576) }} MB</dd>{% endif %}
|
||||
<dt>sha256</dt><dd class="mono">{{ latest.sha256 or 'not published' }}</dd>
|
||||
<dt>Release page</dt><dd><a href="{{ latest.page }}" target="_blank" rel="noopener">{{ latest.page }}</a></dd>
|
||||
</dl>
|
||||
{% if not latest.sha256 %}<div class="notice">GitHub lists no sha256 for this release, so it cannot be downloaded here; install it by hand (see the instructions below).</div>{% endif %}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="hidden-bar">Not checked yet. Check now asks the GitHub API for the latest release (a few seconds).</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{# ---- Staged -------------------------------------------------------------- #}
|
||||
{% if staged %}
|
||||
<section class="panel ui-list" aria-labelledby="stg-h">
|
||||
<div class="sec-h"><div><h2 id="stg-h" class="sec">Downloaded and verified</h2>
|
||||
<div class="sub">Tick the files to install. ipxe-shim.efi is only installed together with ipxe.efi from the same release; ipxe.pxe is for legacy BIOS clients.</div></div></div>
|
||||
{% for s in staged %}
|
||||
<form method="post" action="{{ url_for('ipxe.install') }}" data-tag="{{ s.tag }}" style="margin: 0"
|
||||
onsubmit="var n=[].slice.call(this.querySelectorAll('input[name=names]:checked')).map(function(c){return c.value;}); if(!n.length){alert('Select at least one file.');return false;} return confirm('Install ' + n.join(', ') + ' from ' + this.dataset.tag + ' into the TFTP root? The current files are backed up first and every client that boots from now on gets the new ones.');">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="tag" value="{{ s.tag }}">
|
||||
<div class="rel-h">
|
||||
<strong class="mono">{{ s.tag }}</strong>
|
||||
<span class="sub">verified {{ s.staged | local_dt }}</span>
|
||||
<span class="tip inl" data-tip="{{ s.tarball_sha256 }}"><span class="sub">tarball sha256 <span class="mono">{{ (s.tarball_sha256 or '')[:12] }}</span> matches GitHub</span></span>
|
||||
</div>
|
||||
{% for name, f in s.files.items() %}
|
||||
{% set fid = 'inst-' ~ loop.index ~ '-' ~ s.tag %}
|
||||
<div class="ui-row i-row">
|
||||
<div class="pick"><input type="checkbox" class="form-check-input" name="names" value="{{ name }}" id="{{ fid }}"
|
||||
{% if not f.ok %}disabled{% elif name in default_install %}checked{% endif %}>
|
||||
<label for="{{ fid }}" class="vh">Install {{ name }} from {{ s.tag }}</label></div>
|
||||
<details {% if not f.ok %}open{% endif %}>
|
||||
<summary class="igrid picked">
|
||||
<span class="name"><span class="mono">{{ name }}</span>{% if name not in default_install %}<span class="sub">optional</span>{% endif %}</span>
|
||||
<span class="tip c-ver" data-tip="{{ f.version or '-' }}"><span class="trunc mono">{{ f.version or '-' }}{% if f.sha256 and f.sha256 == installed_sha[name] %} <span class="sub">(same as installed)</span>{% endif %}</span></span>
|
||||
<span>{% if not f.ok %}{{ ui.chip('Failed', 'err') }}{% elif f.kind %}{{ sig_chip(f) }}{% else %}{{ ui.chip('OK', 'ok') }}{% endif %}</span>
|
||||
<span class="chev" aria-hidden="true">▾</span>
|
||||
</summary>
|
||||
<div class="ibody picked">
|
||||
{% if not f.ok %}<div class="errbox">{{ f.error }}</div>{% elif f.kind %}{{ sig_text(f) }}{% endif %}
|
||||
<dl class="kv">
|
||||
<dt>From tarball</dt><dd class="mono">{{ f.source or '-' }}</dd>
|
||||
{% if f.sha256 %}<dt>sha256</dt><dd class="mono">{{ f.sha256 }}</dd>{% endif %}
|
||||
</dl>
|
||||
{% if f.signers is defined %}{{ chain(f) }}{% endif %}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div class="list-foot">
|
||||
<button type="submit" class="ui-btn pri">Install selected</button>
|
||||
<span class="sub">The current files are backed up first.</span>
|
||||
</div>
|
||||
</form>
|
||||
{% endfor %}
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{# ---- dnsmasq routing ----------------------------------------------------- #}
|
||||
<section class="panel ui-list" aria-labelledby="rt-h">
|
||||
<div class="sec-h"><div><h2 id="rt-h" class="sec">Who gets which file</h2>
|
||||
<div class="sub">Read from the dnsmasq configuration; never changed here.</div></div></div>
|
||||
{% if routing.boots %}
|
||||
{% for b in routing.boots %}
|
||||
<div class="ui-row">
|
||||
<details>
|
||||
<summary class="rgrid">
|
||||
<span class="name"><span class="tip" data-tip="{{ b.file }}"><span class="trunc mono">{{ b.file }}</span></span>
|
||||
{% if b.managed %}{{ ui.chip('updated here', 'w11') }}{% elif b.read_only %}{{ ui.chip('never touched', 'neutral') }}{% elif b.other_copy %}{{ ui.chip('other copy, not updated here', 'warn') }}{% endif %}</span>
|
||||
<span class="sub c-who">
|
||||
{% if b.macs %}{{ b.macs | length }} tagged client{{ 's' if b.macs | length != 1 }} ({{ b.need | join(', ') }})
|
||||
{%- elif b.need %}clients matching {{ b.need | join(' and ') }}
|
||||
{% else %}every client not excluded{% endif -%}{% if b.exclude %}; not {{ b.exclude | join(', ') }}{% endif %}</span>
|
||||
<span class="chev" aria-hidden="true">▾</span>
|
||||
</summary>
|
||||
<div class="ibody">
|
||||
{% if b.other_copy %}<div class="notice">This page installs into the TFTP root only; this copy in a subfolder is not updated.</div>{% endif %}
|
||||
<dl class="kv">
|
||||
<dt>Requires tag</dt><dd class="mono">{{ b.need | join(', ') or '-' }}</dd>
|
||||
<dt>Unless tagged</dt><dd class="mono">{{ b.exclude | join(', ') or '-' }}</dd>
|
||||
{% if b.macs %}<dt>Clients</dt><dd class="mono">{{ b.macs | join(', ') }}</dd>{% endif %}
|
||||
<dt>Line</dt><dd class="mono">{{ b.line }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<details style="border-top: 1px solid var(--line)">
|
||||
<summary class="disc" style="padding: 0 24px; min-height: 44px"><span class="chev" aria-hidden="true">▾</span>Relevant dnsmasq lines ({{ routing.lines | length }})</summary>
|
||||
<pre class="cmd mono" style="margin: 0 24px 16px">{% for l in routing.lines %}{{ l }}
|
||||
{% endfor %}</pre>
|
||||
</details>
|
||||
{% else %}
|
||||
<p class="text-muted mb-0">No dhcp-boot lines found in the dnsmasq configuration.</p>
|
||||
<div class="hidden-bar">No dhcp-boot lines found in the dnsmasq configuration.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{# ---- Backups ------------------------------------------------------------- #}
|
||||
<div class="section-card">
|
||||
<div class="section-title"><i class="bi bi-clock-history"></i> Backups</div>
|
||||
{% if backups %}
|
||||
<div class="table-container">
|
||||
<table class="data-table">
|
||||
<thead><tr><th>Backup</th><th>Reason</th><th>Files</th><th class="text-end"></th></tr></thead>
|
||||
<tbody>
|
||||
{% for b in backups %}
|
||||
<tr>
|
||||
<td class="mono text-nowrap">{{ b.id }}</td>
|
||||
<td>{{ b.reason or '-' }}</td>
|
||||
<td>
|
||||
{% for f in b.files %}<div class="small"><span class="mono">{{ f }}</span> <span class="text-muted">{{ b.versions[f] or '' }}</span></div>{% endfor %}
|
||||
{% if not b.files %}<span class="text-muted">empty</span>{% endif %}
|
||||
</td>
|
||||
<td class="text-end text-nowrap">
|
||||
{% if b.files %}
|
||||
{# The id is a folder name (hand-made backups too): keep it out of the JS source. #}
|
||||
<form method="post" action="{{ url_for('ipxe.rollback') }}" style="display:inline;"
|
||||
data-confirm="Put back {{ b.files | join(', ') }} from backup {{ b.id }}? The current files are backed up first."
|
||||
onsubmit="return confirm(this.dataset.confirm);">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="backup" value="{{ b.id }}">
|
||||
<button type="submit" class="pxe-btn pxe-btn-ghost btn-row-action"><i class="bi bi-arrow-counterclockwise"></i> Rollback</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<section class="panel ui-list" aria-labelledby="bk-h">
|
||||
<div class="sec-h"><div><h2 id="bk-h" class="sec">Backups</h2>
|
||||
<div class="sub">One is made automatically before every install and rollback. Restore is in each row's menu.</div></div></div>
|
||||
{% for b in backups %}
|
||||
<div class="ui-row">
|
||||
<details>
|
||||
<summary class="bgrid">
|
||||
<span class="mono">{{ b.id }}</span>
|
||||
<span class="tip" data-tip="{{ b.reason or '-' }}"><span class="trunc">{{ b.reason or '-' }}</span></span>
|
||||
<span class="tip" data-tip="{{ b.files | join(', ') or 'empty' }}"><span class="trunc sub mono">{{ b.files | join(', ') or 'empty' }}</span></span>
|
||||
<span class="chev" aria-hidden="true">▾</span>
|
||||
</summary>
|
||||
<div class="ibody">
|
||||
{% if b.reason %}<div>{{ b.reason }}</div>{% endif %}
|
||||
{% for f in b.files %}<div><span class="mono">{{ f }}</span> <span class="muted">{{ b.versions[f] or '' }}</span></div>{% else %}<div class="muted">Empty backup.</div>{% endfor %}
|
||||
</div>
|
||||
</details>
|
||||
{% if b.files %}
|
||||
<div class="act">
|
||||
{% call ui.more('backup ' ~ b.id) %}
|
||||
{# The id is a folder name (hand-made backups too): keep it out of the JS source. #}
|
||||
<form method="post" action="{{ url_for('ipxe.rollback') }}"
|
||||
data-confirm="Put back {{ b.files | join(', ') }} from backup {{ b.id }}? The current files are backed up first."
|
||||
onsubmit="return confirm(this.dataset.confirm);">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="backup" value="{{ b.id }}">
|
||||
<button type="submit">Restore these files</button>
|
||||
</form>
|
||||
{% endcall %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted mb-0">No backups yet. One is made automatically before every install and rollback.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="hidden-bar">No backups yet. One is made automatically before every install and rollback.</div>
|
||||
{% endfor %}
|
||||
</section>
|
||||
|
||||
{# ---- How-to -------------------------------------------------------------- #}
|
||||
<div class="section-card">
|
||||
<div class="section-title"><i class="bi bi-journal-text"></i> How to find and install the latest signed build by hand</div>
|
||||
<p>Use this when the buttons above cannot reach GitHub, or to check what they do. All commands run on the PXE server as root.</p>
|
||||
<ol>
|
||||
<li class="mb-3"><strong>Find the release.</strong> The latest release is at
|
||||
<section class="panel" aria-labelledby="how-h">
|
||||
<details>
|
||||
<summary class="sec-h" style="align-items: center">
|
||||
<div><h2 id="how-h" class="sec" style="font-size: 15px">Find and install the latest signed build by hand</h2>
|
||||
<div class="sub">For when the buttons above cannot reach GitHub, or to check what they do.</div></div>
|
||||
<span class="chev" aria-hidden="true">▾</span>
|
||||
</summary>
|
||||
<div style="padding: 0 24px 20px">
|
||||
<p class="sub" style="margin: 0 0 12px">All commands run on the PXE server as root.</p>
|
||||
<ol class="howto">
|
||||
<li><strong>Find the release.</strong> The latest release is at
|
||||
<a href="{{ releases_page }}" target="_blank" rel="noopener" class="mono">{{ releases_page }}</a>. The signed boot files are the
|
||||
asset <span class="mono">ipxeboot.tar.gz</span>; GitHub shows its sha256 next to the asset (or ask the API:
|
||||
<span class="mono">curl -s https://api.github.com/repos/ipxe/ipxe/releases/latest | grep -B2 -A2 '"digest"'</span>).</li>
|
||||
<li class="mb-3"><strong>Download and check the sha256.</strong> It must equal the digest on the release page.
|
||||
<pre class="mono mt-2 mb-1" style="white-space: pre-wrap; background: var(--bg); padding: 0.75rem; border-radius: 0.25rem;">cd /tmp
|
||||
<li><strong>Download and check the sha256.</strong> It must equal the digest on the release page.
|
||||
<pre class="cmd mono">cd /tmp
|
||||
curl -fLO {{ tarball_url }}
|
||||
sha256sum ipxeboot.tar.gz</pre>
|
||||
<span class="small text-muted">GitHub goes through Zscaler. A certificate error from curl means the Zscaler root CA is missing from
|
||||
<span class="sub">GitHub goes through Zscaler. A certificate error from curl means the Zscaler root CA is missing from
|
||||
<span class="mono">/usr/local/share/ca-certificates/</span> (the playbook installs it).</span></li>
|
||||
<li class="mb-3"><strong>Unpack.</strong> The Secure Boot files are in <span class="mono">ipxeboot/x86_64-sb/</span>:
|
||||
<li><strong>Unpack.</strong> The Secure Boot files are in <span class="mono">ipxeboot/x86_64-sb/</span>:
|
||||
<span class="mono">shimx64.efi</span> (the shim), <span class="mono">ipxe.efi</span> and <span class="mono">snponly.efi</span>.
|
||||
<span class="mono">ipxe-shim.efi</span> and <span class="mono">snponly-shim.efi</span> there are only symlinks to
|
||||
<span class="mono">shimx64.efi</span>. The legacy BIOS build is <span class="mono">ipxeboot/x86_64/ipxe.pxe</span>.
|
||||
<pre class="mono mt-2 mb-0" style="white-space: pre-wrap; background: var(--bg); padding: 0.75rem; border-radius: 0.25rem;">tar xzf ipxeboot.tar.gz
|
||||
<pre class="cmd mono">tar xzf ipxeboot.tar.gz
|
||||
ls -l ipxeboot/x86_64-sb/</pre></li>
|
||||
<li class="mb-3"><strong>Check the signatures.</strong>
|
||||
<pre class="mono mt-2 mb-1" style="white-space: pre-wrap; background: var(--bg); padding: 0.75rem; border-radius: 0.25rem;">sbverify --list ipxeboot/x86_64-sb/shimx64.efi
|
||||
<li><strong>Check the signatures.</strong>
|
||||
<pre class="cmd mono">sbverify --list ipxeboot/x86_64-sb/shimx64.efi
|
||||
sbverify --list ipxeboot/x86_64-sb/ipxe.efi</pre>
|
||||
The shim must be issued by <span class="mono">CN=Microsoft Corporation UEFI CA 2011</span> or, on a newer build,
|
||||
<span class="mono">CN=Microsoft UEFI CA 2023</span> (prefer a 2023-signed shim when upstream ships one).
|
||||
<span class="mono">ipxe.efi</span> must show <span class="mono">CN=iPXE Secure Boot Intermediate ...</span> issued by
|
||||
<span class="mono">CN=iPXE Secure Boot CA</span>. The shim's <span class="mono">warning: data remaining ... gaps between PE/COFF sections?</span>
|
||||
is normal. Do not rely on <span class="mono">sbverify --cert</span>: it reports OK even for the wrong certificate.</li>
|
||||
<li class="mb-3"><strong>Back up the current files.</strong> A backup made in this folder also shows in the Backups list above, with Rollback.
|
||||
<pre class="mono mt-2 mb-0" style="white-space: pre-wrap; background: var(--bg); padding: 0.75rem; border-radius: 0.25rem;">B=/var/lib/pxe-webapp/updates/ipxe/backups/$(date +%Y%m%d-%H%M%S)
|
||||
<li><strong>Back up the current files.</strong> A backup made in this folder also shows in the Backups list above, with Restore.
|
||||
<pre class="cmd mono">B=/var/lib/pxe-webapp/updates/ipxe/backups/$(date +%Y%m%d-%H%M%S)
|
||||
mkdir -p "$B" && cp -p /srv/tftp/ipxe-shim.efi /srv/tftp/ipxe.efi "$B"/</pre></li>
|
||||
<li class="mb-3"><strong>Install both files, from the same release.</strong> The shim only starts an <span class="mono">ipxe.efi</span> signed under the
|
||||
<li><strong>Install both files, from the same release.</strong> The shim only starts an <span class="mono">ipxe.efi</span> signed under the
|
||||
CA it carries, and it always loads <span class="mono">ipxe.efi</span> from the TFTP root, so the name must stay
|
||||
<span class="mono">ipxe.efi</span>. Copy to a temporary name and rename, so a client that is booting never reads half a file:
|
||||
<pre class="mono mt-2 mb-1" style="white-space: pre-wrap; background: var(--bg); padding: 0.75rem; border-radius: 0.25rem;">install -m 0644 ipxeboot/x86_64-sb/ipxe.efi /srv/tftp/.ipxe.efi.new && mv -f /srv/tftp/.ipxe.efi.new /srv/tftp/ipxe.efi
|
||||
<pre class="cmd mono">install -m 0644 ipxeboot/x86_64-sb/ipxe.efi /srv/tftp/.ipxe.efi.new && mv -f /srv/tftp/.ipxe.efi.new /srv/tftp/ipxe.efi
|
||||
install -m 0644 ipxeboot/x86_64-sb/shimx64.efi /srv/tftp/.ipxe-shim.efi.new && mv -f /srv/tftp/.ipxe-shim.efi.new /srv/tftp/ipxe-shim.efi</pre>
|
||||
<span class="small text-muted">No dnsmasq restart is needed. Never replace <span class="mono">ipxe-bcm.efi</span>; it serves every other UEFI client.</span></li>
|
||||
<li class="mb-3"><strong>Who gets it.</strong> dnsmasq decides, in <span class="mono">/etc/dnsmasq.conf</span>: clients listed as
|
||||
<span class="sub">No dnsmasq restart is needed. Never replace <span class="mono">ipxe-bcm.efi</span>; it serves every other UEFI client.</span></li>
|
||||
<li><strong>Who gets it.</strong> dnsmasq decides, in <span class="mono">/etc/dnsmasq.conf</span>: clients listed as
|
||||
<span class="mono">dhcp-host=<mac>,set:ipxe2</span> get the shim through
|
||||
<span class="mono">dhcp-boot=tag:ipxe2,tag:!ipxe,tag:!bios,ipxe-shim.efi</span>; every other UEFI client gets
|
||||
<span class="mono">ipxe-bcm.efi</span> through <span class="mono">dhcp-boot=tag:!bios,tag:!ipxe2,ipxe-bcm.efi</span>. The table above shows the
|
||||
live lines. Test on one tagged machine before tagging more.</li>
|
||||
<li class="mb-3"><strong>Roll back.</strong> Use Rollback above, or copy the backed-up files back the same way (temporary name, then
|
||||
<span class="mono">ipxe-bcm.efi</span> through <span class="mono">dhcp-boot=tag:!bios,tag:!ipxe2,ipxe-bcm.efi</span>. Who gets which file
|
||||
above shows the live lines. Test on one tagged machine before tagging more.</li>
|
||||
<li><strong>Roll back.</strong> Use Restore in the Backups list, or copy the backed-up files back the same way (temporary name, then
|
||||
<span class="mono">mv -f</span>).</li>
|
||||
<li><strong>Normal log noise.</strong> While booting, the shim asks TFTP for optional files such as
|
||||
<span class="mono">revocations_*.efi</span> and <span class="mono">shim_certificate_0.efi</span>. The "not found" lines for them in the
|
||||
dnsmasq log are expected and harmless.</li>
|
||||
</ol>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{% if job.state in ('queued', 'running') %}
|
||||
|
||||
Reference in New Issue
Block a user