Monitor's Get-Snapshot already tracks Phase 1-5 (Intune Registration,
Device Configuration, Software Deployment, Credentials, Lockdown).
The webapp dashboard only saw a single idx=7 push for the entire
post-PPKG / pre-lockdown window, so the friendly label couldn't
reflect "where is this bay actually". Operator looking at the
dashboard had no idea whether to assign category or hit ARTS for
lockdown next.
Monitor now pushes additional idx=7 entries as it crosses Phase
boundaries:
- On DeviceId capture: "Intune Device ID captured" (existing)
- On Phase 2 done (SFLD policy delivered = category was assigned):
"Phase 2 SFLD policy delivered (device configuration)"
- On Phase 1-4 all complete: "Phases 1-4 complete - ready for
lockdown (ARTS request)"
- On lockdown done: idx=8 (existing)
imaging.html maps the stage_string substring to friendly labels:
- default idx=7 -> "Registered - assign category"
- 'sfld policy' / 'phase 2' -> "Phase 2 - device configuration"
- 'credentials' / 'phase 4' -> "Phase 3 / 4 - DSC + credentials"
- 'ready for lockdown' / 'request lockdown' -> "Ready - request
lockdown" (hint: click ARTS request)
Operator now knows exactly when to act vs when to wait.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
413 lines
18 KiB
HTML
413 lines
18 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}Imaging Progress - PXE Server Manager{% endblock %}
|
|
|
|
{% block extra_head %}
|
|
{# JS-driven refresh instead of meta http-equiv so we can cancel it while a #}
|
|
{# LAPS-password QR is showing (otherwise the 5s reload wipes the in-page #}
|
|
{# state every cycle). #}
|
|
<script>
|
|
function scheduleImagingReload() {
|
|
window._imagingReloadTimer = setTimeout(function() { location.reload(); }, 15000);
|
|
}
|
|
function cancelImagingReload() {
|
|
if (window._imagingReloadTimer) { clearTimeout(window._imagingReloadTimer); window._imagingReloadTimer = null; }
|
|
}
|
|
window.addEventListener('DOMContentLoaded', scheduleImagingReload);
|
|
</script>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
|
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
|
<div>
|
|
<h2 class="mb-0">Imaging Progress</h2>
|
|
<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"><span id="visible-count">{{ sessions|length }}</span>/{{ sessions|length }}</span>
|
|
</div>
|
|
|
|
<div class="mb-3">
|
|
<input id="imaging-search" type="search" class="form-control form-control-sm"
|
|
placeholder="Filter by serial, hostname, pctype, machine#, Intune id, stage name, stage-N, status - typing pauses auto-refresh"
|
|
autocomplete="off">
|
|
</div>
|
|
|
|
{% if not sessions %}
|
|
<div class="card">
|
|
<div class="card-body text-center text-muted py-5">
|
|
<p class="mb-1">No imaging sessions yet.</p>
|
|
<p class="small mb-0">A PC being imaged will post status here.
|
|
Until then, nothing to show.</p>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
{% set stage_labels = {
|
|
1: ('Booting from PXE', 'WinPE loaded - applying Windows image to disk.'),
|
|
2: ('Configuring Windows', 'First boot. Running shopfloor setup baseline scripts.'),
|
|
3: ('Installing apps', 'Type-specific app installs (eDNC, UDC, NTLARS, etc).'),
|
|
4: ('Apps installed', 'Type-specific scripts complete. Preparing for Intune enrollment.'),
|
|
5: ('Enrolling in Intune', 'PPKG installing - device joining Azure AD + Intune. ~5-10 min, reboot to follow.'),
|
|
6: ('Waiting on first Intune sync','Post-PPKG settle (~120s). Triggering Schedule #3 sync repeatedly.'),
|
|
7: ('Registered - assign category',
|
|
'Phase 1 (Intune Registration) complete. Click "set category" in the Intune portal to drop the bay into the right config-profile group.'),
|
|
8: ('Imaging complete',
|
|
'Lockdown applied. Bay rebooted into ShopFloor session. Ready for production.')
|
|
} %}
|
|
|
|
{% for s in sessions %}
|
|
{% set stage_idx = s.stage_index | int(0) %}
|
|
{% set stage_total = s.stage_total | int(0) %}
|
|
{% set pct = 100 if s.status == 'succeeded' else ((stage_idx / stage_total * 100) | round(0, 'floor')) if stage_total > 0 else 0 %}
|
|
{% set is_failed = s.status == 'failed' %}
|
|
{% 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, '')) %}
|
|
{# Stage 7 fans out by sub-phase. Monitor pushes different stage #}
|
|
{# strings as it crosses each Phase 1-4 boundary. Swap friendly #}
|
|
{# label based on which keyword shows up. #}
|
|
{% if stage_idx == 7 and s.current_stage %}
|
|
{% set _cs = s.current_stage|lower %}
|
|
{% if 'ready for lockdown' in _cs or 'request lockdown' in _cs %}
|
|
{% set friendly = ('Ready - request lockdown',
|
|
'Phase 1-4 all complete (Registration, Device Config, Software Deploy, Credentials). Click "ARTS request" to initiate the lockdown workflow.') %}
|
|
{% elif 'credentials' in _cs or 'phase 4' in _cs %}
|
|
{% set friendly = ('Phase 3 / 4 - DSC + credentials',
|
|
'SFLD policy delivered, DSC pulling device-config.yaml + running per-app wrappers. SFLD share creds populating.') %}
|
|
{% elif 'sfld policy' in _cs or 'phase 2' in _cs or 'device configuration' in _cs %}
|
|
{% set friendly = ('Phase 2 - device configuration',
|
|
'Category was assigned in Intune. SFLD ConfigurationProfile (Function + SasToken) has delivered. DSC kicking off next.') %}
|
|
{% endif %}
|
|
{% endif %}
|
|
<details class="card border-{{ border }} mb-2 shadow-sm imaging-card"
|
|
data-serial="{{ s.serial }}"
|
|
data-filter="{{ s.serial|lower }} {{ (s.hostname_target or '')|lower }} {{ (s.pctype or '')|lower }} {{ (s.machinenumber or '')|lower }} {{ (s.intune_device_id or '')|lower }} {{ friendly[0]|lower }} stage-{{ stage_idx }} {{ (s.status or 'in_progress')|lower }}">
|
|
<summary class="card-body py-2" style="cursor:pointer; list-style:none;">
|
|
<div class="d-flex flex-wrap gap-3 align-items-center">
|
|
{% if s.intune_device_id %}
|
|
<div data-qr="{{ s.intune_device_id }}" data-qr-size="96" 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 small"
|
|
style="width:96px; height:96px; border-radius:0.25rem; flex-shrink:0; text-align:center; padding:0.25rem;">
|
|
no DeviceId
|
|
</div>
|
|
{% endif %}
|
|
|
|
<div class="flex-grow-1" style="min-width:0;">
|
|
<div class="d-flex flex-wrap align-items-center gap-2">
|
|
<strong class="fs-6">{{ s.serial or '(no serial)' }}</strong>
|
|
{% if s.hostname_target %}<code class="text-muted small">{{ 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="d-flex justify-content-between align-items-baseline mt-1">
|
|
<div>
|
|
<strong>{{ 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 mt-1" style="height:0.7rem;">
|
|
<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>
|
|
</div>
|
|
</div>
|
|
</summary>
|
|
|
|
<div class="card-body pt-0 pb-3 border-top">
|
|
{% if friendly[1] %}<div class="small text-muted mt-2">{{ friendly[1] }}</div>{% endif %}
|
|
|
|
{% if s.intune_device_id %}
|
|
<div class="small mt-2" style="font-size:0.75rem;">
|
|
<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 copy-btn"
|
|
style="font-size:0.65rem; line-height:1; transition: all 0.2s;"
|
|
data-copy-text="{{ s.intune_device_id }}">copy</button>
|
|
<a class="btn btn-sm btn-outline-primary py-0 px-1"
|
|
style="font-size:0.65rem; 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>
|
|
<a class="btn btn-sm btn-outline-warning py-0 px-1"
|
|
style="font-size:0.65rem; line-height:1;"
|
|
target="_blank" rel="noopener"
|
|
href="https://arts.dw.geaerospace.net/requests/type">ARTS request</a>
|
|
</div>
|
|
{% endif %}
|
|
|
|
<div class="text-muted mt-1" style="font-size:0.7rem;">
|
|
<span class="me-3">started <code>{{ s.started_at or '-' }}</code></span>
|
|
<span class="me-3">last <code>{{ s.last_updated or '-' }}</code></span>
|
|
<span class="me-3">MAC <code>{{ s.mac or '-' }}</code></span>
|
|
{% if s.current_stage %}<span style="font-family:monospace;">{{ s.current_stage }}</span>{% endif %}
|
|
</div>
|
|
|
|
{% if s.error %}
|
|
<div class="alert alert-danger small py-2 mb-2 mt-3">
|
|
<strong>Error:</strong> {{ s.error }}
|
|
</div>
|
|
{% endif %}
|
|
|
|
<div class="mt-3 laps-card" data-serial="{{ s.serial }}">
|
|
<div class="text-muted small mb-1">LAPS password QR (paste -> scan on bay - persists until cleared)</div>
|
|
<div class="d-flex align-items-center gap-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"
|
|
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>
|
|
</div>
|
|
|
|
{% if s.log_tail %}
|
|
<details class="mt-3">
|
|
<summary class="text-muted small">Log tail ({{ s.log_tail | length }} line{{ 's' if s.log_tail | length != 1 }})</summary>
|
|
<pre class="bg-light p-2 mt-2 small mb-0" style="max-height: 12rem; overflow-y: auto;">{% for line in s.log_tail %}{{ line }}
|
|
{% endfor %}</pre>
|
|
</details>
|
|
{% endif %}
|
|
|
|
<div class="mt-3 text-end">
|
|
<form method="post" action="{{ url_for('imaging_delete_session', serial=s.serial) }}" style="display: inline;">
|
|
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
|
<button type="submit" class="btn btn-sm btn-outline-secondary"
|
|
onclick="return confirm('Clear session {{ s.serial }}?');">
|
|
Clear
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</details>
|
|
{% endfor %}
|
|
|
|
<div class="card mt-3">
|
|
<div class="card-body small text-muted">
|
|
<strong>How to push status from an imaging client:</strong>
|
|
<pre class="mb-0 mt-2">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>
|
|
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); }
|
|
}
|
|
});
|
|
// 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 });
|
|
});
|
|
});
|
|
|
|
// Persist tile expanded/collapsed state across page refresh via
|
|
// localStorage. Set of expanded serials lives at 'imaging-expanded'.
|
|
(function() {
|
|
var KEY = 'imaging-expanded';
|
|
function loadSet() {
|
|
try { return new Set(JSON.parse(localStorage.getItem(KEY) || '[]')); }
|
|
catch (e) { return new Set(); }
|
|
}
|
|
function saveSet(set) {
|
|
try { localStorage.setItem(KEY, JSON.stringify(Array.from(set))); }
|
|
catch (e) {}
|
|
}
|
|
window.addEventListener('DOMContentLoaded', function() {
|
|
var expanded = loadSet();
|
|
document.querySelectorAll('.imaging-card').forEach(function(card) {
|
|
var serial = card.getAttribute('data-serial');
|
|
if (serial && expanded.has(serial)) card.open = true;
|
|
card.addEventListener('toggle', function() {
|
|
var s = loadSet();
|
|
if (card.open) s.add(serial); else s.delete(serial);
|
|
saveSet(s);
|
|
});
|
|
});
|
|
});
|
|
})();
|
|
|
|
// Client-side filter: hide imaging-card elements whose data-filter doesn't
|
|
// match the search query. Live as user types. Pauses auto-reload while
|
|
// the input is focused or non-empty so typing isn't interrupted by refresh.
|
|
window.addEventListener('DOMContentLoaded', function() {
|
|
var search = document.getElementById('imaging-search');
|
|
var counter = document.getElementById('visible-count');
|
|
if (!search) return;
|
|
function applyFilter() {
|
|
var q = search.value.trim().toLowerCase();
|
|
var visible = 0;
|
|
document.querySelectorAll('.imaging-card').forEach(function(card) {
|
|
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 (q !== '') { cancelImagingReload(); }
|
|
else { cancelImagingReload(); scheduleImagingReload(); }
|
|
}
|
|
search.addEventListener('input', applyFilter);
|
|
search.addEventListener('focus', cancelImagingReload);
|
|
search.addEventListener('blur', function() {
|
|
if (search.value.trim() === '') { cancelImagingReload(); scheduleImagingReload(); }
|
|
});
|
|
applyFilter();
|
|
});
|
|
</script>
|
|
{% endblock %}
|