imaging: redesign tile + LAPS persist + 15s refresh

Tile redesign:
 - QR (or placeholder if not yet captured) on the left as a fixed 160px block
 - Right side: header (serial / hostname / pctype / machinenumber / status)
   then stage label as a big h4 with stage badge + % on the same row,
   then full-width progress bar, then friendly stage hint
 - Intune device id row with copy + set-category buttons consolidated
   under the progress section
 - Footer one-liner: started / last / MAC / raw current_stage (small grey)
 - LAPS QR + log tail still expandable below
 - shadow-sm for visual lift, no card-header line splitting

LAPS persist: POST password to /imaging/<serial>/laps so it survives
the dashboard refresh. Auto-renders QR on page load if the session
already has a stored password. Clear button POSTs empty string to
wipe server-side. No more 60s auto-clear - stays until cleared (or
daily server reset).

Refresh: 5s -> 15s. Reduces polling jitter + gives the eye time to
read before page flickers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-05-14 20:11:58 -04:00
parent 5f322d1110
commit ca647cb690

View File

@@ -7,7 +7,7 @@
{# state every cycle). #}
<script>
function scheduleImagingReload() {
window._imagingReloadTimer = setTimeout(function() { location.reload(); }, 5000);
window._imagingReloadTimer = setTimeout(function() { location.reload(); }, 15000);
}
function cancelImagingReload() {
if (window._imagingReloadTimer) { clearTimeout(window._imagingReloadTimer); window._imagingReloadTimer = null; }
@@ -21,7 +21,7 @@ window.addEventListener('DOMContentLoaded', scheduleImagingReload);
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h2 class="mb-0">Imaging Progress</h2>
<small class="text-muted">Auto-refresh 5s. POST updates from imaging clients arrive at <code>/imaging/status</code>.</small>
<small class="text-muted">Auto-refresh 15s. POST updates from imaging clients arrive at <code>/imaging/status</code>.</small>
</div>
<span class="badge bg-secondary fs-6">{{ sessions|length }} session{{ 's' if sessions|length != 1 }}</span>
</div>
@@ -55,19 +55,48 @@ window.addEventListener('DOMContentLoaded', scheduleImagingReload);
{% set is_done = s.status == 'succeeded' %}
{% set border = 'danger' if is_failed else ('success' if is_done else 'primary') %}
{% set friendly = stage_labels.get(stage_idx, ('Stage ' ~ stage_idx, '')) %}
<div class="card border-{{ border }} mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center gap-3">
<div class="card border-{{ border }} mb-4 shadow-sm">
<div class="card-body">
<div class="d-flex flex-wrap gap-3 align-items-start">
{% if s.intune_device_id %}
<div data-qr="{{ s.intune_device_id }}" data-qr-size="160" data-qr-ec="M" style="line-height:0;"
<div data-qr="{{ s.intune_device_id }}" data-qr-size="160" data-qr-ec="M"
style="line-height:0; flex-shrink:0;"
title="Intune Device ID: {{ s.intune_device_id }}"></div>
{% else %}
<div class="d-flex align-items-center justify-content-center bg-light text-muted"
style="width:160px; height:160px; border-radius:0.25rem; flex-shrink:0; font-size:0.85rem; text-align:center; padding:0.5rem;">
waiting for Intune Device ID
</div>
{% endif %}
<div>
<strong>{{ s.serial or '(no serial)' }}</strong>
{% if s.hostname_target %}<code class="ms-2 small text-muted">{{ s.hostname_target }}</code>{% endif %}
{% if s.pctype %}<span class="badge bg-info text-dark ms-2">{{ s.pctype }}</span>{% endif %}
{% if s.machinenumber %}<span class="badge bg-secondary ms-1">#{{ s.machinenumber }}</span>{% endif %}
{% if s.intune_device_id %}<div class="small text-muted mt-1">Intune: <code>{{ s.intune_device_id }}</code>
<div class="flex-grow-1" style="min-width:0;">
<div class="d-flex flex-wrap align-items-center gap-2 mb-2">
<h4 class="mb-0">{{ s.serial or '(no serial)' }}</h4>
{% if s.hostname_target %}<code class="text-muted">{{ s.hostname_target }}</code>{% endif %}
{% if s.pctype %}<span class="badge bg-info text-dark">{{ s.pctype }}</span>{% endif %}
{% if s.machinenumber %}<span class="badge bg-secondary">#{{ s.machinenumber }}</span>{% endif %}
<span class="badge bg-{{ border }} ms-auto">{{ s.status or 'in_progress' }}</span>
</div>
<div class="mb-2">
<div class="d-flex justify-content-between align-items-baseline mb-1">
<div>
<strong class="fs-5">{{ friendly[0] }}</strong>
<span class="badge bg-secondary ms-1">{{ stage_idx }}/{{ stage_total or '?' }}</span>
</div>
<span class="text-muted small">{{ pct }}%</span>
</div>
<div class="progress" style="height:1.2rem;">
<div class="progress-bar bg-{{ border }} {% if not is_done and not is_failed %}progress-bar-striped progress-bar-animated{% endif %}"
role="progressbar" style="width: {{ pct }}%;"
aria-valuenow="{{ pct }}" aria-valuemin="0" aria-valuemax="100"></div>
</div>
{% if friendly[1] %}<div class="small text-muted mt-1">{{ friendly[1] }}</div>{% endif %}
</div>
{% if s.intune_device_id %}
<div class="small mb-2">
<span class="text-muted">Intune:</span> <code>{{ s.intune_device_id }}</code>
<button type="button" class="btn btn-sm btn-outline-secondary py-0 px-1 ms-1 copy-btn"
style="font-size:0.7rem; line-height:1; transition: all 0.2s;"
data-copy-text="{{ s.intune_device_id }}">copy</button>
@@ -75,60 +104,36 @@ window.addEventListener('DOMContentLoaded', scheduleImagingReload);
style="font-size:0.7rem; line-height:1;"
target="_blank" rel="noopener"
href="https://portal.azure.us/?feature.msaljs=false#view/Microsoft_Intune_Devices/DeviceSettingsMenuBlade/~/properties/aadDeviceId/{{ s.intune_device_id }}">set category</a>
</div>{% endif %}
</div>
</div>
<span class="badge bg-{{ border }}">{{ s.status or 'in_progress' }}</span>
</div>
<div class="card-body">
<div class="row g-3 mb-3 small">
<div class="col-md-5">
<div class="text-muted">Current stage <span class="badge bg-secondary ms-1">{{ stage_idx }}/{{ stage_total or '?' }}</span></div>
<div><strong>{{ friendly[0] }}</strong></div>
{% if friendly[1] %}<div class="small text-muted">{{ friendly[1] }}</div>{% endif %}
{% if s.current_stage %}<div class="text-muted" style="font-size:0.7rem;font-family:monospace;">{{ s.current_stage }}</div>{% endif %}
</div>
<div class="col-md-2">
<div class="text-muted">Started</div>
<div><code>{{ s.started_at or '-' }}</code></div>
</div>
<div class="col-md-3">
<div class="text-muted">Last update</div>
<div><code>{{ s.last_updated or '-' }}</code></div>
</div>
<div class="col-md-2">
<div class="text-muted">MAC</div>
<div><code class="small">{{ s.mac or '-' }}</code></div>
</div>
</div>
</div>
{% endif %}
<div class="progress mb-2" style="height: 1.4rem;">
<div class="progress-bar bg-{{ border }} {% if not is_done and not is_failed %}progress-bar-striped progress-bar-animated{% endif %}"
role="progressbar"
style="width: {{ pct }}%;"
aria-valuenow="{{ pct }}"
aria-valuemin="0"
aria-valuemax="100">{{ pct }}%</div>
<div class="text-muted" style="font-size:0.75rem;">
started <code>{{ s.started_at or '-' }}</code>
&middot; last <code>{{ s.last_updated or '-' }}</code>
{% if s.mac %}&middot; MAC <code>{{ s.mac }}</code>{% endif %}
{% if s.current_stage %}&middot; <span style="font-family:monospace;">{{ s.current_stage }}</span>{% endif %}
</div>
</div>
</div>
{% if s.error %}
<div class="alert alert-danger small py-2 mb-2">
<div class="alert alert-danger small py-2 mb-2 mt-3">
<strong>Error:</strong> {{ s.error }}
</div>
{% endif %}
{% if s.intune_device_id %}
<details class="mt-2 laps-card">
<summary class="text-muted small">LAPS password QR (paste -> scan on bay)</summary>
<details class="mt-2 laps-card" data-serial="{{ s.serial }}" {% if s.laps_password %}open{% endif %}>
<summary class="text-muted small">LAPS password QR (paste -> scan on bay - persists until cleared)</summary>
<div class="d-flex align-items-center gap-2 mt-2">
<input type="text"
class="form-control form-control-sm laps-input"
style="font-family: monospace; max-width: 22rem;"
placeholder="paste LAPS password from Intune portal here"
autocomplete="off">
<button type="button" class="btn btn-sm btn-primary laps-make-btn">Make QR</button>
<button type="button" class="btn btn-sm btn-outline-secondary laps-clear-btn" style="display:none;">Clear</button>
<span class="laps-timer text-muted small"></span>
autocomplete="off"
value="{{ s.laps_password or '' }}">
<button type="button" class="btn btn-sm btn-primary laps-make-btn">{% if s.laps_password %}Update QR{% else %}Make QR{% endif %}</button>
<button type="button" class="btn btn-sm btn-outline-secondary laps-clear-btn" {% if not s.laps_password %}style="display:none;"{% endif %}>Clear</button>
</div>
<div class="laps-qr-container mt-2"></div>
</details>
@@ -241,14 +246,28 @@ document.addEventListener('click', function(e) {
.catch(function(err) { console.error('copy failed:', err); flashCopied(btn, false); });
});
// LAPS password -> client-side QR. Password never leaves browser tab.
// Auto-clears input + QR after 60s so it doesn't linger on shared screens.
function renderLapsQR(card) {
// 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 timerEl = card.querySelector('.laps-timer');
var serial = card.getAttribute('data-serial');
var text = input.value;
if (!text) { input.focus(); return; }
try {
@@ -259,36 +278,27 @@ function renderLapsQR(card) {
var size = 280;
var cellSize = Math.max(1, Math.floor(size / (modules + 8)));
container.innerHTML = qr.createImgTag(cellSize, 4);
makeBtn.style.display = 'none';
makeBtn.textContent = 'Update QR';
clearBtn.style.display = '';
cancelImagingReload(); // freeze page refresh while QR is visible
var remaining = 60;
timerEl.textContent = '(auto-clears in ' + remaining + 's)';
if (card._lapsTimer) clearInterval(card._lapsTimer);
card._lapsTimer = setInterval(function() {
remaining--;
if (remaining <= 0) { clearLapsQR(card); return; }
timerEl.textContent = '(auto-clears in ' + remaining + 's)';
}, 1000);
} 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 timerEl = card.querySelector('.laps-timer');
if (card._lapsTimer) { clearInterval(card._lapsTimer); card._lapsTimer = null; }
var serial = card.getAttribute('data-serial');
input.value = '';
container.innerHTML = '';
timerEl.textContent = '';
makeBtn.style.display = '';
makeBtn.textContent = 'Make QR';
clearBtn.style.display = 'none';
// Resume page refresh now that no QR is showing on any card.
if (!document.querySelector('.laps-qr-container img')) {
scheduleImagingReload();
if (serial) {
lapsPersist(serial, '').catch(function(e) { console.error('LAPS clear failed:', e); });
}
}
document.addEventListener('click', function(e) {
@@ -307,5 +317,13 @@ document.addEventListener('keydown', function(e) {
if (card) { e.preventDefault(); renderLapsQR(card); }
}
});
// On page load, any laps-card with a pre-populated input (from
// server-persisted laps_password) auto-renders its QR without re-POSTing.
window.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.laps-card').forEach(function(card) {
var input = card.querySelector('.laps-input');
if (input && input.value) renderLapsQR(card, { skipPersist: true });
});
});
</script>
{% endblock %}