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 %}
|
||||
@@ -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 %}
|
||||
|
||||
@@ -95,7 +95,11 @@
|
||||
<span class="badge bg-secondary ms-1">{{ config.drivers|length }}</span>
|
||||
</span>
|
||||
<div>
|
||||
<button type="button" class="btn btn-sm btn-success" id="saveDrivers">
|
||||
<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>
|
||||
@@ -153,37 +157,106 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Orphan drivers sub-section #}
|
||||
{# 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">
|
||||
<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 not in any JSON</small>
|
||||
<small class="text-muted ms-1">.zip files on disk, no JSON entry</small>
|
||||
</a>
|
||||
</div>
|
||||
<div class="collapse" id="orphanDrivers">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>File Name</th>
|
||||
<th>Relative Path</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for orph in config.orphan_drivers %}
|
||||
<tr>
|
||||
<td><small>{{ orph.fileName }}</small></td>
|
||||
<td><small class="text-muted">{{ orph.relPath }}</small></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<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">
|
||||
@@ -262,7 +335,11 @@
|
||||
<span class="badge bg-secondary ms-1">{{ config.packages|length }}</span>
|
||||
</span>
|
||||
<div>
|
||||
<button type="button" class="btn btn-sm btn-success" id="savePackages">
|
||||
<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>
|
||||
@@ -328,4 +405,54 @@
|
||||
{% 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 %}
|
||||
|
||||
@@ -2,17 +2,17 @@
|
||||
{% block title %}Imaging Progress - PXE Server Manager{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
{# JS-driven refresh instead of meta http-equiv so we can cancel it while a #}
|
||||
{# LAPS-password QR is showing (otherwise the 5s reload wipes the in-page #}
|
||||
{# state every cycle). #}
|
||||
{# Tile refresh is driven by SSE (/imaging/stream) with a polling fallback. #}
|
||||
{# Replacing the full-page reload preserves scroll, filter input, expanded #}
|
||||
{# tile state, and LAPS QR input text across refreshes. #}
|
||||
<script>
|
||||
function scheduleImagingReload() {
|
||||
window._imagingReloadTimer = setTimeout(function() { location.reload(); }, 15000);
|
||||
// Polling fallback only; SSE is the primary path. Initialized in
|
||||
// imaging-refresh.js block at the bottom of the page.
|
||||
}
|
||||
function cancelImagingReload() {
|
||||
if (window._imagingReloadTimer) { clearTimeout(window._imagingReloadTimer); window._imagingReloadTimer = null; }
|
||||
if (window._imagingPollTimer) { clearTimeout(window._imagingPollTimer); window._imagingPollTimer = null; }
|
||||
}
|
||||
window.addEventListener('DOMContentLoaded', scheduleImagingReload);
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -21,10 +21,11 @@ window.addEventListener('DOMContentLoaded', scheduleImagingReload);
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<div>
|
||||
<h2 class="mb-0">Imaging Progress</h2>
|
||||
<small class="text-muted">Auto-refresh 15s. POST updates from imaging clients arrive at <code>/imaging/status</code>.</small>
|
||||
<small class="text-muted">Live via SSE (15s polling fallback). Client pushes -> <code>/imaging/status</code>; log-inferred bays in yellow.</small>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="badge bg-secondary fs-6"><span id="visible-count">{{ sessions|length }}</span>/{{ sessions|length }}</span>
|
||||
<span id="imaging-live-dot" class="status-dot" title="live stream" style="background-color:#6c757d;"></span>
|
||||
<span class="badge bg-secondary fs-6"><span id="visible-count">{{ sessions|length }}</span>/<span id="total-count">{{ sessions|length }}</span></span>
|
||||
{% if sessions %}
|
||||
<form method="post" action="{{ url_for('imaging_delete_all') }}"
|
||||
onsubmit="return confirm('Clear all {{ sessions|length }} imaging session(s)? This wipes every tile from the dashboard. Live re-images will repopulate on next status push.');"
|
||||
@@ -38,172 +39,24 @@ window.addEventListener('DOMContentLoaded', scheduleImagingReload);
|
||||
|
||||
<div class="mb-3">
|
||||
<input id="imaging-search" type="search" class="form-control form-control-sm"
|
||||
placeholder="Filter by serial, hostname, pctype, machine#, Intune id, stage name, stage-N, status - typing pauses auto-refresh"
|
||||
placeholder="Filter by serial, hostname, pctype, machine#, Intune id, MAC, IP, stage name, stage-N, status, source (client|inferred)"
|
||||
autocomplete="off">
|
||||
</div>
|
||||
|
||||
{% if not sessions %}
|
||||
<div class="card">
|
||||
<div id="imaging-empty" class="card">
|
||||
<div class="card-body text-center text-muted py-5">
|
||||
<p class="mb-1">No imaging sessions yet.</p>
|
||||
<p class="small mb-0">A PC being imaged will post status here.
|
||||
Until then, nothing to show.</p>
|
||||
<p class="small mb-0">A PC being imaged will post status here, or appear
|
||||
automatically once it touches DHCP / TFTP / boot.wim.</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% 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.')
|
||||
} %}
|
||||
<div id="imaging-tiles">
|
||||
{% include "_imaging_tiles.html" %}
|
||||
</div>
|
||||
|
||||
{% for s in sessions %}
|
||||
{% 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' %}
|
||||
{% set border = 'danger' if is_failed else ('success' if is_done else 'primary') %}
|
||||
{% set friendly = stage_labels.get(stage_idx, ('Stage ' ~ stage_idx, '')) %}
|
||||
{# Stage 1 sub-phase: if WinPE pushed a BIOS update stage string, #}
|
||||
{# show a BIOS-specific friendly label. Otherwise default idx=1. #}
|
||||
{% 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 %}
|
||||
{# Stage 7 fans out by sub-phase. Monitor pushes different stage #}
|
||||
{# strings as it crosses each Phase 1-4 boundary. Swap friendly #}
|
||||
{# label based on which keyword shows up. #}
|
||||
{% 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 %}
|
||||
<details class="card border-{{ border }} mb-2 shadow-sm imaging-card"
|
||||
data-serial="{{ s.serial }}"
|
||||
data-filter="{{ s.serial|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 }}">
|
||||
<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;">
|
||||
no DeviceId
|
||||
</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">{{ s.serial or '(no serial)' }}</strong>
|
||||
{% if s.hostname_target %}<code class="text-muted small">{{ s.hostname_target }}</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 %}
|
||||
<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>
|
||||
<span class="badge bg-secondary ms-1">{{ stage_idx }}/{{ stage_total or '?' }}</span>
|
||||
</div>
|
||||
<span class="text-muted small">{{ pct }}%</span>
|
||||
</div>
|
||||
<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>
|
||||
</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 %}
|
||||
|
||||
<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>
|
||||
|
||||
{% if s.log_tail %}
|
||||
<details class="mt-3">
|
||||
<summary class="text-muted small">Log tail ({{ 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 %}
|
||||
|
||||
<div class="mt-3 text-end">
|
||||
<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>
|
||||
</div>
|
||||
</details>
|
||||
{% endfor %}
|
||||
|
||||
<div class="card mt-3">
|
||||
<div class="card-body small text-muted">
|
||||
@@ -230,6 +83,104 @@ Content-Type: application/json
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
// -------- Live refresh: SSE primary, polling fallback --------
|
||||
// Rebuilds the #imaging-tiles inner HTML from /imaging/tiles when the
|
||||
// server signals a state change. Preserves scroll, filter input value,
|
||||
// and any LAPS input that the operator is actively editing.
|
||||
(function() {
|
||||
var TILES_URL = "{{ url_for('imaging_tiles_partial') }}";
|
||||
var STREAM_URL = "{{ url_for('imaging_stream') }}";
|
||||
var POLL_MS = 15000;
|
||||
var lastHash = null;
|
||||
var sse = null;
|
||||
var dot = function() { return document.getElementById('imaging-live-dot'); };
|
||||
|
||||
function setDot(color, title) {
|
||||
var d = dot();
|
||||
if (d) { d.style.backgroundColor = color; d.title = title || ''; }
|
||||
}
|
||||
|
||||
function lapsInputIsDirty() {
|
||||
// Skip the tile swap if any LAPS input is focused (operator is
|
||||
// mid-paste) OR has unsaved text that differs from the server-side
|
||||
// copy. The next refresh after they hit Make-QR will catch up.
|
||||
var active = document.activeElement;
|
||||
if (active && active.classList && active.classList.contains('laps-input')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function refreshTiles(force) {
|
||||
if (!force && lapsInputIsDirty()) return;
|
||||
fetch(TILES_URL, { credentials: 'same-origin' })
|
||||
.then(function(r) { return r.text(); })
|
||||
.then(function(html) {
|
||||
var container = document.getElementById('imaging-tiles');
|
||||
if (!container) return;
|
||||
container.innerHTML = html;
|
||||
if (typeof window.imagingPostSwapHooks === 'function') {
|
||||
window.imagingPostSwapHooks();
|
||||
}
|
||||
})
|
||||
.catch(function(err) { console.error('refreshTiles failed:', err); });
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
if (window._imagingPollTimer) return;
|
||||
window._imagingPollTimer = setInterval(function() {
|
||||
refreshTiles(false);
|
||||
}, POLL_MS);
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (window._imagingPollTimer) {
|
||||
clearInterval(window._imagingPollTimer);
|
||||
window._imagingPollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function startSSE() {
|
||||
if (!window.EventSource) {
|
||||
setDot('#ffc107', 'EventSource unsupported - polling only');
|
||||
startPolling();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
sse = new EventSource(STREAM_URL);
|
||||
} catch (e) {
|
||||
setDot('#dc3545', 'SSE failed - polling');
|
||||
startPolling();
|
||||
return;
|
||||
}
|
||||
sse.onopen = function() {
|
||||
setDot('#198754', 'live stream connected');
|
||||
stopPolling();
|
||||
};
|
||||
sse.onmessage = function(ev) {
|
||||
var data;
|
||||
try { data = JSON.parse(ev.data); } catch (e) { return; }
|
||||
if (!data || data.hash === lastHash) return;
|
||||
lastHash = data.hash;
|
||||
refreshTiles(false);
|
||||
};
|
||||
sse.onerror = function() {
|
||||
setDot('#dc3545', 'live stream lost - polling fallback');
|
||||
try { sse.close(); } catch (e) {}
|
||||
sse = null;
|
||||
startPolling();
|
||||
// Try to reconnect SSE after a backoff.
|
||||
setTimeout(startSSE, 10000);
|
||||
};
|
||||
}
|
||||
|
||||
// Expose so external code (LAPS, filter) can trigger an immediate
|
||||
// refresh after user action.
|
||||
window.imagingRefreshNow = function() { refreshTiles(true); };
|
||||
|
||||
window.addEventListener('DOMContentLoaded', function() {
|
||||
startSSE();
|
||||
});
|
||||
})();
|
||||
|
||||
function copyText(text) {
|
||||
// Modern path - only works over HTTPS or localhost
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
@@ -362,67 +313,87 @@ document.addEventListener('keydown', function(e) {
|
||||
if (card) { e.preventDefault(); renderLapsQR(card); }
|
||||
}
|
||||
});
|
||||
// On page load, any laps-card with a pre-populated input (from
|
||||
// server-persisted laps_password) auto-renders its QR without re-POSTing.
|
||||
window.addEventListener('DOMContentLoaded', function() {
|
||||
document.querySelectorAll('.laps-card').forEach(function(card) {
|
||||
var input = card.querySelector('.laps-input');
|
||||
if (input && input.value) renderLapsQR(card, { skipPersist: true });
|
||||
});
|
||||
});
|
||||
|
||||
// Persist tile expanded/collapsed state across page refresh via
|
||||
// localStorage. Set of expanded serials lives at 'imaging-expanded'.
|
||||
// Per-tile hooks that must re-run after every tile-swap. Called on
|
||||
// DOMContentLoaded for first paint, then by the SSE/polling refresh after
|
||||
// it replaces the innerHTML of #imaging-tiles.
|
||||
(function() {
|
||||
var KEY = 'imaging-expanded';
|
||||
function loadSet() {
|
||||
try { return new Set(JSON.parse(localStorage.getItem(KEY) || '[]')); }
|
||||
var EXPANDED_KEY = 'imaging-expanded';
|
||||
function loadExpandedSet() {
|
||||
try { return new Set(JSON.parse(localStorage.getItem(EXPANDED_KEY) || '[]')); }
|
||||
catch (e) { return new Set(); }
|
||||
}
|
||||
function saveSet(set) {
|
||||
try { localStorage.setItem(KEY, JSON.stringify(Array.from(set))); }
|
||||
function saveExpandedSet(set) {
|
||||
try { localStorage.setItem(EXPANDED_KEY, JSON.stringify(Array.from(set))); }
|
||||
catch (e) {}
|
||||
}
|
||||
window.addEventListener('DOMContentLoaded', function() {
|
||||
var expanded = loadSet();
|
||||
document.querySelectorAll('.imaging-card').forEach(function(card) {
|
||||
var serial = card.getAttribute('data-serial');
|
||||
if (serial && expanded.has(serial)) card.open = true;
|
||||
card.addEventListener('toggle', function() {
|
||||
var s = loadSet();
|
||||
if (card.open) s.add(serial); else s.delete(serial);
|
||||
saveSet(s);
|
||||
});
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
// Client-side filter: hide imaging-card elements whose data-filter doesn't
|
||||
// match the search query. Live as user types. Pauses auto-reload while
|
||||
// the input is focused or non-empty so typing isn't interrupted by refresh.
|
||||
window.addEventListener('DOMContentLoaded', function() {
|
||||
var search = document.getElementById('imaging-search');
|
||||
var counter = document.getElementById('visible-count');
|
||||
if (!search) return;
|
||||
function applyFilter() {
|
||||
var q = search.value.trim().toLowerCase();
|
||||
var visible = 0;
|
||||
function restoreExpandedState() {
|
||||
var expanded = loadExpandedSet();
|
||||
document.querySelectorAll('.imaging-card').forEach(function(card) {
|
||||
var serial = card.getAttribute('data-serial') || card.getAttribute('data-key');
|
||||
if (serial && expanded.has(serial)) card.open = true;
|
||||
if (!card._toggleBound) {
|
||||
card.addEventListener('toggle', function() {
|
||||
var s = loadExpandedSet();
|
||||
if (card.open) s.add(serial); else s.delete(serial);
|
||||
saveExpandedSet(s);
|
||||
});
|
||||
card._toggleBound = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function autoRenderLapsQRs() {
|
||||
document.querySelectorAll('.laps-card').forEach(function(card) {
|
||||
var input = card.querySelector('.laps-input');
|
||||
var container = card.querySelector('.laps-qr-container');
|
||||
if (input && input.value && container && !container.innerHTML.trim()) {
|
||||
renderLapsQR(card, { skipPersist: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderIntuneQRs() {
|
||||
// qr-render.js looks for [data-qr] and renders an image. It runs on
|
||||
// initial DOMContentLoaded but not after a tile-swap. Re-run if the
|
||||
// hook is exposed; otherwise no-op.
|
||||
if (typeof window.renderAllQRs === 'function') window.renderAllQRs();
|
||||
}
|
||||
|
||||
function applyFilter() {
|
||||
var search = document.getElementById('imaging-search');
|
||||
var counter = document.getElementById('visible-count');
|
||||
var total = document.getElementById('total-count');
|
||||
if (!search) return;
|
||||
var q = search.value.trim().toLowerCase();
|
||||
var visible = 0, totalN = 0;
|
||||
document.querySelectorAll('.imaging-card').forEach(function(card) {
|
||||
totalN++;
|
||||
var hay = card.getAttribute('data-filter') || '';
|
||||
var match = (q === '') || hay.indexOf(q) !== -1;
|
||||
card.style.display = match ? '' : 'none';
|
||||
if (match) visible++;
|
||||
});
|
||||
if (counter) counter.textContent = visible;
|
||||
if (q !== '') { cancelImagingReload(); }
|
||||
else { cancelImagingReload(); scheduleImagingReload(); }
|
||||
if (total) total.textContent = totalN;
|
||||
}
|
||||
search.addEventListener('input', applyFilter);
|
||||
search.addEventListener('focus', cancelImagingReload);
|
||||
search.addEventListener('blur', function() {
|
||||
if (search.value.trim() === '') { cancelImagingReload(); scheduleImagingReload(); }
|
||||
|
||||
window.imagingPostSwapHooks = function() {
|
||||
restoreExpandedState();
|
||||
autoRenderLapsQRs();
|
||||
renderIntuneQRs();
|
||||
applyFilter();
|
||||
};
|
||||
|
||||
window.addEventListener('DOMContentLoaded', function() {
|
||||
// Search input is rendered outside #imaging-tiles, so its listeners
|
||||
// only bind once.
|
||||
var search = document.getElementById('imaging-search');
|
||||
if (search) {
|
||||
search.addEventListener('input', applyFilter);
|
||||
}
|
||||
window.imagingPostSwapHooks();
|
||||
});
|
||||
applyFilter();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
191
webapp/templates/imaging_detail.html
Normal file
191
webapp/templates/imaging_detail.html
Normal file
@@ -0,0 +1,191 @@
|
||||
{% 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 %}
|
||||
Reference in New Issue
Block a user