Adopt the shopdb-flask visual language across the PXE webapp (presentation only, Flask/Jinja logic unchanged): - New static/pxe-theme.css: GE Aerospace palette (atmosphere-blue sidebar, sky-blue primary, avionics-green), Inter font stack, light/dark theming via data-theme + localStorage (key pxe-theme) with system-pref fallback, and card/button/table/form/badge/alert component styles layered over Bootstrap. - base.html: shopdb-style sidebar (logo + title, nav sections, footer light/dark toggle) + theme boot script. - All 13 content templates restyled to the new page-header + card/table/badge vocabulary; unattend_editor grouped per unattend-UX research. - Fixed a pre-existing CRITICAL bug found during review: nested <form>s in image_config.html made Adopt submit the delete form and Delete-selected post every orphan filename regardless of checkboxes; split into standalone forms wired via the form= attribute. Built by a Fable-orchestrated Opus workflow (17 agents). All 14 templates parse clean under Jinja2.
403 lines
15 KiB
HTML
403 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="page-header">
|
|
<div>
|
|
<h1>Imaging Progress</h1>
|
|
<small class="text-light">Live via SSE (15s polling fallback). Client pushes → <code class="mono">/imaging/status</code>; log-inferred bays in yellow.</small>
|
|
</div>
|
|
<div class="header-actions">
|
|
<span class="status-indicator">
|
|
<span id="imaging-live-dot" class="status-dot" title="live stream" style="background-color:var(--secondary);"></span>
|
|
<span class="badge badge-secondary badge-lg"><span id="visible-count">{{ sessions|length }}</span>/<span id="total-count">{{ sessions|length }}</span></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="pxe-btn pxe-btn-danger pxe-btn-sm">
|
|
<i class="bi bi-trash"></i> Clear all
|
|
</button>
|
|
</form>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-field 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="pxe-card text-center text-light" style="padding: 3rem 1.25rem;">
|
|
<p class="mb-1"><i class="bi bi-hdd-network" style="font-size: 1.75rem; opacity: 0.5;"></i></p>
|
|
<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>
|
|
{% endif %}
|
|
|
|
<div id="imaging-tiles">
|
|
{% include "_imaging_tiles.html" %}
|
|
</div>
|
|
|
|
|
|
<div class="section-card mt-3">
|
|
<div class="section-title">How to push status from an imaging client</div>
|
|
<div class="pxe-card-body">
|
|
<pre class="mono mb-0" style="white-space: pre-wrap;">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('var(--warning)', 'EventSource unsupported - polling only');
|
|
startPolling();
|
|
return;
|
|
}
|
|
try {
|
|
sse = new EventSource(STREAM_URL);
|
|
} catch (e) {
|
|
setDot('var(--danger)', 'SSE failed - polling');
|
|
startPolling();
|
|
return;
|
|
}
|
|
sse.onopen = function() {
|
|
setDot('var(--success)', '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(--danger)', '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 %}
|