Files
pxe-server/webapp/templates/image_config.html
cproudlock 69a1682a7f webapp: imaging UX overhaul + image management CRUD
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>
2026-05-30 13:21:06 -04:00

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
&mdash; 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 %}