Per-bay <details> section with: - Input field for LAPS password (paste from Intune portal manually, since deep-link to LAPS blade needs AAD objectId we can't obtain) - Make QR button generates a client-side QR from the input - QR displayed below at 280px with 4-cell quiet zone - Auto-clears input + QR after 60s with live countdown - Manual Clear button - Enter key on the input also triggers QR generation Password never POSTs to server, never logged, never persists past the 60s window. Generated using the same qrcode-generator lib already loaded for the device-id QR. Scan with a USB barcode scanner plugged into the bay (HID keyboard mode) -> password types into bay login field. Faster than reading off the Intune portal letter-by-letter. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
286 lines
11 KiB
HTML
286 lines
11 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}Imaging Progress - PXE Server Manager{% endblock %}
|
|
|
|
{% block extra_head %}
|
|
<meta http-equiv="refresh" content="5">
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
|
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
<div>
|
|
<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>
|
|
</div>
|
|
<span class="badge bg-secondary fs-6">{{ sessions|length }} session{{ 's' if sessions|length != 1 }}</span>
|
|
</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 %}
|
|
|
|
{% 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') %}
|
|
<div class="card border-{{ border }} mb-3">
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|
<div class="d-flex align-items-center gap-3">
|
|
{% if s.intune_device_id %}
|
|
<div data-qr="{{ s.intune_device_id }}" data-qr-size="160" data-qr-ec="M" style="line-height:0;"
|
|
title="Intune Device ID: {{ s.intune_device_id }}"></div>
|
|
{% endif %}
|
|
<div>
|
|
<strong>{{ s.serial or '(no serial)' }}</strong>
|
|
{% if s.hostname_target %}<code class="ms-2 small text-muted">{{ s.hostname_target }}</code>{% endif %}
|
|
{% if s.pctype %}<span class="badge bg-info text-dark ms-2">{{ s.pctype }}</span>{% endif %}
|
|
{% if s.machinenumber %}<span class="badge bg-secondary ms-1">#{{ s.machinenumber }}</span>{% endif %}
|
|
{% if s.intune_device_id %}<div class="small text-muted mt-1">Intune: <code>{{ s.intune_device_id }}</code>
|
|
<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;"
|
|
data-copy-text="{{ s.intune_device_id }}">copy</button>
|
|
<a class="btn btn-sm btn-outline-primary py-0 px-1 ms-1"
|
|
style="font-size:0.7rem; 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>
|
|
</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-3">
|
|
<div class="text-muted">Current stage</div>
|
|
<div><strong>{{ s.current_stage or '-' }}</strong></div>
|
|
</div>
|
|
<div class="col-md-2">
|
|
<div class="text-muted">Stage</div>
|
|
<div>{{ stage_idx }} / {{ stage_total or '?' }}</div>
|
|
</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 class="progress mb-2" style="height: 1.4rem;">
|
|
<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">{{ pct }}%</div>
|
|
</div>
|
|
|
|
{% if s.error %}
|
|
<div class="alert alert-danger small py-2 mb-2">
|
|
<strong>Error:</strong> {{ s.error }}
|
|
</div>
|
|
{% endif %}
|
|
|
|
{% if s.intune_device_id %}
|
|
<details class="mt-2 laps-card">
|
|
<summary class="text-muted small">LAPS password QR (paste -> scan on bay)</summary>
|
|
<div class="d-flex align-items-center gap-2 mt-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">
|
|
<button type="button" class="btn btn-sm btn-primary laps-make-btn">Make QR</button>
|
|
<button type="button" class="btn btn-sm btn-outline-secondary laps-clear-btn" style="display:none;">Clear</button>
|
|
<span class="laps-timer text-muted small"></span>
|
|
</div>
|
|
<div class="laps-qr-container mt-2"></div>
|
|
</details>
|
|
{% endif %}
|
|
|
|
{% if s.log_tail %}
|
|
<details>
|
|
<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>
|
|
</div>
|
|
{% 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 -> client-side QR. Password never leaves browser tab.
|
|
// Auto-clears input + QR after 60s so it doesn't linger on shared screens.
|
|
function renderLapsQR(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 timerEl = card.querySelector('.laps-timer');
|
|
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.style.display = 'none';
|
|
clearBtn.style.display = '';
|
|
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) {
|
|
container.textContent = 'QR error: ' + err;
|
|
}
|
|
}
|
|
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 timerEl = card.querySelector('.laps-timer');
|
|
if (card._lapsTimer) { clearInterval(card._lapsTimer); card._lapsTimer = null; }
|
|
input.value = '';
|
|
container.innerHTML = '';
|
|
timerEl.textContent = '';
|
|
makeBtn.style.display = '';
|
|
clearBtn.style.display = 'none';
|
|
}
|
|
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); }
|
|
}
|
|
});
|
|
</script>
|
|
{% endblock %}
|