Files
pxe-server/webapp/templates/imaging.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

398 lines
17 KiB
HTML

{% extends "base.html" %}
{% block title %}Imaging Progress - PXE Server Manager{% endblock %}
{% block extra_head %}
{# Tile refresh is driven by SSE (/imaging/stream) with a polling fallback. #}
{# Replacing the full-page reload preserves scroll, filter input, expanded #}
{# tile state, and LAPS QR input text across refreshes. #}
<script>
function scheduleImagingReload() {
// Polling fallback only; SSE is the primary path. Initialized in
// imaging-refresh.js block at the bottom of the page.
}
function cancelImagingReload() {
if (window._imagingPollTimer) { clearTimeout(window._imagingPollTimer); window._imagingPollTimer = null; }
}
</script>
{% endblock %}
{% block content %}
{% import '_ui.html' as ui %}
{% call ui.head('Imaging floor', 'Operate') %}
<span class="sub" style="display: inline-flex; align-items: center; gap: 6px"><span id="imaging-live-dot" class="dot" title="Live stream" style="background: var(--track-done)"></span>Live</span>
{% if sessions %}
{% call ui.more('the imaging floor') %}
<form method="post" action="{{ url_for('imaging_delete_all') }}"
onsubmit="return confirm('Clear all {{ sessions|length }} imaging sessions? Every row is removed; PCs still imaging reappear on their next status push.');">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="del">Clear all sessions</button>
</form>
{% endcall %}
{% endif %}
{% endcall %}
<section class="panel floor" aria-labelledby="floor-h">
<h2 id="floor-h" class="vh">PCs on the imaging network</h2>
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 10px; padding: 16px 24px 8px">
<div role="search" style="flex: 1 1 220px; min-width: 160px; display: flex">
<label for="imaging-search" class="vh">Filter by serial, hostname, PC type, machine number, Intune ID, MAC, IP or stage</label>
<input id="imaging-search" type="search" class="field" style="flex: 1; min-width: 0"
placeholder="Serial, hostname, PC type, machine #, MAC, IP or stage" autocomplete="off">
</div>
<div role="group" aria-label="Show only" id="floor-filters" style="display: flex; flex-wrap: wrap; gap: 6px">
<button type="button" class="fbtn" data-state="" aria-pressed="true">All <span class="n" data-count="all"></span></button>
<button type="button" class="fbtn" data-state="failed" aria-pressed="false">Failed <span class="n" data-count="failed"></span></button>
<button type="button" class="fbtn" data-state="action" aria-pressed="false">Needs action <span class="n" data-count="action"></span></button>
<button type="button" class="fbtn" data-state="running" aria-pressed="false">In progress <span class="n" data-count="running"></span></button>
<button type="button" class="fbtn" data-state="done" aria-pressed="false">Done <span class="n" data-count="done"></span></button>
</div>
</div>
{% if hidden %}
<div class="sub" style="padding: 0 24px 8px">{{ hidden }} finished session{{ 's' if hidden != 1 }} older than {{ window_hours }} h hidden.
<a href="{{ url_for('imaging_dashboard', all='1') }}">Show all</a></div>
{% elif show_all %}
<div class="sub" style="padding: 0 24px 8px">Showing finished sessions of every age. <a href="{{ url_for('imaging_dashboard') }}">Hide old finished sessions</a></div>
{% endif %}
<div class="fhead" aria-hidden="true"><span>Status</span><span>PC</span><span>Stage</span></div>
<div id="imaging-tiles">
{% include "_imaging_tiles.html" %}
</div>
<div class="hidden-bar" id="floor-nomatch" hidden><span>No PC matches the filter.</span></div>
</section>
<details class="panel" style="margin-top: 22px; border-radius: 10px">
<summary class="disc" style="padding: 0 16px; min-height: 44px"><span class="chev" aria-hidden="true">&#9662;</span>How an imaging client pushes status</summary>
<pre class="mono" style="margin: 0; padding: 0 16px 16px 42px; white-space: pre-wrap; font-size: 12px">POST http://172.16.9.1:9009/imaging/status
Content-Type: application/json
{
"serial": "4HBLF33",
"mac": "e4:54:e8:dc:b1:f0",
"hostname_target": "EDNMG3D4",
"pctype": "gea-shopfloor-keyence",
"machinenumber": "9999",
"current_stage": "Run-ShopfloorSetup: 09-Setup-Keyence",
"stage_index": 7,
"stage_total": 9,
"status": "in_progress",
"log_lines": ["last few log lines from the stage"]
}</pre>
</details>
{% endblock %}
{% block extra_scripts %}
<script>
// -------- Live refresh: SSE primary, polling fallback --------
// Rebuilds the #imaging-tiles inner HTML from /imaging/tiles when the
// server signals a state change. Preserves scroll, filter input value,
// and any LAPS input that the operator is actively editing.
(function() {
var TILES_URL = "{{ url_for('imaging_tiles_partial', all='1' if show_all else None) }}";
var STREAM_URL = "{{ url_for('imaging_stream') }}";
var POLL_MS = 15000;
var lastHash = null;
var sse = null;
var dot = function() { return document.getElementById('imaging-live-dot'); };
function setDot(color, title) {
var d = dot();
if (d) { d.style.backgroundColor = color; d.title = title || ''; }
}
function lapsInputIsDirty() {
// Skip the tile swap if any LAPS input is focused (operator is
// mid-paste) OR has unsaved text that differs from the server-side
// copy. The next refresh after they hit Make-QR will catch up.
var active = document.activeElement;
if (active && active.classList && active.classList.contains('laps-input')) return true;
// Also while a password is shown, or typed but not yet made into a QR.
var inputs = document.querySelectorAll('.laps-input');
for (var i = 0; i < inputs.length; i++) {
if (inputs[i].type === 'text' || inputs[i].value !== inputs[i].defaultValue) return true;
}
return false;
}
function refreshTiles(force) {
if (!force && lapsInputIsDirty()) return;
fetch(TILES_URL, { credentials: 'same-origin' })
.then(function(r) { return r.text(); })
.then(function(html) {
var container = document.getElementById('imaging-tiles');
if (!container) return;
container.innerHTML = html;
if (typeof window.imagingPostSwapHooks === 'function') {
window.imagingPostSwapHooks();
}
})
.catch(function(err) { console.error('refreshTiles failed:', err); });
}
function startPolling() {
if (window._imagingPollTimer) return;
window._imagingPollTimer = setInterval(function() {
refreshTiles(false);
}, POLL_MS);
}
function stopPolling() {
if (window._imagingPollTimer) {
clearInterval(window._imagingPollTimer);
window._imagingPollTimer = null;
}
}
function startSSE() {
if (!window.EventSource) {
setDot('var(--act)', 'EventSource unsupported - polling only');
startPolling();
return;
}
try {
sse = new EventSource(STREAM_URL);
} catch (e) {
setDot('var(--fail)', 'SSE failed - polling');
startPolling();
return;
}
sse.onopen = function() {
setDot('var(--ok)', 'live stream connected');
stopPolling();
};
sse.onmessage = function(ev) {
var data;
try { data = JSON.parse(ev.data); } catch (e) { return; }
if (!data || data.hash === lastHash) return;
lastHash = data.hash;
refreshTiles(false);
};
sse.onerror = function() {
setDot('var(--fail)', 'live stream lost - polling fallback');
try { sse.close(); } catch (e) {}
sse = null;
startPolling();
// Try to reconnect SSE after a backoff.
setTimeout(startSSE, 10000);
};
}
// Expose so external code (LAPS, filter) can trigger an immediate
// refresh after user action.
window.imagingRefreshNow = function() { refreshTiles(true); };
window.addEventListener('DOMContentLoaded', function() {
startSSE();
});
})();
// Copy buttons (.copy-btn) are handled globally in static/app.js.
// LAPS password QR. Persisted server-side per bay so it survives the
// 5s dashboard refresh. Stays put until the operator hits Clear (or
// daily server reset). Password is plain in the session JSON - air-gapped
// PXE LAN + daily reset acceptable risk per ops.
function lapsCsrfToken() {
var m = document.querySelector('meta[name=csrf-token]');
return m ? m.getAttribute('content') : '';
}
function lapsPersist(serial, password) {
return fetch('/imaging/' + encodeURIComponent(serial) + '/laps', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': lapsCsrfToken() },
body: JSON.stringify({ password: password })
});
}
function renderLapsQR(card, opts) {
opts = opts || {};
var input = card.querySelector('.laps-input');
var container = card.querySelector('.laps-qr-container');
var makeBtn = card.querySelector('.laps-make-btn');
var clearBtn = card.querySelector('.laps-clear-btn');
var serial = card.getAttribute('data-serial');
var text = input.value;
if (!text) { input.focus(); return; }
try {
var qr = qrcode(0, 'M');
qr.addData(text);
qr.make();
var modules = qr.getModuleCount();
var size = 280;
var cellSize = Math.max(1, Math.floor(size / (modules + 8)));
container.innerHTML = qr.createImgTag(cellSize, 4);
makeBtn.textContent = 'Update QR';
clearBtn.style.display = '';
} catch (err) {
container.textContent = 'QR error: ' + err;
}
if (!opts.skipPersist && serial) {
lapsPersist(serial, text).catch(function(e) { console.error('LAPS persist failed:', e); });
}
}
function clearLapsQR(card) {
var input = card.querySelector('.laps-input');
var container = card.querySelector('.laps-qr-container');
var makeBtn = card.querySelector('.laps-make-btn');
var clearBtn = card.querySelector('.laps-clear-btn');
var serial = card.getAttribute('data-serial');
input.value = '';
container.innerHTML = '';
makeBtn.textContent = 'Make QR';
clearBtn.style.display = 'none';
if (serial) {
lapsPersist(serial, '').catch(function(e) { console.error('LAPS clear failed:', e); });
}
}
document.addEventListener('click', function(e) {
var card;
if (e.target.classList.contains('laps-make-btn')) {
card = e.target.closest('.laps-card');
if (card) renderLapsQR(card);
} else if (e.target.classList.contains('laps-clear-btn')) {
card = e.target.closest('.laps-card');
if (card) clearLapsQR(card);
} else if (e.target.classList.contains('laps-show-btn')) {
card = e.target.closest('.laps-card');
var inp = card && card.querySelector('.laps-input');
if (inp) {
var show = inp.type === 'password';
inp.type = show ? 'text' : 'password';
e.target.textContent = show ? 'Hide' : 'Show';
e.target.setAttribute('aria-pressed', show ? 'true' : 'false');
}
}
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Enter' && e.target.classList.contains('laps-input')) {
var card = e.target.closest('.laps-card');
if (card) { e.preventDefault(); renderLapsQR(card); }
}
});
// Per-tile hooks that must re-run after every tile-swap. Called on
// DOMContentLoaded for first paint, then by the SSE/polling refresh after
// it replaces the innerHTML of #imaging-tiles.
(function() {
var EXPANDED_KEY = 'imaging-expanded';
var LOG_EXPANDED_KEY = 'imaging-log-expanded';
function loadExpandedSet(key) {
try { return new Set(JSON.parse(localStorage.getItem(key || EXPANDED_KEY) || '[]')); }
catch (e) { return new Set(); }
}
function saveExpandedSet(set, key) {
try { localStorage.setItem(key || EXPANDED_KEY, JSON.stringify(Array.from(set))); }
catch (e) {}
}
// Log-tail scroll per tile, in memory only. A tile followed at the
// bottom keeps following new lines; one scrolled up stays put.
var logScroll = {};
function restoreExpandedState() {
var expanded = loadExpandedSet();
document.querySelectorAll('.imaging-card').forEach(function(card) {
var serial = card.getAttribute('data-serial') || card.getAttribute('data-key');
var row = card.querySelector('details.imaging-toggle');
if (!row) return;
if (serial && expanded.has(serial)) row.open = true;
if (!row._toggleBound) {
row.addEventListener('toggle', function() {
var s = loadExpandedSet();
if (row.open) s.add(serial); else s.delete(serial);
saveExpandedSet(s);
});
row._toggleBound = true;
}
});
// The nested "Log tail" <details> is re-rendered closed on every
// refresh; keep it open (and its scroll) the same way as the tile.
var logExpanded = loadExpandedSet(LOG_EXPANDED_KEY);
document.querySelectorAll('.imaging-log').forEach(function(log) {
var key = log.getAttribute('data-key');
if (!key) return;
if (logExpanded.has(key)) log.open = true;
var pre = log.querySelector('.imaging-log-pre');
if (pre) {
var saved = logScroll[key];
if (!saved || saved.atBottom) pre.scrollTop = pre.scrollHeight;
else pre.scrollTop = saved.top;
pre.addEventListener('scroll', function() {
logScroll[key] = {
top: pre.scrollTop,
atBottom: pre.scrollTop + pre.clientHeight >= pre.scrollHeight - 4
};
});
}
log.addEventListener('toggle', function() {
var s = loadExpandedSet(LOG_EXPANDED_KEY);
if (log.open) s.add(key); else s.delete(key);
saveExpandedSet(s, LOG_EXPANDED_KEY);
if (log.open && pre) pre.scrollTop = pre.scrollHeight;
});
});
}
function autoRenderLapsQRs() {
document.querySelectorAll('.laps-card').forEach(function(card) {
var input = card.querySelector('.laps-input');
var container = card.querySelector('.laps-qr-container');
if (input && input.value && container && !container.innerHTML.trim()) {
renderLapsQR(card, { skipPersist: true });
}
});
}
function renderIntuneQRs() {
// qr-render.js looks for [data-qr] and renders an image. It runs on
// initial DOMContentLoaded but not after a tile-swap. Re-run if the
// hook is exposed; otherwise no-op.
if (typeof window.renderAllQRs === 'function') window.renderAllQRs();
}
var stateFilter = '';
function applyFilter() {
var search = document.getElementById('imaging-search');
var q = search ? search.value.trim().toLowerCase() : '';
var counts = {all: 0, failed: 0, action: 0, running: 0, done: 0};
var visible = 0;
document.querySelectorAll('.imaging-card').forEach(function(card) {
var st = card.getAttribute('data-state') || '';
if (st !== 'logs') { counts.all++; if (counts[st] !== undefined) counts[st]++; }
var hay = card.getAttribute('data-filter') || '';
var match = (q === '' || hay.indexOf(q) !== -1) && (stateFilter === '' || st === stateFilter);
card.hidden = !match;
if (match) visible++;
});
document.querySelectorAll('#floor-filters [data-count]').forEach(function(n) {
n.textContent = counts[n.getAttribute('data-count')];
});
var none = document.getElementById('floor-nomatch');
if (none) none.hidden = visible > 0 || (q === '' && stateFilter === '');
}
window.imagingPostSwapHooks = function() {
restoreExpandedState();
autoRenderLapsQRs();
renderIntuneQRs();
applyFilter();
};
window.addEventListener('DOMContentLoaded', function() {
// Search input is rendered outside #imaging-tiles, so its listeners
// only bind once.
var search = document.getElementById('imaging-search');
if (search) {
search.addEventListener('input', applyFilter);
}
document.querySelectorAll('#floor-filters .fbtn').forEach(function(b) {
b.addEventListener('click', function() {
stateFilter = b.getAttribute('data-state') || '';
document.querySelectorAll('#floor-filters .fbtn').forEach(function(x) {
x.setAttribute('aria-pressed', x === b ? 'true' : 'false');
});
applyFilter();
});
});
window.imagingPostSwapHooks();
});
})();
</script>
{% endblock %}