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>
459 lines
19 KiB
HTML
459 lines
19 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}{{ friendly_name }} - Configuration{% endblock %}
|
|
|
|
{% block extra_head %}
|
|
<style>
|
|
.section-card { margin-bottom: 1.5rem; }
|
|
.section-card .card-header { padding: 0.6rem 1rem; font-size: 0.95rem; }
|
|
.badge-disk { font-size: 0.75rem; }
|
|
.orphan-section { background-color: #fff8e1; }
|
|
.config-table td, .config-table th { vertical-align: middle; }
|
|
.config-table .form-control-sm { min-width: 120px; }
|
|
.text-truncate-cell { max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
</style>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
|
<div>
|
|
<h2 class="mb-1">{{ friendly_name }}</h2>
|
|
<small class="text-muted">
|
|
Image Configuration
|
|
— OS Selection: <strong>{{ config.os_selection or 'Not set' }}</strong>
|
|
</small>
|
|
</div>
|
|
<a href="{{ url_for('unattend_editor', image_type=image_type) }}" class="btn btn-outline-secondary btn-sm">
|
|
Edit Unattend
|
|
</a>
|
|
</div>
|
|
|
|
{# ==================== SECTION 1: Hardware Models ==================== #}
|
|
<div class="card section-card">
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|
<span>Hardware Models
|
|
<span class="badge bg-secondary ms-1">{{ config.hardware_models|length }}</span>
|
|
</span>
|
|
<div>
|
|
<button type="button" class="btn btn-sm btn-outline-primary" id="addHwModel">
|
|
Add
|
|
</button>
|
|
<button type="button" class="btn btn-sm btn-success ms-1" id="saveHwModels">
|
|
Save
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="card-body p-0">
|
|
<form method="POST" action="{{ url_for('image_config_save', image_type=image_type) }}" id="hwModelsForm">
|
|
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
|
<input type="hidden" name="section" value="hardware_models">
|
|
<input type="hidden" name="payload" id="hwModelsData" value="[]">
|
|
</form>
|
|
<table class="table table-sm table-hover mb-0 config-table" id="hwModelsTable">
|
|
<thead class="table-light">
|
|
<tr>
|
|
<th style="width:40px">#</th>
|
|
<th>Model</th>
|
|
<th>Driver Family ID</th>
|
|
<th style="width:90px">On Disk</th>
|
|
<th style="width:60px"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for hm in config.hardware_models %}
|
|
<tr>
|
|
<td class="order-num">{{ loop.index }}</td>
|
|
<td><input type="text" class="form-control form-control-sm" data-field="Model" value="{{ hm.Model }}"></td>
|
|
<td><input type="text" class="form-control form-control-sm" data-field="Id" value="{{ hm.Id }}"></td>
|
|
<td>
|
|
{% if hm._on_disk %}
|
|
<span class="badge bg-success badge-disk">Yes</span>
|
|
{% else %}
|
|
<span class="badge bg-danger badge-disk">No</span>
|
|
{% endif %}
|
|
</td>
|
|
<td>
|
|
<button type="button" class="btn btn-outline-danger btn-row-action remove-row">
|
|
Remove
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
{% if not config.hardware_models %}
|
|
<div class="text-center text-muted py-3 empty-message" id="hwModelsEmpty">
|
|
No hardware models configured.
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
|
|
{# ==================== SECTION 2: Driver Packs ==================== #}
|
|
<div class="card section-card">
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|
<span>Driver Packs
|
|
<span class="badge bg-secondary ms-1">{{ config.drivers|length }}</span>
|
|
</span>
|
|
<div>
|
|
<button type="button" class="btn btn-sm btn-outline-primary"
|
|
data-bs-toggle="modal" data-bs-target="#driverUploadModal">
|
|
Upload .zip
|
|
</button>
|
|
<button type="button" class="btn btn-sm btn-success ms-1" id="saveDrivers">
|
|
Save
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="card-body p-0">
|
|
<form method="POST" action="{{ url_for('image_config_save', image_type=image_type) }}" id="driversForm">
|
|
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
|
<input type="hidden" name="section" value="drivers">
|
|
<input type="hidden" name="payload" id="driversData" value="[]">
|
|
</form>
|
|
<div class="table-responsive">
|
|
<table class="table table-sm table-hover mb-0 config-table" id="driversTable">
|
|
<thead class="table-light">
|
|
<tr>
|
|
<th style="width:40px">#</th>
|
|
<th>Family</th>
|
|
<th>Models</th>
|
|
<th>File Name</th>
|
|
<th style="width:70px">OS IDs</th>
|
|
<th style="width:90px">On Disk</th>
|
|
<th style="width:60px"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for drv in config.drivers %}
|
|
<tr data-json='{{ drv | tojson }}'>
|
|
<td class="order-num">{{ loop.index }}</td>
|
|
<td class="text-truncate-cell" title="{{ drv.family }}">{{ drv.family }}</td>
|
|
<td class="text-truncate-cell" title="{{ drv.models }}">{{ drv.models }}</td>
|
|
<td class="text-truncate-cell" title="{{ drv.FileName or drv.get('fileName','') }}">
|
|
<small>{{ drv.FileName or drv.get('fileName','') }}</small>
|
|
</td>
|
|
<td><small>{{ drv.osId }}</small></td>
|
|
<td>
|
|
{% if drv._on_disk %}
|
|
<span class="badge bg-success badge-disk">Yes</span>
|
|
{% else %}
|
|
<span class="badge bg-danger badge-disk">No</span>
|
|
{% endif %}
|
|
</td>
|
|
<td>
|
|
<button type="button" class="btn btn-outline-danger btn-row-action remove-row">
|
|
Remove
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{% if not config.drivers %}
|
|
<div class="text-center text-muted py-3 empty-message" id="driversEmpty">
|
|
No driver packs configured.
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
|
|
{# Orphan drivers sub-section: zips on disk that aren't referenced by #}
|
|
{# HardwareDriver.json. Each row has an inline Adopt form (family + dest #}
|
|
{# inputs -> adds an entry to HardwareDriver.json). Bulk remove deletes #}
|
|
{# the selected .zip files from Out-of-box Drivers/. #}
|
|
{% if config.orphan_drivers %}
|
|
<div class="card-footer orphan-section p-0">
|
|
<div class="px-3 py-2 d-flex justify-content-between align-items-center">
|
|
<a class="text-decoration-none" data-bs-toggle="collapse" href="#orphanDrivers" role="button">
|
|
<strong>Unregistered Drivers ({{ config.orphan_drivers|length }})</strong>
|
|
<small class="text-muted ms-1">.zip files on disk, no JSON entry</small>
|
|
</a>
|
|
</div>
|
|
<div class="collapse show" id="orphanDrivers">
|
|
<form method="POST" action="{{ url_for('images_drivers_orphans_delete', image_type=image_type) }}"
|
|
onsubmit="return confirm('Delete the selected unregistered driver .zip(s) from disk? Cannot be undone.');">
|
|
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
|
<table class="table table-sm mb-0 align-middle">
|
|
<thead class="table-light">
|
|
<tr>
|
|
<th style="width:30px"><input type="checkbox" id="orphanSelectAll"></th>
|
|
<th>File Name</th>
|
|
<th>Relative Path</th>
|
|
<th style="width:300px">Adopt into HardwareDriver.json</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for orph in config.orphan_drivers %}
|
|
<tr>
|
|
<td><input type="checkbox" class="orphan-select" name="filename" value="{{ orph.fileName }}"></td>
|
|
<td><small>{{ orph.fileName }}</small></td>
|
|
<td><small class="text-muted">{{ orph.relPath }}</small></td>
|
|
<td>
|
|
<form method="POST" action="{{ url_for('images_drivers_adopt', image_type=image_type) }}"
|
|
class="d-flex gap-1">
|
|
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
|
<input type="hidden" name="filename" value="{{ orph.fileName }}">
|
|
<input type="text" class="form-control form-control-sm" name="family"
|
|
placeholder="family id (e.g. Optiplex_7060)" required style="width:11rem;">
|
|
<input type="text" class="form-control form-control-sm" name="destination_dir"
|
|
placeholder="destinationDir" required style="width:11rem;">
|
|
<button type="submit" class="btn btn-sm btn-success">Adopt</button>
|
|
</form>
|
|
</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
<div class="px-3 py-2 text-end">
|
|
<button type="submit" class="btn btn-sm btn-outline-danger">
|
|
Delete selected
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<!-- Driver upload modal -->
|
|
<div class="modal fade" id="driverUploadModal" tabindex="-1">
|
|
<div class="modal-dialog">
|
|
<form method="POST" action="{{ url_for('images_drivers_upload', image_type=image_type) }}" enctype="multipart/form-data">
|
|
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title">Upload driver .zip</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div class="mb-3">
|
|
<label class="form-label">Driver .zip file</label>
|
|
<input type="file" class="form-control" name="driver_file" accept=".zip" required>
|
|
<div class="form-text">Lands in <code>{{ image_type }}/Deploy/Out-of-box Drivers/</code>.</div>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Family ID (optional)</label>
|
|
<input type="text" class="form-control font-monospace" name="family"
|
|
placeholder="Optiplex_7060">
|
|
<div class="form-text">Matches a HardwareModelSelection.Id. Leave blank to land as orphan + adopt later.</div>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Destination directory (optional)</label>
|
|
<input type="text" class="form-control font-monospace" name="destination_dir"
|
|
placeholder="*destinationdir*\Drivers\Optiplex">
|
|
<div class="form-text">Where the .zip extracts at deploy time. Required if Family ID is set.</div>
|
|
</div>
|
|
<div class="form-check">
|
|
<input class="form-check-input" type="checkbox" name="overwrite" value="1" id="driverOverwriteCheck">
|
|
<label class="form-check-label" for="driverOverwriteCheck">Overwrite if same filename exists</label>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
<button type="submit" class="btn btn-primary">Upload</button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
{# ==================== SECTION 3: Operating Systems ==================== #}
|
|
<div class="card section-card">
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|
<span>Operating Systems
|
|
<span class="badge bg-secondary ms-1">{{ config.operating_systems|length }}</span>
|
|
</span>
|
|
<div>
|
|
<button type="button" class="btn btn-sm btn-success" id="saveOs">
|
|
Save
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="card-body p-0">
|
|
<form method="POST" action="{{ url_for('image_config_save', image_type=image_type) }}" id="osForm">
|
|
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
|
<input type="hidden" name="section" value="operating_systems">
|
|
<input type="hidden" name="payload" id="osData" value="[]">
|
|
</form>
|
|
<table class="table table-sm table-hover mb-0 config-table" id="osTable">
|
|
<thead class="table-light">
|
|
<tr>
|
|
<th style="width:40px">#</th>
|
|
<th>Product Name</th>
|
|
<th>Version</th>
|
|
<th>Build</th>
|
|
<th style="width:60px">ID</th>
|
|
<th style="width:70px">Active</th>
|
|
<th style="width:90px">WIM On Disk</th>
|
|
<th style="width:60px"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for entry in config.operating_systems %}
|
|
{% set osv = entry.operatingSystemVersion %}
|
|
<tr data-json='{{ entry | tojson }}'>
|
|
<td class="order-num">{{ loop.index }}</td>
|
|
<td>{{ osv.productName }}</td>
|
|
<td>{{ osv.versionNumber }}</td>
|
|
<td>{{ osv.buildNumber }}</td>
|
|
<td>{{ osv.id }}</td>
|
|
<td>
|
|
{% if osv.isActive %}
|
|
<span class="badge bg-success badge-disk">Active</span>
|
|
{% else %}
|
|
<span class="badge bg-secondary badge-disk">Inactive</span>
|
|
{% endif %}
|
|
</td>
|
|
<td>
|
|
{% if entry._on_disk %}
|
|
<span class="badge bg-success badge-disk">Yes</span>
|
|
{% else %}
|
|
<span class="badge bg-danger badge-disk">No</span>
|
|
{% endif %}
|
|
</td>
|
|
<td>
|
|
<button type="button" class="btn btn-outline-danger btn-row-action remove-row">
|
|
Remove
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
{% if not config.operating_systems %}
|
|
<div class="text-center text-muted py-3 empty-message" id="osEmpty">
|
|
No operating systems configured.
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
|
|
{# ==================== SECTION 4: Packages ==================== #}
|
|
<div class="card section-card">
|
|
<div class="card-header d-flex justify-content-between align-items-center">
|
|
<span>Packages
|
|
<span class="badge bg-secondary ms-1">{{ config.packages|length }}</span>
|
|
</span>
|
|
<div>
|
|
<button type="button" class="btn btn-sm btn-outline-primary"
|
|
data-bs-toggle="modal" data-bs-target="#packageUploadModal">
|
|
Upload file
|
|
</button>
|
|
<button type="button" class="btn btn-sm btn-success ms-1" id="savePackages">
|
|
Save
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="card-body p-0">
|
|
<form method="POST" action="{{ url_for('image_config_save', image_type=image_type) }}" id="packagesForm">
|
|
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
|
<input type="hidden" name="section" value="packages">
|
|
<input type="hidden" name="payload" id="packagesData" value="[]">
|
|
</form>
|
|
<div class="table-responsive">
|
|
<table class="table table-sm table-hover mb-0 config-table" id="packagesTable">
|
|
<thead class="table-light">
|
|
<tr>
|
|
<th style="width:40px">#</th>
|
|
<th>Name</th>
|
|
<th>Comment</th>
|
|
<th>File</th>
|
|
<th style="width:70px">OS IDs</th>
|
|
<th style="width:80px">Enabled</th>
|
|
<th style="width:90px">On Disk</th>
|
|
<th style="width:60px"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for pkg in config.packages %}
|
|
<tr data-json='{{ pkg | tojson }}'>
|
|
<td class="order-num">{{ loop.index }}</td>
|
|
<td class="text-truncate-cell" title="{{ pkg.name }}"><small>{{ pkg.name }}</small></td>
|
|
<td class="text-truncate-cell" title="{{ pkg.comment }}"><small>{{ pkg.comment }}</small></td>
|
|
<td class="text-truncate-cell" title="{{ pkg.fileName or pkg.get('FileName','') }}">
|
|
<small>{{ pkg.fileName or pkg.get('FileName','') }}</small>
|
|
</td>
|
|
<td><small>{{ pkg.osId }}</small></td>
|
|
<td>
|
|
{% if pkg.enabled %}
|
|
<span class="badge bg-success badge-disk">Yes</span>
|
|
{% else %}
|
|
<span class="badge bg-secondary badge-disk">No</span>
|
|
{% endif %}
|
|
</td>
|
|
<td>
|
|
{% if pkg._on_disk %}
|
|
<span class="badge bg-success badge-disk">Yes</span>
|
|
{% else %}
|
|
<span class="badge bg-danger badge-disk">No</span>
|
|
{% endif %}
|
|
</td>
|
|
<td>
|
|
<button type="button" class="btn btn-outline-danger btn-row-action remove-row">
|
|
Remove
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{% if not config.packages %}
|
|
<div class="text-center text-muted py-3 empty-message" id="packagesEmpty">
|
|
No packages configured.
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Package upload modal -->
|
|
<div class="modal fade" id="packageUploadModal" tabindex="-1">
|
|
<div class="modal-dialog">
|
|
<form method="POST" action="{{ url_for('images_packages_upload', image_type=image_type) }}" enctype="multipart/form-data">
|
|
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title">Upload package</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div class="mb-3">
|
|
<label class="form-label">Package file</label>
|
|
<input type="file" class="form-control" name="package_file" required>
|
|
<div class="form-text">Lands in <code>{{ image_type }}/Deploy/Packages/</code>.</div>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Destination directory (optional)</label>
|
|
<input type="text" class="form-control font-monospace" name="destination_dir"
|
|
placeholder="*destinationdir*\Packages">
|
|
<div class="form-text">Setting this also appends an entry to <code>packages.json</code>.</div>
|
|
</div>
|
|
<div class="form-check">
|
|
<input class="form-check-input" type="checkbox" name="overwrite" value="1" id="pkgOverwriteCheck">
|
|
<label class="form-check-label" for="pkgOverwriteCheck">Overwrite if same filename exists</label>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
<button type="submit" class="btn btn-primary">Upload</button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
{% endblock %}
|
|
|
|
{% block extra_scripts %}
|
|
<script>
|
|
// Orphan drivers: select-all checkbox toggles every row's selector.
|
|
document.addEventListener('click', function(e) {
|
|
if (e.target && e.target.id === 'orphanSelectAll') {
|
|
document.querySelectorAll('.orphan-select').forEach(function(cb) {
|
|
cb.checked = e.target.checked;
|
|
});
|
|
}
|
|
});
|
|
</script>
|
|
{% endblock %}
|