Files
pxe-server/webapp/templates/imaging.html
cproudlock 69a1682a7f webapp: imaging UX overhaul + image management CRUD
Imaging dashboard
- services/imaging_log_tail.py: parses dnsmasq leases, Apache access log,
  Samba per-host log files, and dnsmasq syslog (DHCP/TFTP). Synthesizes
  inferred sessions keyed by MAC for bays that have only touched the boot
  chain but not yet pushed to /imaging/status. Active window 90 min.
- imaging_status.list_sessions() merges inferred sessions into the dashboard
  list. Real client-pushed sessions win for the same MAC.
- imaging_status: stage_history field tracks every stage transition (capped
  30); sidecar .log file per serial records every log_lines push uncapped
  (read_full_log() caps detail-page response to 1 MB).
- delete_session/delete_all_sessions clean up sidecar .log too.
- New SSE endpoint /imaging/stream emits a session-list hash every 5s.
  Client fetches /imaging/tiles (HTML partial) on hash change and swaps
  #imaging-tiles innerHTML. Polling fallback at 15s if SSE drops.
- Tile-swap preserves scroll, filter input, expanded state via localStorage,
  and any LAPS input the operator is mid-pasting (swap skipped when a
  laps-input is focused).
- imaging.html: removed 15s location.reload(). Added live-status dot in
  header (gray idle / green SSE connected / red SSE lost).
- _imaging_tiles.html: shared partial used by both /imaging full render and
  /imaging/tiles SSE refresh. Inferred bays render with yellow border +
  log-inferred badge + no progress bar (stage_index inference is coarse).
- imaging_detail.html (new): per-bay forensics page at /imaging/session/
  <serial>. Session metadata grid, stage timeline table, full sidecar log
  with truncation indicator, Copy-support-summary button. Linked from each
  client-pushed tile.
- qr-render.js exposes window.renderAllQRs() so the SSE swap can re-render
  Intune device-ID QRs in the swapped-in tiles.

Image management
- services/image_registry.py: JSON registry of image types at
  {SAMBA_SHARE}/image-registry.json. Bootstraps from baked-in
  config.IMAGE_TYPES on first run. create/clone/delete/rename_friendly
  mutate the file then call reload() which rewrites config.IMAGE_TYPES +
  config.FRIENDLY_NAMES in place. Sidebar reflects on next request.
- app.py routes: /images/new, /images/<t>/clone, /images/<t>/delete (with
  optional content-wipe checkbox), /images/<t>/rename.
- dashboard.html: + New image type button + Clone/Delete per row, all in
  Bootstrap modals with confirmation copy.
- Clone copies Deploy/ tree but preserves symlinks to shared dirs (Out-of-
  box Drivers, Operating Systems, Packages) so disk usage stays low.
- Delete with content checked unlinks symlinks (does not follow into shared
  dirs).

Driver / package upload + orphan adoption
- services/images.py: upload_driver, adopt_orphan, remove_orphans,
  upload_package. Filename sanitization blocks path traversal.
- app.py routes: /images/<t>/drivers/upload, /images/<t>/drivers/adopt,
  /images/<t>/drivers/orphans/delete, /images/<t>/packages/upload.
- image_config.html: Upload .zip button + modal on Drivers section. Orphan
  drivers card-footer rebuilt as interactive list with per-row Adopt inline
  form (family + destinationDir inputs) and bulk select+delete.
- Upload .zip on Packages section with optional destinationDir field that
  appends a packages.json entry.

Configuration
- config.py: new env vars DNSMASQ_LEASES, APACHE_ACCESS_LOG, SAMBA_LOG_DIR,
  DNSMASQ_SYSLOG for the log-tailer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 13:21:06 -04:00

400 lines
15 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 %}
<div class="d-flex justify-content-between align-items-center mb-2">
<div>
<h2 class="mb-0">Imaging Progress</h2>
<small class="text-muted">Live via SSE (15s polling fallback). Client pushes -> <code>/imaging/status</code>; log-inferred bays in yellow.</small>
</div>
<div class="d-flex align-items-center gap-2">
<span id="imaging-live-dot" class="status-dot" title="live stream" style="background-color:#6c757d;"></span>
<span class="badge bg-secondary fs-6"><span id="visible-count">{{ sessions|length }}</span>/<span id="total-count">{{ sessions|length }}</span></span>
{% if sessions %}
<form method="post" action="{{ url_for('imaging_delete_all') }}"
onsubmit="return confirm('Clear all {{ sessions|length }} imaging session(s)? This wipes every tile from the dashboard. Live re-images will repopulate on next status push.');"
style="display:inline;">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-danger">Clear all</button>
</form>
{% endif %}
</div>
</div>
<div class="mb-3">
<input id="imaging-search" type="search" class="form-control form-control-sm"
placeholder="Filter by serial, hostname, pctype, machine#, Intune id, MAC, IP, stage name, stage-N, status, source (client|inferred)"
autocomplete="off">
</div>
{% if not sessions %}
<div id="imaging-empty" class="card">
<div class="card-body text-center text-muted py-5">
<p class="mb-1">No imaging sessions yet.</p>
<p class="small mb-0">A PC being imaged will post status here, or appear
automatically once it touches DHCP / TFTP / boot.wim.</p>
</div>
</div>
{% endif %}
<div id="imaging-tiles">
{% include "_imaging_tiles.html" %}
</div>
<div class="card mt-3">
<div class="card-body small text-muted">
<strong>How to push status from an imaging client:</strong>
<pre class="mb-0 mt-2">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>
</div>
</div>
{% 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') }}";
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('#ffc107', 'EventSource unsupported - polling only');
startPolling();
return;
}
try {
sse = new EventSource(STREAM_URL);
} catch (e) {
setDot('#dc3545', 'SSE failed - polling');
startPolling();
return;
}
sse.onopen = function() {
setDot('#198754', '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('#dc3545', '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();
});
})();
function copyText(text) {
// Modern path - only works over HTTPS or localhost
if (navigator.clipboard && window.isSecureContext) {
return navigator.clipboard.writeText(text);
}
// Legacy fallback for plain HTTP
return new Promise(function(resolve, reject) {
var ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.left = '-9999px';
ta.style.top = '0';
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);
}
});
}
function flashCopied(btn, success) {
var origText = btn.dataset.origText || btn.textContent;
btn.dataset.origText = origText;
if (success) {
btn.textContent = 'copied!';
btn.classList.remove('btn-outline-secondary');
btn.classList.add('btn-success');
btn.style.transform = 'scale(1.15)';
setTimeout(function() {
btn.textContent = origText;
btn.classList.remove('btn-success');
btn.classList.add('btn-outline-secondary');
btn.style.transform = 'scale(1)';
}, 1200);
} else {
btn.textContent = 'failed';
btn.classList.remove('btn-outline-secondary');
btn.classList.add('btn-danger');
setTimeout(function() {
btn.textContent = origText;
btn.classList.remove('btn-danger');
btn.classList.add('btn-outline-secondary');
}, 1500);
}
}
document.addEventListener('click', function(e) {
var btn = e.target.closest('.copy-btn');
if (!btn) return;
var text = btn.getAttribute('data-copy-text');
if (!text) return;
copyText(text).then(function() { flashCopied(btn, true); })
.catch(function(err) { console.error('copy failed:', err); flashCopied(btn, false); });
});
// 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);
}
});
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';
function loadExpandedSet() {
try { return new Set(JSON.parse(localStorage.getItem(EXPANDED_KEY) || '[]')); }
catch (e) { return new Set(); }
}
function saveExpandedSet(set) {
try { localStorage.setItem(EXPANDED_KEY, JSON.stringify(Array.from(set))); }
catch (e) {}
}
function restoreExpandedState() {
var expanded = loadExpandedSet();
document.querySelectorAll('.imaging-card').forEach(function(card) {
var serial = card.getAttribute('data-serial') || card.getAttribute('data-key');
if (serial && expanded.has(serial)) card.open = true;
if (!card._toggleBound) {
card.addEventListener('toggle', function() {
var s = loadExpandedSet();
if (card.open) s.add(serial); else s.delete(serial);
saveExpandedSet(s);
});
card._toggleBound = true;
}
});
}
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();
}
function applyFilter() {
var search = document.getElementById('imaging-search');
var counter = document.getElementById('visible-count');
var total = document.getElementById('total-count');
if (!search) return;
var q = search.value.trim().toLowerCase();
var visible = 0, totalN = 0;
document.querySelectorAll('.imaging-card').forEach(function(card) {
totalN++;
var hay = card.getAttribute('data-filter') || '';
var match = (q === '') || hay.indexOf(q) !== -1;
card.style.display = match ? '' : 'none';
if (match) visible++;
});
if (counter) counter.textContent = visible;
if (total) total.textContent = totalN;
}
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);
}
window.imagingPostSwapHooks();
});
})();
</script>
{% endblock %}