Everything West Jefferson specific moved out to a separate repo (pxe-site-wj): NTLARS/UDC/heat-treat backups keyed by WJ machine numbers, CMM and Wax/Trace bay lists, WJ OpenText profiles, site-config.json, the WJ preinstall list, and the WJ shopfloor PC types (Collections, NoCollections, Common, Keyence, CMM, Genspect, HeatTreat, Wax/Trace, Display, PartMarker) with GE-Enforce, machine numbers and the desktop layout. The part-marker bench tools moved to their own repo. Dead scripts removed: build-proxmox-iso, deploy-bios, sync_hardware_models, mirror-from-gold, pull-bios, startnet-template.cmd. Shopfloor is one generic type, gea-shopfloor-standard, with today's timing: - PPKG at first logon (unattend Order 10, unchanged). - Install-AppList.ps1 runs C:\PreInstall\preinstall.json during setup. - A new SYSTEM task, GE Shopfloor Post-PPKG, runs postppkg.json once after the post-PPKG reboot. It also clears AutoLogonCount after the ShopFloor handover (the Users-level monitor can be denied that HKLM write) and removes the Intune Sync task when done. - Both lists ship empty. A site adds entries and installers. - WinPE choice 3 goes straight to the PPKG menu. Staging, fetch and heal carry only the generic payload. The webapp's shopfloor sub-menu editor is gone, and the imaging tiles name the new stages. - The playbook deploys the empty lists only if missing. It now deploys the PowerShell 7 MSI the shopfloor unattend installs, which it never did before. Blancco site settings move into the webapp (Updates > Blancco releases): business location, licensing Wi-Fi SSID and password. They are rendered into the served preferences.xml and applied to every staged release, and are seeded from site-vars (site_name, blancco_wifi_ssid, blancco_wifi_password) only while none exist. The repo XML carries no site values; the plaintext Wi-Fi password is gone from it. New site_name and site_timezone settings: report and evidence-pack site name, server clock and webapp time zone. No more West Jefferson defaults in webapp/config.py or the dashboard title. Sync-BPRT.ps1 gets the server address filled in at download instead of WJ's GE address. USB on Windows: - scripts/Build-PxeUsb.ps1 runs the same checks as build-usb.sh. It writes one FAT32 CIDATA partition (ISO files plus patched grub.cfg plus payload) with diskpart and reads every file back by SHA-256. - Tested with Windows PowerShell 5.1 in the win11 VM, and the layout installs and first-boots in the pxetest VM. - scripts/stage-usb-share.sh stages the build folder on a share. - site-content/ is optional for both builders. KB docs/kb/How_To_Deploy_PXE_Server_New_Site.html: new-site deployment, Windows only, with a site-vars.yml guide. Checks: webapp tests pass (5 suites); the unattend lint and playbook syntax checks pass; every .ps1 parses; there are no WJ hosts, IPs, names or PC types in playbook/, webapp/, scripts/ or autoinstall/.
415 lines
18 KiB
HTML
415 lines
18 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 %}
|
|
{% 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 {{ clear_count }} imaging session files{{ (', including ' ~ hidden ~ ' hidden finished') if hidden else '' }}? 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="del">Clear all sessions</button>
|
|
</form>
|
|
{% 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>
|
|
{% 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>
|
|
|
|
<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
|
|
|
|
{
|
|
"serial": "4HBLF33",
|
|
"mac": "e4:54:e8:dc:b1:f0",
|
|
"hostname_target": "EDNMG3D4",
|
|
"pctype": "gea-shopfloor-standard",
|
|
"current_stage": "Run-ShopfloorSetup: installing the site app list",
|
|
"stage_index": 3,
|
|
"stage_total": 8,
|
|
"status": "in_progress",
|
|
"log_lines": ["last few log lines from the stage"]
|
|
}</pre>
|
|
</details>
|
|
{% 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', all='1' if show_all else None) }}";
|
|
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;
|
|
// Also while a password is shown, or typed but not yet made into a QR.
|
|
var inputs = document.querySelectorAll('.laps-input');
|
|
for (var i = 0; i < inputs.length; i++) {
|
|
if (inputs[i].type === 'text' || inputs[i].value !== inputs[i].defaultValue) 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(--act)', 'EventSource unsupported - polling only');
|
|
startPolling();
|
|
return;
|
|
}
|
|
try {
|
|
sse = new EventSource(STREAM_URL);
|
|
} catch (e) {
|
|
setDot('var(--fail)', 'SSE failed - polling');
|
|
startPolling();
|
|
return;
|
|
}
|
|
sse.onopen = function() {
|
|
setDot('var(--ok)', '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(--fail)', '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();
|
|
});
|
|
})();
|
|
|
|
// 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
|
|
// 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);
|
|
} else if (e.target.classList.contains('laps-show-btn')) {
|
|
card = e.target.closest('.laps-card');
|
|
var inp = card && card.querySelector('.laps-input');
|
|
if (inp) {
|
|
var show = inp.type === 'password';
|
|
inp.type = show ? 'text' : 'password';
|
|
e.target.textContent = show ? 'Hide' : 'Show';
|
|
e.target.setAttribute('aria-pressed', show ? 'true' : 'false');
|
|
}
|
|
}
|
|
});
|
|
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';
|
|
var LOG_EXPANDED_KEY = 'imaging-log-expanded';
|
|
function loadExpandedSet(key) {
|
|
try { return new Set(JSON.parse(localStorage.getItem(key || EXPANDED_KEY) || '[]')); }
|
|
catch (e) { return new Set(); }
|
|
}
|
|
function saveExpandedSet(set, key) {
|
|
try { localStorage.setItem(key || EXPANDED_KEY, JSON.stringify(Array.from(set))); }
|
|
catch (e) {}
|
|
}
|
|
// Log-tail scroll per tile, in memory only. A tile followed at the
|
|
// bottom keeps following new lines; one scrolled up stays put.
|
|
var logScroll = {};
|
|
|
|
function restoreExpandedState() {
|
|
var expanded = loadExpandedSet();
|
|
document.querySelectorAll('.imaging-card').forEach(function(card) {
|
|
var serial = card.getAttribute('data-serial') || card.getAttribute('data-key');
|
|
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 (row.open) s.add(serial); else s.delete(serial);
|
|
saveExpandedSet(s);
|
|
});
|
|
row._toggleBound = true;
|
|
}
|
|
});
|
|
// The nested "Log tail" <details> is re-rendered closed on every
|
|
// refresh; keep it open (and its scroll) the same way as the tile.
|
|
var logExpanded = loadExpandedSet(LOG_EXPANDED_KEY);
|
|
document.querySelectorAll('.imaging-log').forEach(function(log) {
|
|
var key = log.getAttribute('data-key');
|
|
if (!key) return;
|
|
if (logExpanded.has(key)) log.open = true;
|
|
var pre = log.querySelector('.imaging-log-pre');
|
|
if (pre) {
|
|
var saved = logScroll[key];
|
|
if (!saved || saved.atBottom) pre.scrollTop = pre.scrollHeight;
|
|
else pre.scrollTop = saved.top;
|
|
pre.addEventListener('scroll', function() {
|
|
logScroll[key] = {
|
|
top: pre.scrollTop,
|
|
atBottom: pre.scrollTop + pre.clientHeight >= pre.scrollHeight - 4
|
|
};
|
|
});
|
|
}
|
|
log.addEventListener('toggle', function() {
|
|
var s = loadExpandedSet(LOG_EXPANDED_KEY);
|
|
if (log.open) s.add(key); else s.delete(key);
|
|
saveExpandedSet(s, LOG_EXPANDED_KEY);
|
|
if (log.open && pre) pre.scrollTop = pre.scrollHeight;
|
|
});
|
|
});
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
var stateFilter = '';
|
|
function applyFilter() {
|
|
var search = document.getElementById('imaging-search');
|
|
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) {
|
|
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) && (stateFilter === '' || st === stateFilter);
|
|
card.hidden = !match;
|
|
if (match) visible++;
|
|
});
|
|
document.querySelectorAll('#floor-filters [data-count]').forEach(function(n) {
|
|
n.textContent = counts[n.getAttribute('data-count')];
|
|
});
|
|
// A log-only bay that matches a search is shown: open its group.
|
|
var logs = document.querySelector('details.floor-logs');
|
|
if (logs) {
|
|
var hit = Array.prototype.some.call(logs.querySelectorAll('.imaging-card'), function (c) { return !c.hidden; });
|
|
if (q !== '' && hit) logs.open = true;
|
|
if (!hit) logs.hidden = true; else logs.hidden = false;
|
|
}
|
|
var none = document.getElementById('floor-nomatch');
|
|
if (none) none.hidden = visible > 0 || (q === '' && stateFilter === '');
|
|
}
|
|
|
|
function restoreLogsGroup() {
|
|
var g = document.querySelector('details.floor-logs');
|
|
if (!g) return;
|
|
try { if (sessionStorage.getItem('floor-logs-open') === '1') g.open = true; } catch (e) {}
|
|
if (!g._bound) {
|
|
g.addEventListener('toggle', function () { try { sessionStorage.setItem('floor-logs-open', g.open ? '1' : '0'); } catch (e) {} });
|
|
g._bound = true;
|
|
}
|
|
}
|
|
|
|
window.imagingPostSwapHooks = function() {
|
|
restoreLogsGroup();
|
|
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);
|
|
}
|
|
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();
|
|
});
|
|
})();
|
|
</script>
|
|
{% endblock %}
|