Files
pxe-server/webapp/templates/imaging_detail.html
cproudlock 2bcc9d8016 Webapp redesign: fixes from the code review
Blocker
- /reports/export returned 500: REPORT_EXPORT_RANGES and _months_ago were
  deleted with the old reports route. Restored.

Major
- A session without stage_index/stage_total took down /, /imaging and
  /imaging/tiles (int filter on Undefined). The templates default both;
  saving a LAPS password for a session that no longer exists is refused
  (it created a bare session).
- /reports/todo parsed every report four times (23 s at 700 reports).
  blancco_report.parse() now caches by path, mtime and size: 0.09 s.
- Stored XSS through the session serial in the Clear confirm (the
  browser decodes ' before running an inline handler). The message
  moves to data-confirm, and update_session() only accepts serials of
  letters, digits, '.', '_' and '-' (400 from /imaging/status otherwise).
- Reports without a document id and with odd characters in the file name
  got a key the mark routes rejected, so they could never be cleared.
  report_key() now always produces a valid key.

Minor
- failure_note() handles a list-valued erasure target (500 on the report
  and to-do pages).
- _recently_marked() tolerates naive, 'Z' and non-dict state entries.
- Key regexes use fullmatch (a trailing newline was accepted).
- Unreadable reports are no longer counted as to tick / to destroy; the
  reports page already flags them as Unreadable.
- Rollback review recounts restorable files after reading the backups.
- Enrollment: the startnet fallback package (no or empty ppkg.conf) and a
  quoted SOURCE_PPKG are protected too.
- Backup upload overwrite check is case-insensitive (SMB share) and
  replaces the existing file rather than adding a case twin.
- Overview polling keeps the last good rows on an error and pauses while
  the tab is hidden. A shown or typed LAPS password survives the floor's
  refresh.
- hardware_dismiss only redirects to same-site paths; tiles ?limit is
  clamped; the Updates overview survives an unreadable Dell scan or iPXE
  state; describe() uses aversion(); a client cannot label its session
  'inferred'.
2026-10-14 14:42:21 -04:00

201 lines
11 KiB
HTML

{% 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 or 0) | int(0) %}
{% set stage_total = (session.stage_total or 0) | 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 %}
<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 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="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="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 %}
<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 %}
</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="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="id-pre" tabindex="0" aria-label="Full log">{{ full_log }}</pre>
{% else %}
<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 %}
</section>
{% if session.log_tail %}
<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>
</section>
{% endif %}
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
// 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 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 %}