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:
192
webapp/templates/_imaging_tiles.html
Normal file
192
webapp/templates/_imaging_tiles.html
Normal file
@@ -0,0 +1,192 @@
|
||||
{# Per-bay tiles. Rendered standalone by /imaging/tiles for SSE refresh, and
|
||||
included by imaging.html for first paint. Receives `sessions`. #}
|
||||
{% set stage_labels = {
|
||||
1: ('Booting from PXE', 'WinPE loaded - applying Windows image to disk.'),
|
||||
2: ('Configuring Windows', 'First boot. Running shopfloor setup baseline scripts.'),
|
||||
3: ('Installing apps', 'Type-specific app installs (eDNC, UDC, NTLARS, etc).'),
|
||||
4: ('Apps installed', 'Type-specific scripts complete. Preparing for Intune enrollment.'),
|
||||
5: ('Enrolling in Intune', 'PPKG installing - device joining Azure AD + Intune. ~5-10 min, reboot to follow.'),
|
||||
6: ('Waiting on first Intune sync','Post-PPKG settle (~120s). Triggering Schedule #3 sync repeatedly.'),
|
||||
7: ('Registered - assign category',
|
||||
'Phase 1 (Intune Registration) complete. Click "set category" in the Intune portal to drop the bay into the right config-profile group.'),
|
||||
8: ('Imaging complete',
|
||||
'Lockdown applied. Bay rebooted into ShopFloor session. Ready for production.')
|
||||
} %}
|
||||
|
||||
{% set inferred_stage_labels = {
|
||||
'stage_0_dhcp': ('DHCP lease issued', 'Client got a DHCP lease but has not fetched the bootloader yet.'),
|
||||
'stage_0_tftp_bios': ('TFTP bootloader (BIOS)','Client fetched undionly.kpxe via TFTP. Booting iPXE.'),
|
||||
'stage_0_tftp_uefi': ('TFTP bootloader (UEFI)','Client fetched ipxe.efi via TFTP. Booting iPXE.'),
|
||||
'stage_0_menu': ('Boot menu loaded', 'Client loaded the iPXE menu. Waiting on selection or auto-boot.'),
|
||||
'stage_0_boot_pre': ('Pre-boot fetch', 'Client downloading WinPE boot files from HTTP.'),
|
||||
'stage_1_wim_get': ('Downloading boot.wim', 'Client pulling WinPE image. No /imaging/status push yet.'),
|
||||
'stage_2_smb_winpe': ('WinPE running (no push)','Client has opened SMB shares - WinPE is up but startnet has not pushed status.'),
|
||||
} %}
|
||||
|
||||
{% for s in sessions %}
|
||||
{% set is_inferred = (s.source == 'inferred') %}
|
||||
{% 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' %}
|
||||
{% if is_inferred %}
|
||||
{% set border = 'warning' %}
|
||||
{% else %}
|
||||
{% set border = 'danger' if is_failed else ('success' if is_done else 'primary') %}
|
||||
{% endif %}
|
||||
{% if is_inferred %}
|
||||
{% set friendly = inferred_stage_labels.get(s.current_stage, ('Inferred from logs', s.current_stage or '')) %}
|
||||
{% else %}
|
||||
{% set friendly = stage_labels.get(stage_idx, ('Stage ' ~ stage_idx, '')) %}
|
||||
{% if stage_idx == 1 and s.current_stage and 'bios' in s.current_stage|lower %}
|
||||
{% set friendly = ('Updating BIOS firmware',
|
||||
'WinPE detected a firmware update for this model. Do NOT power off until the next reboot. Imaging continues afterward.') %}
|
||||
{% endif %}
|
||||
{% if stage_idx == 7 and s.current_stage %}
|
||||
{% set _cs = s.current_stage|lower %}
|
||||
{% if 'ready for lockdown' in _cs or 'request lockdown' in _cs %}
|
||||
{% set friendly = ('Ready - request lockdown',
|
||||
'Phase 1-4 all complete (Registration, Device Config, Software Deploy, Credentials). Click "ARTS request" to initiate the lockdown workflow.') %}
|
||||
{% elif 'credentials' in _cs or 'phase 4' in _cs %}
|
||||
{% set friendly = ('Phase 3 / 4 - DSC + credentials',
|
||||
'SFLD policy delivered, DSC pulling device-config.yaml + running per-app wrappers. SFLD share creds populating.') %}
|
||||
{% elif 'sfld policy' in _cs or 'phase 2' in _cs or 'device configuration' in _cs %}
|
||||
{% set friendly = ('Phase 2 - device configuration',
|
||||
'Category was assigned in Intune. SFLD ConfigurationProfile (Function + SasToken) has delivered. DSC kicking off next.') %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% set card_key = s.serial or ('mac-' ~ (s.mac or 'unknown') ~ '-' ~ (s.ip or '')) %}
|
||||
<details class="card border-{{ border }} mb-2 shadow-sm imaging-card{% if is_inferred %} imaging-inferred{% endif %}"
|
||||
data-serial="{{ s.serial or '' }}"
|
||||
data-key="{{ card_key }}"
|
||||
data-mac="{{ s.mac or '' }}"
|
||||
data-ip="{{ s.ip or '' }}"
|
||||
data-source="{{ s.source or 'client' }}"
|
||||
data-filter="{{ (s.serial or '')|lower }} {{ (s.hostname_target or '')|lower }} {{ (s.pctype or '')|lower }} {{ (s.machinenumber or '')|lower }} {{ (s.intune_device_id or '')|lower }} {{ friendly[0]|lower }} stage-{{ stage_idx }} {{ (s.status or 'in_progress')|lower }} {{ (s.mac or '')|lower }} {{ (s.ip or '') }} {{ s.source or 'client' }}">
|
||||
<summary class="card-body py-2" style="cursor:pointer; list-style:none;">
|
||||
<div class="d-flex flex-wrap gap-3 align-items-center">
|
||||
{% if s.intune_device_id %}
|
||||
<div data-qr="{{ s.intune_device_id }}" data-qr-size="96" data-qr-ec="M"
|
||||
style="line-height:0; flex-shrink:0;"
|
||||
title="Intune Device ID: {{ s.intune_device_id }}"></div>
|
||||
{% else %}
|
||||
<div class="d-flex align-items-center justify-content-center bg-light text-muted small"
|
||||
style="width:96px; height:96px; border-radius:0.25rem; flex-shrink:0; text-align:center; padding:0.25rem;">
|
||||
{% if is_inferred %}log-only{% else %}no DeviceId{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="flex-grow-1" style="min-width:0;">
|
||||
<div class="d-flex flex-wrap align-items-center gap-2">
|
||||
<strong class="fs-6">
|
||||
{% if s.serial %}{{ s.serial }}
|
||||
{% elif s.hostname_target %}{{ s.hostname_target }}
|
||||
{% elif s.mac %}{{ s.mac }}
|
||||
{% else %}{{ s.ip or '(unknown bay)' }}{% endif %}
|
||||
</strong>
|
||||
{% if s.hostname_target and s.serial %}<code class="text-muted small">{{ s.hostname_target }}</code>{% endif %}
|
||||
{% if s.ip %}<code class="text-muted small">{{ s.ip }}</code>{% endif %}
|
||||
{% if s.pctype %}<span class="badge bg-info text-dark">{{ s.pctype }}</span>{% endif %}
|
||||
{% if s.machinenumber %}<span class="badge bg-secondary">#{{ s.machinenumber }}</span>{% endif %}
|
||||
{% if is_inferred %}<span class="badge bg-warning text-dark">log-inferred</span>{% endif %}
|
||||
<span class="badge bg-{{ border }} ms-auto">{{ s.status or 'in_progress' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-baseline mt-1">
|
||||
<div>
|
||||
<strong>{{ friendly[0] }}</strong>
|
||||
{% if not is_inferred %}
|
||||
<span class="badge bg-secondary ms-1">{{ stage_idx }}/{{ stage_total or '?' }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if not is_inferred %}<span class="text-muted small">{{ pct }}%</span>{% endif %}
|
||||
</div>
|
||||
{% if not is_inferred %}
|
||||
<div class="progress mt-1" style="height:0.7rem;">
|
||||
<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"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</summary>
|
||||
|
||||
<div class="card-body pt-0 pb-3 border-top">
|
||||
{% if friendly[1] %}<div class="small text-muted mt-2">{{ friendly[1] }}</div>{% endif %}
|
||||
|
||||
{% if s.intune_device_id %}
|
||||
<div class="small mt-2" style="font-size:0.75rem;">
|
||||
<span class="text-muted">Intune:</span> <code>{{ s.intune_device_id }}</code>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary py-0 px-1 copy-btn"
|
||||
style="font-size:0.65rem; 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"
|
||||
style="font-size:0.65rem; 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>
|
||||
<a class="btn btn-sm btn-outline-warning py-0 px-1"
|
||||
style="font-size:0.65rem; line-height:1;"
|
||||
target="_blank" rel="noopener"
|
||||
href="https://arts.dw.geaerospace.net/requests/type">ARTS request</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="text-muted mt-1" style="font-size:0.7rem;">
|
||||
<span class="me-3">started <code>{{ s.started_at or '-' }}</code></span>
|
||||
<span class="me-3">last <code>{{ s.last_updated or '-' }}</code></span>
|
||||
<span class="me-3">MAC <code>{{ s.mac or '-' }}</code></span>
|
||||
{% if s.current_stage %}<span style="font-family:monospace;">{{ s.current_stage }}</span>{% endif %}
|
||||
</div>
|
||||
|
||||
{% if s.error %}
|
||||
<div class="alert alert-danger small py-2 mb-2 mt-3">
|
||||
<strong>Error:</strong> {{ s.error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not is_inferred %}
|
||||
<div class="mt-3 laps-card" data-serial="{{ s.serial }}">
|
||||
<div class="text-muted small mb-1">LAPS password QR (paste -> scan on bay - persists until cleared)</div>
|
||||
<div class="d-flex align-items-center gap-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"
|
||||
value="{{ s.laps_password or '' }}">
|
||||
<button type="button" class="btn btn-sm btn-primary laps-make-btn">{% if s.laps_password %}Update QR{% else %}Make QR{% endif %}</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary laps-clear-btn" {% if not s.laps_password %}style="display:none;"{% endif %}>Clear</button>
|
||||
</div>
|
||||
<div class="laps-qr-container mt-2"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if s.log_tail %}
|
||||
<details class="mt-3">
|
||||
<summary class="text-muted small">{% if is_inferred %}Evidence{% else %}Log tail{% endif %} ({{ 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 %}
|
||||
|
||||
{% if not is_inferred and s.serial %}
|
||||
<div class="mt-3 d-flex justify-content-between align-items-center">
|
||||
<a href="{{ url_for('imaging_session_detail', serial=s.serial) }}"
|
||||
class="btn btn-sm btn-outline-primary">
|
||||
Details / full log
|
||||
</a>
|
||||
<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>
|
||||
{% endif %}
|
||||
</div>
|
||||
</details>
|
||||
{% endfor %}
|
||||
Reference in New Issue
Block a user