Webapp redesign phase 2: Overview and imaging floor
Imaging floor (/imaging, /imaging/tiles): - One line per PC: status chip, serial with machine number, hostname and IP, stage text with a segmented stage track, one action plus a '...' menu. Everything else (description, error, timestamps, Intune ID + QR, Set category / ARTS links, LAPS QR, log tail) is in the row's expandable body. - Order: failed, needs action (stage 7), in progress, done; log-only bays fold into one collapsed group. imaging_status.floor_state() and floor_order() compute this. - Status filter buttons with live counts next to the text filter. SSE refresh, expanded-row memory and LAPS handling are unchanged. - Stage text is never truncated; BIOS updates and erasing are bold. Overview (/): - Services popover, technician to-do line, the six most urgent floor rows (?limit=6 partial, refreshed every 15 s), and image cards with lint warnings, map errors and missing zips as separate chips. New, clone, delete and unattend stay on the cards' menus. Copy buttons are now handled once in static/app.js, with a fallback for plain-http pages where navigator.clipboard is unavailable.
This commit is contained in:
@@ -17,60 +17,52 @@ function cancelImagingReload() {
|
||||
{% 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>
|
||||
{% if hidden %}
|
||||
<div class="small mt-1">{{ 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="small mt-1">Showing finished sessions of every age. <a href="{{ url_for('imaging_dashboard') }}">Hide old finished sessions</a></div>
|
||||
{% endif %}
|
||||
</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 %}
|
||||
{% 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 {{ sessions|length }} imaging session(s)? This wipes every tile from the dashboard. Live re-images will repopulate on next status push.');"
|
||||
style="display:inline;">
|
||||
onsubmit="return confirm('Clear all {{ sessions|length }} imaging sessions? 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="pxe-btn pxe-btn-danger pxe-btn-sm">
|
||||
<i class="bi bi-trash"></i> Clear all
|
||||
</button>
|
||||
<button type="submit" class="del">Clear all sessions</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% 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>
|
||||
</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>
|
||||
|
||||
<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
|
||||
<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">▾</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
|
||||
|
||||
{
|
||||
@@ -85,9 +77,7 @@ Content-Type: application/json
|
||||
"status": "in_progress",
|
||||
"log_lines": ["last few log lines from the stage"]
|
||||
}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</details>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
@@ -149,19 +139,19 @@ Content-Type: application/json
|
||||
|
||||
function startSSE() {
|
||||
if (!window.EventSource) {
|
||||
setDot('var(--warning)', 'EventSource unsupported - polling only');
|
||||
setDot('var(--act)', 'EventSource unsupported - polling only');
|
||||
startPolling();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
sse = new EventSource(STREAM_URL);
|
||||
} catch (e) {
|
||||
setDot('var(--danger)', 'SSE failed - polling');
|
||||
setDot('var(--fail)', 'SSE failed - polling');
|
||||
startPolling();
|
||||
return;
|
||||
}
|
||||
sse.onopen = function() {
|
||||
setDot('var(--success)', 'live stream connected');
|
||||
setDot('var(--ok)', 'live stream connected');
|
||||
stopPolling();
|
||||
};
|
||||
sse.onmessage = function(ev) {
|
||||
@@ -172,7 +162,7 @@ Content-Type: application/json
|
||||
refreshTiles(false);
|
||||
};
|
||||
sse.onerror = function() {
|
||||
setDot('var(--danger)', 'live stream lost - polling fallback');
|
||||
setDot('var(--fail)', 'live stream lost - polling fallback');
|
||||
try { sse.close(); } catch (e) {}
|
||||
sse = null;
|
||||
startPolling();
|
||||
@@ -190,66 +180,7 @@ Content-Type: application/json
|
||||
});
|
||||
})();
|
||||
|
||||
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); });
|
||||
});
|
||||
// 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
|
||||
@@ -353,14 +284,16 @@ document.addEventListener('keydown', function(e) {
|
||||
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 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 (card.open) s.add(serial); else s.delete(serial);
|
||||
if (row.open) s.add(serial); else s.delete(serial);
|
||||
saveExpandedSet(s);
|
||||
});
|
||||
card._toggleBound = true;
|
||||
row._toggleBound = true;
|
||||
}
|
||||
});
|
||||
// The nested "Log tail" <details> is re-rendered closed on every
|
||||
@@ -408,22 +341,25 @@ document.addEventListener('keydown', function(e) {
|
||||
if (typeof window.renderAllQRs === 'function') window.renderAllQRs();
|
||||
}
|
||||
|
||||
var stateFilter = '';
|
||||
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;
|
||||
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) {
|
||||
totalN++;
|
||||
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;
|
||||
card.style.display = match ? '' : 'none';
|
||||
var match = (q === '' || hay.indexOf(q) !== -1) && (stateFilter === '' || st === stateFilter);
|
||||
card.hidden = !match;
|
||||
if (match) visible++;
|
||||
});
|
||||
if (counter) counter.textContent = visible;
|
||||
if (total) total.textContent = totalN;
|
||||
document.querySelectorAll('#floor-filters [data-count]').forEach(function(n) {
|
||||
n.textContent = counts[n.getAttribute('data-count')];
|
||||
});
|
||||
var none = document.getElementById('floor-nomatch');
|
||||
if (none) none.hidden = visible > 0 || (q === '' && stateFilter === '');
|
||||
}
|
||||
|
||||
window.imagingPostSwapHooks = function() {
|
||||
@@ -440,6 +376,15 @@ document.addEventListener('keydown', function(e) {
|
||||
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();
|
||||
});
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user