Files
pxe-server/webapp/templates/imaging.html
cproudlock 0b86255711 Webapp redesign phase 2: Overview and imaging floor
Imaging floor (/imaging, /imaging/tiles):
- One line per PC: status chip, serial with machine number, hostname
  and IP, stage text with a segmented stage track, one action plus a
  '...' menu. Everything else (description, error, timestamps, Intune
  ID + QR, Set category / ARTS links, LAPS QR, log tail) is in the row's
  expandable body.
- Order: failed, needs action (stage 7), in progress, done; log-only
  bays fold into one collapsed group. imaging_status.floor_state() and
  floor_order() compute this.
- Status filter buttons with live counts next to the text filter. SSE
  refresh, expanded-row memory and LAPS handling are unchanged.
- Stage text is never truncated; BIOS updates and erasing are bold.

Overview (/):
- Services popover, technician to-do line, the six most urgent floor
  rows (?limit=6 partial, refreshed every 15 s), and image cards with
  lint warnings, map errors and missing zips as separate chips. New,
  clone, delete and unattend stay on the cards' menus.

Copy buttons are now handled once in static/app.js, with a fallback for
plain-http pages where navigator.clipboard is unavailable.
2026-10-14 13:44:07 -04:00

393 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;
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 %}