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:
@@ -7,7 +7,7 @@
|
|||||||
{# state every cycle). #}
|
{# state every cycle). #}
|
||||||
<script>
|
<script>
|
||||||
function scheduleImagingReload() {
|
function scheduleImagingReload() {
|
||||||
window._imagingReloadTimer = setTimeout(function() { location.reload(); }, 5000);
|
window._imagingReloadTimer = setTimeout(function() { location.reload(); }, 15000);
|
||||||
}
|
}
|
||||||
function cancelImagingReload() {
|
function cancelImagingReload() {
|
||||||
if (window._imagingReloadTimer) { clearTimeout(window._imagingReloadTimer); window._imagingReloadTimer = null; }
|
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 class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="mb-0">Imaging Progress</h2>
|
<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>
|
</div>
|
||||||
<span class="badge bg-secondary fs-6">{{ sessions|length }} session{{ 's' if sessions|length != 1 }}</span>
|
<span class="badge bg-secondary fs-6">{{ sessions|length }} session{{ 's' if sessions|length != 1 }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -55,19 +55,48 @@ window.addEventListener('DOMContentLoaded', scheduleImagingReload);
|
|||||||
{% set is_done = s.status == 'succeeded' %}
|
{% set is_done = s.status == 'succeeded' %}
|
||||||
{% set border = 'danger' if is_failed else ('success' if is_done else 'primary') %}
|
{% set border = 'danger' if is_failed else ('success' if is_done else 'primary') %}
|
||||||
{% set friendly = stage_labels.get(stage_idx, ('Stage ' ~ stage_idx, '')) %}
|
{% set friendly = stage_labels.get(stage_idx, ('Stage ' ~ stage_idx, '')) %}
|
||||||
<div class="card border-{{ border }} mb-3">
|
<div class="card border-{{ border }} mb-4 shadow-sm">
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
<div class="card-body">
|
||||||
<div class="d-flex align-items-center gap-3">
|
<div class="d-flex flex-wrap gap-3 align-items-start">
|
||||||
{% if s.intune_device_id %}
|
{% 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>
|
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 %}
|
{% endif %}
|
||||||
|
|
||||||
|
<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>
|
<div>
|
||||||
<strong>{{ s.serial or '(no serial)' }}</strong>
|
<strong class="fs-5">{{ friendly[0] }}</strong>
|
||||||
{% if s.hostname_target %}<code class="ms-2 small text-muted">{{ s.hostname_target }}</code>{% endif %}
|
<span class="badge bg-secondary ms-1">{{ stage_idx }}/{{ stage_total or '?' }}</span>
|
||||||
{% if s.pctype %}<span class="badge bg-info text-dark ms-2">{{ s.pctype }}</span>{% endif %}
|
</div>
|
||||||
{% if s.machinenumber %}<span class="badge bg-secondary ms-1">#{{ s.machinenumber }}</span>{% endif %}
|
<span class="text-muted small">{{ pct }}%</span>
|
||||||
{% if s.intune_device_id %}<div class="small text-muted mt-1">Intune: <code>{{ s.intune_device_id }}</code>
|
</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"
|
<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;"
|
style="font-size:0.7rem; line-height:1; transition: all 0.2s;"
|
||||||
data-copy-text="{{ s.intune_device_id }}">copy</button>
|
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;"
|
style="font-size:0.7rem; line-height:1;"
|
||||||
target="_blank" rel="noopener"
|
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>
|
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="text-muted" style="font-size:0.75rem;">
|
||||||
<div class="progress-bar bg-{{ border }} {% if not is_done and not is_failed %}progress-bar-striped progress-bar-animated{% endif %}"
|
started <code>{{ s.started_at or '-' }}</code>
|
||||||
role="progressbar"
|
· last <code>{{ s.last_updated or '-' }}</code>
|
||||||
style="width: {{ pct }}%;"
|
{% if s.mac %}· MAC <code>{{ s.mac }}</code>{% endif %}
|
||||||
aria-valuenow="{{ pct }}"
|
{% if s.current_stage %}· <span style="font-family:monospace;">{{ s.current_stage }}</span>{% endif %}
|
||||||
aria-valuemin="0"
|
</div>
|
||||||
aria-valuemax="100">{{ pct }}%</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if s.error %}
|
{% 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 }}
|
<strong>Error:</strong> {{ s.error }}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if s.intune_device_id %}
|
{% if s.intune_device_id %}
|
||||||
<details class="mt-2 laps-card">
|
<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)</summary>
|
<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">
|
<div class="d-flex align-items-center gap-2 mt-2">
|
||||||
<input type="text"
|
<input type="text"
|
||||||
class="form-control form-control-sm laps-input"
|
class="form-control form-control-sm laps-input"
|
||||||
style="font-family: monospace; max-width: 22rem;"
|
style="font-family: monospace; max-width: 22rem;"
|
||||||
placeholder="paste LAPS password from Intune portal here"
|
placeholder="paste LAPS password from Intune portal here"
|
||||||
autocomplete="off">
|
autocomplete="off"
|
||||||
<button type="button" class="btn btn-sm btn-primary laps-make-btn">Make QR</button>
|
value="{{ s.laps_password or '' }}">
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary laps-clear-btn" style="display:none;">Clear</button>
|
<button type="button" class="btn btn-sm btn-primary laps-make-btn">{% if s.laps_password %}Update QR{% else %}Make QR{% endif %}</button>
|
||||||
<span class="laps-timer text-muted small"></span>
|
<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>
|
||||||
<div class="laps-qr-container mt-2"></div>
|
<div class="laps-qr-container mt-2"></div>
|
||||||
</details>
|
</details>
|
||||||
@@ -241,14 +246,28 @@ document.addEventListener('click', function(e) {
|
|||||||
.catch(function(err) { console.error('copy failed:', err); flashCopied(btn, false); });
|
.catch(function(err) { console.error('copy failed:', err); flashCopied(btn, false); });
|
||||||
});
|
});
|
||||||
|
|
||||||
// LAPS password -> client-side QR. Password never leaves browser tab.
|
// LAPS password QR. Persisted server-side per bay so it survives the
|
||||||
// Auto-clears input + QR after 60s so it doesn't linger on shared screens.
|
// 5s dashboard refresh. Stays put until the operator hits Clear (or
|
||||||
function renderLapsQR(card) {
|
// 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 input = card.querySelector('.laps-input');
|
||||||
var container = card.querySelector('.laps-qr-container');
|
var container = card.querySelector('.laps-qr-container');
|
||||||
var makeBtn = card.querySelector('.laps-make-btn');
|
var makeBtn = card.querySelector('.laps-make-btn');
|
||||||
var clearBtn = card.querySelector('.laps-clear-btn');
|
var clearBtn = card.querySelector('.laps-clear-btn');
|
||||||
var timerEl = card.querySelector('.laps-timer');
|
var serial = card.getAttribute('data-serial');
|
||||||
var text = input.value;
|
var text = input.value;
|
||||||
if (!text) { input.focus(); return; }
|
if (!text) { input.focus(); return; }
|
||||||
try {
|
try {
|
||||||
@@ -259,36 +278,27 @@ function renderLapsQR(card) {
|
|||||||
var size = 280;
|
var size = 280;
|
||||||
var cellSize = Math.max(1, Math.floor(size / (modules + 8)));
|
var cellSize = Math.max(1, Math.floor(size / (modules + 8)));
|
||||||
container.innerHTML = qr.createImgTag(cellSize, 4);
|
container.innerHTML = qr.createImgTag(cellSize, 4);
|
||||||
makeBtn.style.display = 'none';
|
makeBtn.textContent = 'Update QR';
|
||||||
clearBtn.style.display = '';
|
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) {
|
} catch (err) {
|
||||||
container.textContent = 'QR error: ' + 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) {
|
function clearLapsQR(card) {
|
||||||
var input = card.querySelector('.laps-input');
|
var input = card.querySelector('.laps-input');
|
||||||
var container = card.querySelector('.laps-qr-container');
|
var container = card.querySelector('.laps-qr-container');
|
||||||
var makeBtn = card.querySelector('.laps-make-btn');
|
var makeBtn = card.querySelector('.laps-make-btn');
|
||||||
var clearBtn = card.querySelector('.laps-clear-btn');
|
var clearBtn = card.querySelector('.laps-clear-btn');
|
||||||
var timerEl = card.querySelector('.laps-timer');
|
var serial = card.getAttribute('data-serial');
|
||||||
if (card._lapsTimer) { clearInterval(card._lapsTimer); card._lapsTimer = null; }
|
|
||||||
input.value = '';
|
input.value = '';
|
||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
timerEl.textContent = '';
|
makeBtn.textContent = 'Make QR';
|
||||||
makeBtn.style.display = '';
|
|
||||||
clearBtn.style.display = 'none';
|
clearBtn.style.display = 'none';
|
||||||
// Resume page refresh now that no QR is showing on any card.
|
if (serial) {
|
||||||
if (!document.querySelector('.laps-qr-container img')) {
|
lapsPersist(serial, '').catch(function(e) { console.error('LAPS clear failed:', e); });
|
||||||
scheduleImagingReload();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
document.addEventListener('click', function(e) {
|
document.addEventListener('click', function(e) {
|
||||||
@@ -307,5 +317,13 @@ document.addEventListener('keydown', function(e) {
|
|||||||
if (card) { e.preventDefault(); renderLapsQR(card); }
|
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>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user