Files
pxe-server/webapp/templates/imaging.html
cproudlock 2ed2cbe2e9 Webapp redesign: fixes from the functional regression test
- Unattend: a command's '...' menu stayed open after Move up/down and
  covered the next row's menu, so the next click could stage removing the
  wrong command. The menu now closes and focus returns to its button.
- '...' menus close on an outside click, on Escape and when another
  opens (static/app.js), for every page.
- Drivers: Remove asks to confirm again, as before the redesign. Long zip
  paths in a row body wrap (the page scrolled sideways at 390 px).
- Rollback review returns to the page it was opened from (Dell page or
  Updates overview), for both Cancel and the rollback itself.
- To-do: the ServiceNow period offers a year again (366 days).
- Imaging floor: a search that matches a log-only bay opens that group;
  its open state survives the live refresh. 'Clear all' states the
  number of session files it deletes, hidden finished ones included.
- Dashboard: the image key pattern is valid under the browser's v flag
  (client-side validation was silently skipped).
- Import: allowed_import_source() compares real paths, so
  '<upload dir>/../..' is refused. This path traversal predates the
  redesign; the import moves the source tree, so it mattered.
2026-10-14 14:48:10 -04:00

416 lines
18 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 {{ clear_count }} imaging session files{{ (', including ' ~ hidden ~ ' hidden finished') if hidden else '' }}? 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')];
});
// A log-only bay that matches a search is shown: open its group.
var logs = document.querySelector('details.floor-logs');
if (logs) {
var hit = Array.prototype.some.call(logs.querySelectorAll('.imaging-card'), function (c) { return !c.hidden; });
if (q !== '' && hit) logs.open = true;
if (!hit) logs.hidden = true; else logs.hidden = false;
}
var none = document.getElementById('floor-nomatch');
if (none) none.hidden = visible > 0 || (q === '' && stateFilter === '');
}
function restoreLogsGroup() {
var g = document.querySelector('details.floor-logs');
if (!g) return;
try { if (sessionStorage.getItem('floor-logs-open') === '1') g.open = true; } catch (e) {}
if (!g._bound) {
g.addEventListener('toggle', function () { try { sessionStorage.setItem('floor-logs-open', g.open ? '1' : '0'); } catch (e) {} });
g._bound = true;
}
}
window.imagingPostSwapHooks = function() {
restoreLogsGroup();
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 %}