Imaging dashboard
- services/imaging_log_tail.py: parses dnsmasq leases, Apache access log,
Samba per-host log files, and dnsmasq syslog (DHCP/TFTP). Synthesizes
inferred sessions keyed by MAC for bays that have only touched the boot
chain but not yet pushed to /imaging/status. Active window 90 min.
- imaging_status.list_sessions() merges inferred sessions into the dashboard
list. Real client-pushed sessions win for the same MAC.
- imaging_status: stage_history field tracks every stage transition (capped
30); sidecar .log file per serial records every log_lines push uncapped
(read_full_log() caps detail-page response to 1 MB).
- delete_session/delete_all_sessions clean up sidecar .log too.
- New SSE endpoint /imaging/stream emits a session-list hash every 5s.
Client fetches /imaging/tiles (HTML partial) on hash change and swaps
#imaging-tiles innerHTML. Polling fallback at 15s if SSE drops.
- Tile-swap preserves scroll, filter input, expanded state via localStorage,
and any LAPS input the operator is mid-pasting (swap skipped when a
laps-input is focused).
- imaging.html: removed 15s location.reload(). Added live-status dot in
header (gray idle / green SSE connected / red SSE lost).
- _imaging_tiles.html: shared partial used by both /imaging full render and
/imaging/tiles SSE refresh. Inferred bays render with yellow border +
log-inferred badge + no progress bar (stage_index inference is coarse).
- imaging_detail.html (new): per-bay forensics page at /imaging/session/
<serial>. Session metadata grid, stage timeline table, full sidecar log
with truncation indicator, Copy-support-summary button. Linked from each
client-pushed tile.
- qr-render.js exposes window.renderAllQRs() so the SSE swap can re-render
Intune device-ID QRs in the swapped-in tiles.
Image management
- services/image_registry.py: JSON registry of image types at
{SAMBA_SHARE}/image-registry.json. Bootstraps from baked-in
config.IMAGE_TYPES on first run. create/clone/delete/rename_friendly
mutate the file then call reload() which rewrites config.IMAGE_TYPES +
config.FRIENDLY_NAMES in place. Sidebar reflects on next request.
- app.py routes: /images/new, /images/<t>/clone, /images/<t>/delete (with
optional content-wipe checkbox), /images/<t>/rename.
- dashboard.html: + New image type button + Clone/Delete per row, all in
Bootstrap modals with confirmation copy.
- Clone copies Deploy/ tree but preserves symlinks to shared dirs (Out-of-
box Drivers, Operating Systems, Packages) so disk usage stays low.
- Delete with content checked unlinks symlinks (does not follow into shared
dirs).
Driver / package upload + orphan adoption
- services/images.py: upload_driver, adopt_orphan, remove_orphans,
upload_package. Filename sanitization blocks path traversal.
- app.py routes: /images/<t>/drivers/upload, /images/<t>/drivers/adopt,
/images/<t>/drivers/orphans/delete, /images/<t>/packages/upload.
- image_config.html: Upload .zip button + modal on Drivers section. Orphan
drivers card-footer rebuilt as interactive list with per-row Adopt inline
form (family + destinationDir inputs) and bulk select+delete.
- Upload .zip on Packages section with optional destinationDir field that
appends a packages.json entry.
Configuration
- config.py: new env vars DNSMASQ_LEASES, APACHE_ACCESS_LOG, SAMBA_LOG_DIR,
DNSMASQ_SYSLOG for the log-tailer.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
192 lines
7.6 KiB
HTML
192 lines
7.6 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}{{ session.serial }} - Imaging Session{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
<div>
|
|
<h2 class="mb-0">{{ session.serial }}
|
|
{% if session.hostname_target %}<small class="text-muted">{{ session.hostname_target }}</small>{% endif %}
|
|
</h2>
|
|
<small class="text-muted">
|
|
{% if session.pctype %}<span class="badge bg-info text-dark">{{ session.pctype }}</span>{% endif %}
|
|
{% if session.machinenumber %}<span class="badge bg-secondary">#{{ session.machinenumber }}</span>{% endif %}
|
|
<span class="badge bg-{{ 'success' if session.status == 'succeeded' else ('danger' if session.status == 'failed' else 'primary') }}">{{ session.status or 'in_progress' }}</span>
|
|
</small>
|
|
</div>
|
|
<div>
|
|
<a href="{{ url_for('imaging_dashboard') }}" class="btn btn-outline-secondary btn-sm">
|
|
« Back to dashboard
|
|
</a>
|
|
<button type="button" class="btn btn-outline-primary btn-sm copy-summary-btn">
|
|
Copy support summary
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row">
|
|
<div class="col-lg-5">
|
|
|
|
<div class="card mb-3">
|
|
<div class="card-header">Session metadata</div>
|
|
<div class="card-body">
|
|
<dl class="row mb-0 small">
|
|
<dt class="col-4">Serial</dt><dd class="col-8"><code>{{ session.serial }}</code></dd>
|
|
<dt class="col-4">Hostname</dt><dd class="col-8"><code>{{ session.hostname_target or '-' }}</code></dd>
|
|
<dt class="col-4">MAC</dt><dd class="col-8"><code>{{ session.mac or '-' }}</code></dd>
|
|
<dt class="col-4">PC type</dt><dd class="col-8">{{ session.pctype or '-' }}</dd>
|
|
<dt class="col-4">Machine #</dt><dd class="col-8">{{ session.machinenumber or '-' }}</dd>
|
|
<dt class="col-4">Intune ID</dt><dd class="col-8">
|
|
{% if session.intune_device_id %}
|
|
<code style="word-break: break-all;">{{ session.intune_device_id }}</code>
|
|
{% else %}-{% endif %}
|
|
</dd>
|
|
<dt class="col-4">Started</dt><dd class="col-8"><code>{{ session.started_at or '-' }}</code></dd>
|
|
<dt class="col-4">Last update</dt><dd class="col-8"><code>{{ session.last_updated or '-' }}</code></dd>
|
|
<dt class="col-4">Stage</dt><dd class="col-8">
|
|
{{ session.stage_index or 0 }}/{{ session.stage_total or '?' }}
|
|
{% if session.current_stage %} - <code class="small">{{ session.current_stage }}</code>{% endif %}
|
|
</dd>
|
|
{% if session.previous_run_at %}
|
|
<dt class="col-4">Prior run</dt><dd class="col-8"><code>{{ session.previous_run_at }}</code></dd>
|
|
{% endif %}
|
|
{% if session.error %}
|
|
<dt class="col-4 text-danger">Error</dt>
|
|
<dd class="col-8 text-danger">{{ session.error }}</dd>
|
|
{% endif %}
|
|
</dl>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card mb-3">
|
|
<div class="card-header">Stage timeline</div>
|
|
<div class="card-body p-0">
|
|
{% if session.stage_history %}
|
|
<table class="table table-sm mb-0">
|
|
<thead class="table-light">
|
|
<tr><th>Time</th><th>Stage</th><th>Status</th><th>Detail</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for h in session.stage_history %}
|
|
<tr>
|
|
<td class="small"><code>{{ h.ts }}</code></td>
|
|
<td><span class="badge bg-secondary">{{ h.stage_index }}</span></td>
|
|
<td>
|
|
<span class="badge bg-{{ 'success' if h.status == 'succeeded' else ('danger' if h.status == 'failed' else 'primary') }}">
|
|
{{ h.status }}
|
|
</span>
|
|
</td>
|
|
<td class="small"><code>{{ h.current_stage or '' }}</code></td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
{% else %}
|
|
<div class="p-3 text-muted small">
|
|
No stage transitions recorded yet. The client only logs a row when
|
|
stage_index advances or status flips to succeeded/failed.
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<div class="col-lg-7">
|
|
|
|
<div class="card mb-3">
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|
<span>Full log
|
|
{% if full_log_truncated %}
|
|
<span class="badge bg-warning text-dark ms-2">truncated (last 1 MB)</span>
|
|
{% endif %}
|
|
</span>
|
|
<button type="button" class="btn btn-sm btn-outline-secondary copy-log-btn">Copy</button>
|
|
</div>
|
|
<div class="card-body p-0">
|
|
{% if full_log %}
|
|
<pre id="full-log" class="bg-light p-2 mb-0 small" style="max-height: 36rem; overflow-y: auto; white-space: pre-wrap;">{{ full_log }}</pre>
|
|
{% else %}
|
|
<div class="p-3 text-muted small">
|
|
No log content. The client has not pushed any <code>log_lines</code>
|
|
entries yet, or the sidecar file was cleared.
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
|
|
{% if session.log_tail %}
|
|
<div class="card mb-3">
|
|
<div class="card-header">Recent tail ({{ session.log_tail | length }} line{{ 's' if session.log_tail | length != 1 }})</div>
|
|
<div class="card-body p-0">
|
|
<pre class="bg-light p-2 mb-0 small" style="max-height: 12rem; overflow-y: auto;">{% for line in session.log_tail %}{{ line }}
|
|
{% endfor %}</pre>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
|
|
</div>
|
|
</div>
|
|
|
|
{% endblock %}
|
|
|
|
{% block extra_scripts %}
|
|
<script>
|
|
function copyText(text) {
|
|
if (navigator.clipboard && window.isSecureContext) {
|
|
return navigator.clipboard.writeText(text);
|
|
}
|
|
return new Promise(function(resolve, reject) {
|
|
var ta = document.createElement('textarea');
|
|
ta.value = text;
|
|
ta.style.position = 'fixed'; ta.style.left = '-9999px';
|
|
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);
|
|
}
|
|
});
|
|
}
|
|
|
|
document.addEventListener('click', function(e) {
|
|
if (e.target.classList.contains('copy-log-btn')) {
|
|
var pre = document.getElementById('full-log');
|
|
if (pre) {
|
|
copyText(pre.textContent).then(function() {
|
|
e.target.textContent = 'copied!';
|
|
setTimeout(function() { e.target.textContent = 'Copy'; }, 1200);
|
|
});
|
|
}
|
|
} else if (e.target.classList.contains('copy-summary-btn')) {
|
|
var lines = [];
|
|
document.querySelectorAll('dl dt').forEach(function(dt) {
|
|
var dd = dt.nextElementSibling;
|
|
if (dd) {
|
|
lines.push(dt.textContent.trim() + ': ' + dd.textContent.trim().replace(/\s+/g, ' '));
|
|
}
|
|
});
|
|
var rows = document.querySelectorAll('table tbody tr');
|
|
if (rows.length) {
|
|
lines.push('');
|
|
lines.push('Stage timeline:');
|
|
rows.forEach(function(tr) {
|
|
var cells = tr.querySelectorAll('td');
|
|
if (cells.length === 4) {
|
|
lines.push(' - ' + cells[0].textContent.trim() + ' stage ' + cells[1].textContent.trim()
|
|
+ ' ' + cells[2].textContent.trim() + ' ' + cells[3].textContent.trim());
|
|
}
|
|
});
|
|
}
|
|
copyText(lines.join('\n')).then(function() {
|
|
e.target.textContent = 'copied!';
|
|
setTimeout(function() { e.target.textContent = 'Copy support summary'; }, 1200);
|
|
});
|
|
}
|
|
});
|
|
</script>
|
|
{% endblock %}
|