Was hiding LAPS QR section until idx=7 pushed with a DeviceId.
Operator couldn't paste a password if Monitor hadn't gotten around
to capturing the DeviceId yet. The QR encoding doesn't depend on
DeviceId - it's just the password being encoded - so the section is
useful any time the bay is past the LAPS reboot.
Drop the {% if s.intune_device_id %} gate. LAPS section now appears
in every expanded tile.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
397 lines
17 KiB
HTML
397 lines
17 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: ('Awaiting Intune lockdown',
|
|
'Device ID captured. If Device Category is NOT yet set in Intune, click "set category" first. Bay then waits for the Intune-driven LAPS-prompt reboot to apply the lockdown configuration.'),
|
|
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, '')) %}
|
|
<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 %}
|