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>
This commit is contained in:
@@ -43,8 +43,11 @@
|
||||
|
||||
<!-- Images -->
|
||||
<div class="card">
|
||||
<div class="card-header d-flex align-items-center">
|
||||
Deployment Images
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span>Deployment Images</span>
|
||||
<button type="button" class="btn btn-sm btn-success" data-bs-toggle="modal" data-bs-target="#newImageModal">
|
||||
+ New image type
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-hover mb-0">
|
||||
@@ -85,9 +88,21 @@
|
||||
Config
|
||||
</a>
|
||||
<a href="{{ url_for('unattend_editor', image_type=img.image_type) }}"
|
||||
class="btn btn-sm btn-outline-primary">
|
||||
Edit Unattend
|
||||
class="btn btn-sm btn-outline-primary me-1">
|
||||
Unattend
|
||||
</a>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary me-1"
|
||||
data-bs-toggle="modal" data-bs-target="#cloneImageModal"
|
||||
data-src-key="{{ img.image_type }}"
|
||||
data-src-friendly="{{ img.friendly_name }}">
|
||||
Clone
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger"
|
||||
data-bs-toggle="modal" data-bs-target="#deleteImageModal"
|
||||
data-src-key="{{ img.image_type }}"
|
||||
data-src-friendly="{{ img.friendly_name }}">
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
@@ -95,4 +110,132 @@
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New image type modal -->
|
||||
<div class="modal fade" id="newImageModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<form method="post" action="{{ url_for('images_new') }}">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Create image type</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">Key</label>
|
||||
<input type="text" class="form-control font-monospace" name="key"
|
||||
pattern="[a-z][a-z0-9-]{1,63}" required
|
||||
placeholder="gea-shopfloor-newtype">
|
||||
<div class="form-text">Lowercase + hyphens. Used as directory name + URL path. 2-64 chars, must start with a letter.</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Friendly name</label>
|
||||
<input type="text" class="form-control" name="friendly_name" required
|
||||
placeholder="GE Aerospace Shop Floor (newtype)">
|
||||
</div>
|
||||
<div class="alert alert-info small">
|
||||
Empty image type. Populate via Image Import or Clone afterwards.
|
||||
</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-success">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Clone image modal -->
|
||||
<div class="modal fade" id="cloneImageModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<form method="post" id="cloneForm">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Clone image type</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Source: <strong id="cloneSrcFriendly"></strong> <code id="cloneSrcKey"></code></p>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">New key</label>
|
||||
<input type="text" class="form-control font-monospace" name="dst_key"
|
||||
pattern="[a-z][a-z0-9-]{1,63}" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Friendly name (optional)</label>
|
||||
<input type="text" class="form-control" name="friendly_name"
|
||||
placeholder="leave blank for ‘<src> (copy)’">
|
||||
</div>
|
||||
<div class="alert alert-info small">
|
||||
Copies Deploy/ tree (Control + Tools + unattend) and preserves
|
||||
symlinks to shared dirs (Out-of-box Drivers, Operating Systems,
|
||||
Packages). Disk usage stays low because shared content is not
|
||||
duplicated.
|
||||
</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-success">Clone</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete image modal -->
|
||||
<div class="modal fade" id="deleteImageModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<form method="post" id="deleteForm">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Delete image type</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Remove <strong id="deleteSrcFriendly"></strong> <code id="deleteSrcKey"></code> from the registry.</p>
|
||||
<div class="form-check mb-3">
|
||||
<input class="form-check-input" type="checkbox" name="delete_content" value="1" id="deleteContentCheck">
|
||||
<label class="form-check-label" for="deleteContentCheck">
|
||||
Also wipe on-disk Deploy/Tools/etc (symlinked shared dirs are unlinked, not followed)
|
||||
</label>
|
||||
</div>
|
||||
<div class="alert alert-warning small">
|
||||
Removing from registry hides the image from the UI and Ansible
|
||||
playbook list. Existing PXE-imaged clients are unaffected. Wiping
|
||||
content is irreversible.
|
||||
</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-danger">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
document.addEventListener('show.bs.modal', function(e) {
|
||||
var btn = e.relatedTarget;
|
||||
if (!btn) return;
|
||||
var srcKey = btn.getAttribute('data-src-key') || '';
|
||||
var srcFriendly = btn.getAttribute('data-src-friendly') || '';
|
||||
if (e.target.id === 'cloneImageModal') {
|
||||
e.target.querySelector('#cloneSrcKey').textContent = srcKey;
|
||||
e.target.querySelector('#cloneSrcFriendly').textContent = srcFriendly;
|
||||
document.getElementById('cloneForm').action = '/images/' + encodeURIComponent(srcKey) + '/clone';
|
||||
} else if (e.target.id === 'deleteImageModal') {
|
||||
e.target.querySelector('#deleteSrcKey').textContent = srcKey;
|
||||
e.target.querySelector('#deleteSrcFriendly').textContent = srcFriendly;
|
||||
document.getElementById('deleteForm').action = '/images/' + encodeURIComponent(srcKey) + '/delete';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user