Webapp redesign phase 6: the remaining pages

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

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

View File

@@ -1,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 %}