Webapp redesign phase 2: Overview and imaging floor

Imaging floor (/imaging, /imaging/tiles):
- One line per PC: status chip, serial with machine number, hostname
  and IP, stage text with a segmented stage track, one action plus a
  '...' menu. Everything else (description, error, timestamps, Intune
  ID + QR, Set category / ARTS links, LAPS QR, log tail) is in the row's
  expandable body.
- Order: failed, needs action (stage 7), in progress, done; log-only
  bays fold into one collapsed group. imaging_status.floor_state() and
  floor_order() compute this.
- Status filter buttons with live counts next to the text filter. SSE
  refresh, expanded-row memory and LAPS handling are unchanged.
- Stage text is never truncated; BIOS updates and erasing are bold.

Overview (/):
- Services popover, technician to-do line, the six most urgent floor
  rows (?limit=6 partial, refreshed every 15 s), and image cards with
  lint warnings, map errors and missing zips as separate chips. New,
  clone, delete and unattend stay on the cards' menus.

Copy buttons are now handled once in static/app.js, with a fallback for
plain-http pages where navigator.clipboard is unavailable.
This commit is contained in:
cproudlock
2026-10-14 13:44:07 -04:00
parent 35cad62733
commit 0b86255711
8 changed files with 427 additions and 422 deletions

View File

@@ -103,13 +103,30 @@ app.register_blueprint(images_unattend_views.bp) # Unattend review/save/undo
@app.route("/") @app.route("/")
def dashboard(): def dashboard():
image_list = [images.image_status(it) for it in config.IMAGE_TYPES] image_list = [images.image_status(it) for it in config.IMAGE_TYPES]
for img in image_list:
# Card details: OS, driver entries and lint, from the image pages.
try:
info = image_pages.header_info(img["image_type"])
img["os_label"] = info["os_label"]
img["driver_count"] = info["driver_count"]
except Exception:
img["os_label"], img["driver_count"] = "", 0
img["lint"] = image_pages._lint_counts(img["image_type"])
services = [system.service_status(s) for s in ("dnsmasq", "apache2", "smbd")] services = [system.service_status(s) for s in ("dnsmasq", "apache2", "smbd")]
sessions, _hidden = imaging_status.hide_stale(imaging_status.list_sessions())
floor = imaging_status.floor_order(sessions)
real = [x for x in floor if x["floor_state"] != "logs"]
counts = {k: sum(1 for x in real if x["floor_state"] == k) for k in ("failed", "action", "running", "done")}
return render_template( return render_template(
"dashboard.html", "dashboard.html",
images=image_list, images=image_list,
services=services, services=services,
image_types=config.IMAGE_TYPES, image_types=config.IMAGE_TYPES,
friendly_names=config.FRIENDLY_NAMES, friendly_names=config.FRIENDLY_NAMES,
sessions=real[:6],
more=max(0, len(real) - 6),
floor_counts=counts,
compact=True,
) )
@@ -845,7 +862,7 @@ def imaging_dashboard():
hidden = 0 hidden = 0
if not show_all: if not show_all:
sessions, hidden = imaging_status.hide_stale(sessions) sessions, hidden = imaging_status.hide_stale(sessions)
return render_template("imaging.html", sessions=sessions, hidden=hidden, return render_template("imaging.html", sessions=imaging_status.floor_order(sessions), hidden=hidden,
show_all=show_all, window_hours=imaging_status.ACTIVE_WINDOW_HOURS) show_all=show_all, window_hours=imaging_status.ACTIVE_WINDOW_HOURS)
@@ -857,7 +874,15 @@ def imaging_tiles_partial():
sessions = imaging_status.list_sessions() sessions = imaging_status.list_sessions()
if request.args.get("all") != "1": if request.args.get("all") != "1":
sessions, _hidden = imaging_status.hide_stale(sessions) sessions, _hidden = imaging_status.hide_stale(sessions)
return render_template("_imaging_tiles.html", sessions=sessions) sessions = imaging_status.floor_order(sessions)
# ?limit=N (the Overview): the first N PCs, log-only bays left out.
limit = request.args.get("limit", type=int)
more = 0
if limit:
real = [x for x in sessions if x["floor_state"] != "logs"]
more = max(0, len(real) - limit)
sessions = real[:limit]
return render_template("_imaging_tiles.html", sessions=sessions, more=more, compact=bool(limit))
def _sessions_hash() -> str: def _sessions_hash() -> str:

View File

@@ -243,6 +243,42 @@ def hide_stale(sessions: list[dict], hours: float = ACTIVE_WINDOW_HOURS) -> tupl
return kept, hidden return kept, hidden
# Imaging floor order: what needs a person first, then live work, then done.
FLOOR_RANK = {"failed": 0, "action": 1, "running": 2, "done": 3, "logs": 4}
def floor_state(s: dict) -> str:
"""One of failed / action / running / done / logs for the floor list.
"action" is stage 7 of a Windows imaging run (registered in Intune and
waiting for someone to assign the device category or request lockdown).
"""
if s.get("source") == "inferred":
return "logs"
status = s.get("status") or "in_progress"
if status == "failed":
return "failed"
if status == "succeeded":
return "done"
blancco = s.get("source") == "blancco" or s.get("pctype") == "blancco"
try:
idx = int(s.get("stage_index") or 0)
except (TypeError, ValueError):
idx = 0
if not blancco and idx == 7:
return "action"
return "running"
def floor_order(sessions: list[dict]) -> list[dict]:
"""Sessions sorted for the floor, each tagged with "floor_state"."""
for s in sessions:
s["floor_state"] = floor_state(s)
# Stable sorts: newest first within each state.
out = sorted(sessions, key=lambda s: s.get("last_updated") or "", reverse=True)
return sorted(out, key=lambda s: FLOOR_RANK[s["floor_state"]])
def get_session(serial: str) -> Optional[dict]: def get_session(serial: str) -> Optional[dict]:
path = _path_for(serial) path = _path_for(serial)
if not os.path.isfile(path): if not os.path.isfile(path):

View File

@@ -467,3 +467,30 @@ document.addEventListener('DOMContentLoaded', function () {
}); });
// Copy buttons: any .copy-btn[data-copy-text], on every page. The LAN is
// plain http, so navigator.clipboard is often unavailable; fall back to a
// hidden textarea + execCommand. The button briefly reads "Copied".
document.addEventListener('click', function (e) {
var btn = e.target.closest && e.target.closest('.copy-btn');
if (!btn) return;
var text = btn.getAttribute('data-copy-text') || '';
if (!text) return;
var done = function (ok) {
var orig = btn.getAttribute('data-orig-label') || btn.textContent;
btn.setAttribute('data-orig-label', orig);
btn.textContent = ok ? 'Copied' : 'Copy failed';
setTimeout(function () { btn.textContent = orig; }, 1400);
};
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(function () { done(true); }, function () { done(false); });
return;
}
var ta = document.createElement('textarea');
ta.value = text; ta.setAttribute('readonly', ''); ta.style.position = 'fixed'; ta.style.opacity = '0';
document.body.appendChild(ta); ta.select();
var ok = false;
try { ok = document.execCommand('copy'); } catch (err) { ok = false; }
document.body.removeChild(ta);
done(ok);
});

View File

@@ -237,3 +237,43 @@ details.more .menu form { margin: 0; }
/* Flash messages restyled to the token palette (Bootstrap markup kept). */ /* Flash messages restyled to the token palette (Bootstrap markup kept). */
.ui-main > .alert { border-radius: 10px; border: 0; box-shadow: 0 1px 2px var(--shadow); } .ui-main > .alert { border-radius: 10px; border: 0; box-shadow: 0 1px 2px var(--shadow); }
/* --------------------------------------------------------------------------
Imaging floor rows (_imaging_tiles.html), used by /imaging and Overview.
-------------------------------------------------------------------------- */
.floor { container-type: inline-size; }
.frow, .fhead {
display: grid; grid-template-columns: 118px minmax(160px, 270px) minmax(0, 1fr);
grid-template-areas: "st pc stage"; align-items: center; gap: 0 20px; padding: 14px 230px 14px 24px;
}
.fhead { padding-top: 6px; padding-bottom: 6px; font-size: 12px; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); }
.g-st { grid-area: st; } .g-pc { grid-area: pc; min-width: 0; display: flex; flex-direction: column; } .g-stage { grid-area: stage; min-width: 0; }
.floor-row .g-act { position: absolute; right: 20px; top: 16px; width: 190px; justify-content: flex-end; }
.floor-row .serial { font-size: 15px; }
.stageline { display: flex; align-items: center; gap: 12px; font-size: 13px; min-height: 24px; }
.stageline .txt { flex: 1 1 auto; min-width: 0; white-space: normal; }
.stageline .warnline { font-weight: 600; }
.stageline .kind { flex: none; font-size: 12px; color: var(--muted); max-width: 40%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.stageline .step { flex: none; color: var(--muted); font-size: 12px; }
.fbody { display: flex; gap: 24px; align-items: flex-start; padding: 0 24px 16px 162px; font-size: 13px; }
.fbody-main { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 10px; }
.fbody-qr { flex: none; line-height: 0; background: #FFFFFF; padding: 6px; border-radius: 6px; }
.fmeta { display: flex; flex-wrap: wrap; gap: 4px 16px; }
.fline { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.imaging-log-pre { max-height: 14rem; overflow-y: auto; margin: 6px 0 0; padding: 8px 10px; background: var(--surface-2); border: 1px solid var(--line); border-radius: 6px; font-size: 12px; white-space: pre-wrap; }
.floor-logs { border-top: 1px solid var(--line); }
.floor-logs > summary { padding: 0 24px; min-height: 44px; color: var(--text-2); font-weight: 400; }
.floor-empty { padding: 28px 24px; text-align: center; border-top: 1px solid var(--line); }
@container (max-width: 820px) {
.fhead { display: none; }
.frow { grid-template-columns: auto minmax(0, 1fr); grid-template-areas: "st pc" "stage stage"; gap: 10px 14px; padding: 14px 18px; }
.floor-row .g-act { position: static; width: auto; display: flex; justify-content: flex-end; padding: 0 18px 12px; }
.fbody { padding: 0 18px 14px; flex-direction: column; }
}
/* Overview image cards */
.img-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(250px, 100%), 1fr)); gap: 16px; }
.img-card { padding: 16px 18px; display: flex; flex-direction: column; gap: 8px; min-width: 0; }
.img-top { display: flex; align-items: center; gap: 8px; }
.img-name { font-weight: 600; font-size: 16px; color: var(--text); text-decoration: none; margin-right: auto; }
.img-name:hover { color: var(--accent); }

View File

@@ -1,5 +1,9 @@
{# Per-bay tiles. Rendered standalone by /imaging/tiles for SSE refresh, and {# Imaging floor rows. Rendered standalone by /imaging/tiles for the SSE
included by imaging.html for first paint. Receives `sessions`. #} refresh, and included by imaging.html and the Overview for first paint.
Receives `sessions` (already in floor order, each with floor_state),
and optionally `compact` / `more` from the Overview.
One line per PC; everything else is in the row's expandable body. #}
{% import '_ui.html' as ui %}
{% set stage_labels = { {% set stage_labels = {
1: ('Booting from PXE', 'WinPE loaded - applying Windows image to disk.'), 1: ('Booting from PXE', 'WinPE loaded - applying Windows image to disk.'),
2: ('Configuring Windows', 'First boot. Running shopfloor setup baseline scripts.'), 2: ('Configuring Windows', 'First boot. Running shopfloor setup baseline scripts.'),
@@ -35,29 +39,26 @@
'stage_2_smb_winpe': ('WinPE running (no push)','Client has opened SMB shares - WinPE is up but startnet has not pushed status.'), '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 state_chip = {
'failed': ('Failed', 'fail'),
'action': ('Needs action', 'act'),
'running': ('In progress', 'run'),
'done': ('Done', 'ok'),
'logs': ('Seen in logs', 'off'),
} %}
{% set track_kind = {'failed': 'fail', 'action': 'act', 'running': 'run', 'done': 'ok'} %}
{% macro floor_row(s) %}
{% set is_inferred = (s.source == 'inferred') %} {% set is_inferred = (s.source == 'inferred') %}
{% set is_blancco = (s.source == 'blancco' or s.pctype == 'blancco') %} {% set is_blancco = (s.source == 'blancco' or s.pctype == 'blancco') %}
{% set stage_idx = s.stage_index | int(0) %} {% set stage_idx = s.stage_index | int(0) %}
{% set stage_total = s.stage_total | 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 state = s.floor_state or 'running' %}
{# Blancco erasing: fold the drive % ("... [75%]") into the bar between
the start of stage 4 and the start of stage 5. #}
{% set drive_pct = None %} {% set drive_pct = None %}
{% if is_blancco and stage_idx == 4 and s.current_stage and '%]' in s.current_stage %} {% if is_blancco and stage_idx == 4 and s.current_stage and '%]' in s.current_stage %}
{% set drive_pct = s.current_stage.split('[')[-1].split('%')[0] | int(0) %} {% set drive_pct = s.current_stage.split('[')[-1].split('%')[0] | int(0) %}
{% if stage_total > 0 and s.status != 'succeeded' %}
{% set pct = (((stage_idx - 1) + drive_pct / 100) / stage_total * 100) | round(0, 'floor') %}
{% endif %}
{% endif %}
{% set pct = pct | int %}
{% 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 %} {% endif %}
{% set warn = False %}
{% if is_inferred %} {% if is_inferred %}
{% set friendly = inferred_stage_labels.get(s.current_stage, ('Inferred from logs', s.current_stage or '')) %} {% set friendly = inferred_stage_labels.get(s.current_stage, ('Inferred from logs', s.current_stage or '')) %}
{% elif is_blancco %} {% elif is_blancco %}
@@ -66,11 +67,13 @@
{% else %} {% else %}
{% set friendly = blancco_stage_labels.get(stage_idx, ('Stage ' ~ stage_idx, '')) %} {% set friendly = blancco_stage_labels.get(stage_idx, ('Stage ' ~ stage_idx, '')) %}
{% endif %} {% endif %}
{% if stage_idx == 4 and s.status != 'failed' %}{% set warn = True %}{% endif %}
{% else %} {% else %}
{% set friendly = stage_labels.get(stage_idx, ('Stage ' ~ stage_idx, '')) %} {% 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 %} {% if stage_idx == 1 and s.current_stage and 'bios' in s.current_stage|lower %}
{% set friendly = ('Updating BIOS firmware', {% set friendly = ('Updating BIOS: do not power off',
'WinPE detected a firmware update for this model. Do NOT power off until the next reboot. Imaging continues afterward.') %} 'WinPE detected a firmware update for this model. Do NOT power off until the next reboot. Imaging continues afterward.') %}
{% set warn = True %}
{% endif %} {% endif %}
{% if stage_idx == 7 and s.current_stage %} {% if stage_idx == 7 and s.current_stage %}
{% set _cs = s.current_stage|lower %} {% set _cs = s.current_stage|lower %}
@@ -86,144 +89,117 @@
{% endif %} {% endif %}
{% endif %} {% endif %}
{% endif %} {% endif %}
{% set label = friendly[0] %}
{% if drive_pct is not none and s.status != 'failed' %}{% set label = 'Erasing: ' ~ drive_pct ~ '%' %}{% endif %}
{% set card_key = s.serial or ('mac-' ~ (s.mac or 'unknown') ~ '-' ~ (s.ip or '')) %} {% set card_key = s.serial or ('mac-' ~ (s.mac or 'unknown') ~ '-' ~ (s.ip or '')) %}
<details class="card mb-2 imaging-card{% if is_inferred %} imaging-inferred{% endif %}" {% set ident = [] %}
style="border-left:4px solid var(--{{ border }});" {% if s.machinenumber %}{% set _ = ident.append('Machine ' ~ s.machinenumber) %}{% endif %}
data-serial="{{ s.serial or '' }}" {% if s.hostname_target and s.serial %}{% set _ = ident.append(s.hostname_target) %}{% endif %}
data-key="{{ card_key }}" {% if s.ip %}{% set _ = ident.append(s.ip) %}{% endif %}
data-mac="{{ s.mac or '' }}" {% set title = s.serial or s.hostname_target or s.mac or s.ip or '(unknown bay)' %}
data-ip="{{ s.ip or '' }}" {% set kind = 'Blancco wipe' if is_blancco else (s.pctype or '') %}
data-source="{{ s.source or 'client' }}" <div class="ui-row floor-row imaging-card" data-state="{{ state }}"
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' }}"> 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;"> <details class="imaging-toggle">
<div class="d-flex flex-wrap gap-3 align-items-center"> <summary class="frow">
{% if s.intune_device_id %} <span class="g-st">{{ ui.chip(state_chip[state][0], state_chip[state][1]) }}</span>
<div data-qr="{{ s.intune_device_id }}" data-qr-size="96" data-qr-ec="M" <span class="g-pc"><span class="mono serial">{{ title }}</span><span class="sub">{{ ident | join(' | ') or ('MAC ' ~ s.mac if s.mac else '') }}</span></span>
style="line-height:0; flex-shrink:0;" <span class="g-stage">
title="Intune Device ID: {{ s.intune_device_id }}"></div> <span class="stageline"><span class="txt{{ ' warnline' if warn }}">{{ label }}</span>{% if kind %}<span class="kind">{{ kind }}</span>{% endif %}{% if not is_inferred %}<span class="mono step">{{ stage_idx }}/{{ stage_total or '?' }}</span>{% endif %}<span class="chev" aria-hidden="true">&#9662;</span></span>
{% else %} {% if not is_inferred and stage_total %}{{ ui.track(stage_total, stage_idx, track_kind.get(state, 'run'),
<div class="d-flex align-items-center justify-content-center eyebrow" label=('Done, all ' ~ stage_total ~ ' stages' if state == 'done' else 'Stage ' ~ stage_idx ~ ' of ' ~ stage_total)) }}{% endif %}
style="width:96px; height:96px; border-radius:0.25rem; flex-shrink:0; text-align:center; padding:0.25rem; background:var(--bg); border:1px solid var(--border);"> </span>
{% if is_inferred %}log-only{% elif is_blancco %}WIPE{% if drive_pct is not none %}<br>{{ drive_pct }}%{% endif %}{% else %}no DeviceId{% endif %} </summary>
</div> <div class="fbody">
{% endif %} <div class="fbody-main">
{% if friendly[1] %}<p class="muted" style="margin: 0">{{ friendly[1] }}</p>{% endif %}
<div class="flex-grow-1" style="min-width:0;"> {% if s.error %}<div class="errbox"><strong>Error:</strong> {{ s.error }}</div>{% endif %}
<div class="d-flex flex-wrap align-items-center gap-2"> <div class="sub fmeta">
<strong class="fs-6"> <span>Started <span class="mono">{{ s.started_at | local_dt(true) }}</span></span>
{% if s.serial %}{{ s.serial }} <span>Last update <span class="mono">{{ s.last_updated | local_dt(true) }}</span></span>
{% elif s.hostname_target %}{{ s.hostname_target }} <span>MAC <span class="mono">{{ s.mac or '-' }}</span></span>
{% elif s.mac %}{{ s.mac }} {% if s.current_stage %}<span>Raw stage <span class="mono">{{ s.current_stage }}</span></span>{% endif %}
{% else %}{{ s.ip or '(unknown bay)' }}{% endif %}
</strong>
{% if s.hostname_target and s.serial %}<code class="mono" style="color:var(--text-light);">{{ s.hostname_target }}</code>{% endif %}
{% if s.ip %}<code class="mono" style="color:var(--text-light);">{{ s.ip }}</code>{% endif %}
{% if s.pctype %}<span class="badge badge-info">{{ s.pctype }}</span>{% endif %}
{% if s.machinenumber %}<span class="badge badge-secondary">#{{ s.machinenumber }}</span>{% endif %}
{% if is_inferred %}<span class="badge badge-warning">log-inferred</span>{% endif %}
<span class="badge badge-{{ border }} ms-auto">{{ s.status or 'in_progress' }}</span>
</div> </div>
{% if s.intune_device_id %}
<div class="d-flex justify-content-between align-items-baseline mt-2"> <div class="fline"><span class="sub">Intune device</span> <span class="mono">{{ s.intune_device_id }}</span>
<div class="d-flex align-items-center gap-2"> <button type="button" class="ui-btn sm ghost copy-btn" data-copy-text="{{ s.intune_device_id }}">Copy</button>
<strong>{{ friendly[0] }}</strong> <a class="ui-btn sm" target="_blank" rel="noopener"
{% if is_blancco and stage_idx == 4 and s.current_stage %}<span class="mono" style="color:var(--text-light); font-size:0.8rem;">{{ s.current_stage | replace('Erasing: ', '') }}</span>{% endif %} href="https://portal.azure.us/?feature.msaljs=false#view/Microsoft_Intune_Devices/DeviceSettingsMenuBlade/~/properties/aadDeviceId/{{ s.intune_device_id }}">Set category</a>
{% if not is_inferred %} <a class="ui-btn sm" target="_blank" rel="noopener" href="https://arts.dw.geaerospace.net/requests/type">ARTS request</a>
<span class="badge badge-secondary">{{ stage_idx }}/{{ stage_total or '?' }}</span>
{% endif %}
</div>
{% if not is_inferred %}<span class="eyebrow">{{ pct }}%</span>{% endif %}
</div>
{% if not is_inferred %}
<div class="progress mt-1" style="height:0.7rem; background:var(--bg); border-radius:0.25rem;">
<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 }}%; background-color:var(--{{ border }});"
aria-valuenow="{{ pct }}" aria-valuemin="0" aria-valuemax="100"></div>
</div> </div>
{% endif %} {% endif %}
</div> {% if compact and not is_inferred and not is_blancco %}
</div> <div class="sub">LAPS QR and the live log are on the <a href="{{ url_for('imaging_dashboard') }}">imaging floor</a>.</div>
</summary> {% elif not is_inferred and not is_blancco %}
<div class="laps-card" data-serial="{{ s.serial }}">
<div class="card-body pt-0 pb-3" style="border-top:1px solid var(--border);"> <div class="sub" style="margin-bottom: 6px">LAPS password QR: paste, then scan on the bay. Kept until cleared.</div>
{% if friendly[1] %}<div class="mt-2" style="color:var(--text-light); font-size:0.85rem;">{{ friendly[1] }}</div>{% endif %} <div class="fline">
{# Masked: the floor is on a shared screen; Show reveals it while typing. #}
{% if s.intune_device_id %} <input type="password" class="field laps-input mono" style="max-width: 22rem; flex: 1 1 200px"
<div class="d-flex flex-wrap align-items-center gap-2 mt-3"> placeholder="Paste the LAPS password from Intune" autocomplete="off" aria-label="LAPS password for {{ title }}"
<span class="eyebrow">Intune</span> value="{{ s.laps_password or '' }}">
<code class="mono">{{ s.intune_device_id }}</code> <button type="button" class="ui-btn laps-show-btn" aria-pressed="false">Show</button>
<button type="button" class="btn btn-sm btn-outline-secondary btn-row-action copy-btn" <button type="button" class="ui-btn pri laps-make-btn">{% if s.laps_password %}Update QR{% else %}Make QR{% endif %}</button>
data-copy-text="{{ s.intune_device_id }}">copy</button> <button type="button" class="ui-btn laps-clear-btn" {% if not s.laps_password %}style="display:none;"{% endif %}>Clear</button>
<a class="btn btn-sm btn-outline-primary btn-row-action" </div>
target="_blank" rel="noopener" <div class="laps-qr-container" style="margin-top: 8px"></div>
href="https://portal.azure.us/?feature.msaljs=false#view/Microsoft_Intune_Devices/DeviceSettingsMenuBlade/~/properties/aadDeviceId/{{ s.intune_device_id }}">set category</a> </div>
<a class="btn btn-sm btn-outline-warning btn-row-action" {% endif %}
target="_blank" rel="noopener" {% if s.log_tail %}
href="https://arts.dw.geaerospace.net/requests/type">ARTS request</a> <details class="imaging-log" data-key="{{ card_key }}">
</div> <summary class="disc"><span class="chev" aria-hidden="true">&#9662;</span>{% if is_inferred %}Evidence{% else %}Log tail{% endif %} ({{ s.log_tail | length }} line{{ 's' if s.log_tail | length != 1 }})</summary>
{% endif %} <pre class="mono imaging-log-pre">{% for line in s.log_tail %}{{ line }}
<div class="mt-2 d-flex flex-wrap gap-3" style="color:var(--text-light); font-size:0.75rem;">
<span>started <code class="mono">{{ s.started_at | local_dt(true) }}</code></span>
<span>last <code class="mono">{{ s.last_updated | local_dt(true) }}</code></span>
<span>MAC <code class="mono">{{ s.mac or '-' }}</code></span>
{% if s.current_stage %}<span class="mono">{{ s.current_stage }}</span>{% endif %}
</div>
{% if s.error %}
<div class="alert alert-danger py-2 mb-2 mt-3" style="font-size:0.85rem;">
<strong>Error:</strong> {{ s.error }}
</div>
{% endif %}
{% if is_blancco %}
<div class="mt-3">
<a href="{{ url_for('blancco_reports') }}" class="btn btn-sm btn-outline-primary btn-row-action">Erasure reports</a>
</div>
{% endif %}
{% if not is_inferred and not is_blancco %}
<div class="mt-3 laps-card" data-serial="{{ s.serial }}">
<div class="eyebrow mb-2">LAPS password QR (paste -> scan on bay - persists until cleared)</div>
<div class="d-flex flex-wrap align-items-center gap-2">
{# Masked: the tile is on a shared screen; Show reveals it while typing. #}
<input type="password"
class="form-control form-control-sm laps-input mono"
style="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-outline-secondary laps-show-btn" aria-pressed="false">Show</button>
<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 imaging-log" data-key="{{ card_key }}">
<summary class="eyebrow" style="cursor:pointer;">{% if is_inferred %}Evidence{% else %}Log tail{% endif %} ({{ s.log_tail | length }} line{{ 's' if s.log_tail | length != 1 }})</summary>
<pre class="mono p-2 mt-2 mb-0 imaging-log-pre" style="max-height: 12rem; overflow-y: auto; background:var(--bg); border:1px solid var(--border); border-radius:0.25rem; font-size:0.78rem;">{% for line in s.log_tail %}{{ line }}
{% endfor %}</pre> {% endfor %}</pre>
</details> </details>
{% endif %} {% endif %}
</div>
{% if not is_inferred and s.serial %} {% if s.intune_device_id %}
<div class="mt-3 d-flex justify-content-between align-items-center"> <div class="fbody-qr" data-qr="{{ s.intune_device_id }}" data-qr-size="112" data-qr-ec="M"
<a href="{{ url_for('imaging_session_detail', serial=s.serial) }}" title="Intune device ID {{ s.intune_device_id }}" role="img" aria-label="QR code of the Intune device ID"></div>
class="btn btn-sm btn-outline-primary"> {% endif %}
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> </div>
</details>
<div class="act g-act">
{% if state == 'action' and s.intune_device_id %}
<a class="ui-btn pri" 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>
{% elif is_blancco and state in ('done', 'failed') %}
<a class="ui-btn" href="{{ url_for('blancco_reports') }}">Report</a>
{% elif not is_inferred and s.serial %}
<a class="ui-btn" href="{{ url_for('imaging_session_detail', serial=s.serial) }}">{{ 'Full log' if state == 'failed' else 'Details' }}</a>
{% endif %}
{% if not is_inferred and s.serial %}
{% call ui.more(title) %}
<a href="{{ url_for('imaging_session_detail', serial=s.serial) }}">Details and full log</a>
{% if s.intune_device_id %}<button type="button" class="copy-btn" data-copy-text="{{ s.intune_device_id }}">Copy Intune device ID</button>
<a target="_blank" rel="noopener" href="https://arts.dw.geaerospace.net/requests/type">ARTS request</a>{% endif %}
{% if is_blancco %}<a href="{{ url_for('blancco_reports') }}">Erasure reports</a>{% endif %}
<div class="sep"></div>
<form method="post" action="{{ url_for('imaging_delete_session', serial=s.serial) }}"
onsubmit="return confirm('Clear the session for {{ s.serial }}? Its status and log files are deleted.');">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="del">Clear session</button>
</form>
{% endcall %}
{% endif %} {% endif %}
</div> </div>
</details> </div>
{% endfor %} {% endmacro %}
{% set real = sessions | rejectattr('floor_state', 'equalto', 'logs') | list %}
{% set logs = sessions | selectattr('floor_state', 'equalto', 'logs') | list %}
{% for s in real %}{{ floor_row(s) }}{% endfor %}
{% if not compact and logs %}
<details class="floor-logs">
<summary class="disc"><span class="chev" aria-hidden="true">&#9662;</span><span><strong>{{ logs | length }}</strong> seen in DHCP/TFTP logs only, no status pushed yet</span></summary>
{% for s in logs %}{{ floor_row(s) }}{% endfor %}
</details>
{% endif %}
{% if compact and more %}
<div class="hidden-bar"><span>{{ more }} more on the floor</span><a class="ui-btn" href="{{ url_for('imaging_dashboard') }}">Open imaging floor</a></div>
{% endif %}
{% if not sessions %}
<div class="floor-empty muted">No PCs on the imaging network right now. A PC appears here once it touches DHCP, TFTP or boot.wim.</div>
{% endif %}

View File

@@ -98,7 +98,7 @@
{% endif %} {% endif %}
{% endwith %} {% endwith %}
{% if blancco_todo.total and ep not in ('blancco_todo', 'blancco_servicenow', 'imaging_tiles') %} {% if blancco_todo.total and ep not in ('blancco_todo', 'blancco_servicenow', 'imaging_tiles', 'dashboard') %}
{% set parts = [] %} {% set parts = [] %}
{% if blancco_todo.servicenow %}{% set _ = parts.append(blancco_todo.servicenow ~ ' to tick in ServiceNow') %}{% endif %} {% if blancco_todo.servicenow %}{% set _ = parts.append(blancco_todo.servicenow ~ ' to tick in ServiceNow') %}{% endif %}
{% if blancco_todo.failed %}{% set _ = parts.append(blancco_todo.failed ~ ' drive' ~ ('s' if blancco_todo.failed != 1) ~ ' to destroy') %}{% endif %} {% if blancco_todo.failed %}{% set _ = parts.append(blancco_todo.failed ~ ' drive' ~ ('s' if blancco_todo.failed != 1) ~ ' to destroy') %}{% endif %}

View File

@@ -1,151 +1,96 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Dashboard - PXE Server Manager{% endblock %} {% import '_ui.html' as ui %}
{% block title %}Overview - PXE Manager{% endblock %}
{% block content %} {% block content %}
<div class="page-header"> {% set services_up = services | selectattr('active') | list | length %}
<h1>Dashboard</h1> {% call ui.head('Overview', 'West Jefferson') %}
<div class="header-actions"> <details class="more">
<a href="{{ url_for('images_import') }}" class="btn btn-outline-secondary btn-sm"> <summary class="fbtn" style="background: var(--surface); min-height: 36px">
<i class="bi bi-box-arrow-in-down"></i> Image Import <span class="dot" style="background: {{ 'var(--ok)' if services_up == services|length else 'var(--fail)' }}"></span>
</a> {{ 'Services OK' if services_up == services|length else (services|length - services_up) ~ ' service' ~ ('s' if services|length - services_up != 1) ~ ' down' }}
<button type="button" class="btn btn-success btn-sm" data-bs-toggle="modal" data-bs-target="#newImageModal"> <span class="chev" aria-hidden="true">&#9662;</span>
<i class="bi bi-plus-lg"></i> New image type </summary>
</button> <div class="menu" style="padding: 10px 14px; gap: 6px; font-size: 13px">
<button class="btn btn-outline-secondary btn-sm" onclick="location.reload()"> {% for svc in services %}
<i class="bi bi-arrow-clockwise"></i> Refresh <div style="display: flex; align-items: center; gap: 8px"><span class="dot" style="background: {{ 'var(--ok)' if svc.active else 'var(--fail)' }}"></span><strong>{{ svc.name }}</strong><span class="muted mono">{{ svc.state }}</span></div>
</button> {% endfor %}
</div> </div>
</div> </details>
{% endcall %}
<!-- At-a-glance stats --> <div class="ui-stack">
<div class="dashboard-grid mb-4"> {% if blancco_todo.total %}
{% set services_up = services | selectattr('active') | list | length %} {% set parts = [] %}
{% set services_total = services | list | length %} {% if blancco_todo.servicenow %}{% set _ = parts.append(blancco_todo.servicenow ~ ' to tick in ServiceNow') %}{% endif %}
<div class="stat-card {{ 'success' if services_up == services_total else 'danger' }}"> {% if blancco_todo.failed %}{% set _ = parts.append(blancco_todo.failed ~ ' drive' ~ ('s' if blancco_todo.failed != 1) ~ ' to destroy') %}{% endif %}
<div class="label"><i class="bi bi-hdd-network"></i> Services running</div> {% if blancco_todo.evidence %}{% set _ = parts.append(blancco_todo.evidence ~ ' evidence pack' ~ ('s' if blancco_todo.evidence != 1) ~ ' to upload') %}{% endif %}
<div class="value">{{ services_up }} / {{ services_total }}</div> {% if blancco_todo.not_in_cloud %}{% set _ = parts.append(blancco_todo.not_in_cloud ~ ' not in the Blancco cloud') %}{% endif %}
</div> {% if blancco_todo.hardware %}{% set _ = parts.append(blancco_todo.hardware ~ ' hardware gap' ~ ('s' if blancco_todo.hardware != 1)) %}{% endif %}
{% set images_total = images | list | length %} <a class="ui-todo" href="{{ url_for('blancco_todo') }}" style="margin: 0">
<div class="stat-card info"> <span class="chip c-neutral">To-do</span>
<div class="label"><i class="bi bi-hdd-stack"></i> Image types</div> <span class="trunc" style="flex: 1 1 auto"><strong>{{ blancco_todo.total }} open.</strong> <span class="muted">{{ parts | join(', ') }}</span></span>
<div class="value">{{ images_total }}</div> <span style="font-weight: 600; color: var(--accent); white-space: nowrap">Open to-do</span>
</div> </a>
{% set images_ready = images | selectattr('has_content') | list | length %} {% endif %}
<div class="stat-card success">
<div class="label"><i class="bi bi-check2-circle"></i> Images with content</div>
<div class="value">{{ images_ready }}</div>
</div>
{% set unattend_missing = images | rejectattr('has_unattend') | list | length %}
<div class="stat-card {{ 'warning' if unattend_missing else 'success' }}">
<div class="label"><i class="bi bi-file-earmark-code"></i> Missing unattend.xml</div>
<div class="value">{{ unattend_missing }}</div>
</div>
</div>
<!-- Services --> <section class="panel floor" aria-labelledby="floor-h">
<div class="card"> <div style="display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 10px; padding: 18px 24px 8px">
<div class="card-header"> <h2 id="floor-h" class="sec">Imaging floor</h2>
<span><i class="bi bi-hdd-network"></i> PXE Services</span> <div style="display: flex; flex-wrap: wrap; align-items: center; gap: 6px">
</div> {% for key, label in (('failed', 'failed'), ('action', 'need action'), ('running', 'in progress'), ('done', 'done')) %}
<div class="card-body p-0"> <a class="fbtn" href="{{ url_for('imaging_dashboard') }}"><strong>{{ floor_counts[key] }}</strong> {{ label }}</a>
<div class="table-container"> {% endfor %}
<table class="table mb-0">
<thead>
<tr>
<th>Service</th>
<th>Status</th>
<th>State</th>
</tr>
</thead>
<tbody>
{% for svc in services %}
<tr>
<td>
<strong>{{ svc.name }}</strong>
</td>
<td>
<span class="status-dot {{ 'active' if svc.active else 'inactive' }}"></span>
{{ "Running" if svc.active else "Stopped" }}
</td>
<td><span class="mono">{{ svc.state }}</span></td>
</tr>
{% endfor %}
</tbody>
</table>
</div> </div>
</div> </div>
</div> <div class="fhead" aria-hidden="true"><span>Status</span><span>PC</span><span>Stage</span></div>
<div id="imaging-tiles">{% include "_imaging_tiles.html" %}</div>
</section>
<!-- Images --> <section aria-labelledby="img-h" style="display: flex; flex-direction: column; gap: 12px">
<div class="card"> <div style="display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 10px">
<div class="card-header"> <h2 id="img-h" class="sec">Images</h2>
<span><i class="bi bi-hdd-stack"></i> Deployment Images</span> <div style="display: flex; gap: 8px">
<button type="button" class="btn btn-sm btn-success" data-bs-toggle="modal" data-bs-target="#newImageModal"> <a class="ui-btn" href="{{ url_for('images_import') }}">Import content</a>
<i class="bi bi-plus-lg"></i> New image type <button type="button" class="ui-btn" data-bs-toggle="modal" data-bs-target="#newImageModal">New image type</button>
</button>
</div>
<div class="card-body p-0">
<div class="table-container">
<table class="table mb-0">
<thead>
<tr>
<th>Image</th>
<th>Deploy Content</th>
<th>unattend.xml</th>
<th>Path</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
{% for img in images %}
<tr>
<td>
<strong>{{ img.friendly_name }}</strong><br>
<small class="text-muted mono">{{ img.image_type }}</small>
</td>
<td>
{% if img.has_content %}
<span class="badge bg-success">Present</span>
{% else %}
<span class="badge bg-secondary">Empty</span>
{% endif %}
</td>
<td>
{% if img.has_unattend %}
<span class="badge bg-success">Exists</span>
{% else %}
<span class="badge bg-warning">Missing</span>
{% endif %}
</td>
<td><span class="mono">{{ img.deploy_path }}</span></td>
<td class="actions text-end">
<a href="{{ image_tab_url(img.image_type, 'overview') }}"
class="btn btn-sm btn-outline-secondary btn-row-action">
<i class="bi bi-clipboard-data"></i> Open
</a>
<a href="{{ url_for('unattend_editor', image_type=img.image_type) }}"
class="btn btn-sm btn-outline-primary btn-row-action">
<i class="bi bi-file-earmark-code"></i> Unattend
</a>
<button type="button" class="btn btn-sm btn-outline-secondary btn-row-action"
data-bs-toggle="modal" data-bs-target="#cloneImageModal"
data-src-key="{{ img.image_type }}"
data-src-friendly="{{ img.friendly_name }}">
<i class="bi bi-files"></i> Clone
</button>
<button type="button" class="btn btn-sm btn-outline-danger btn-row-action"
data-bs-toggle="modal" data-bs-target="#deleteImageModal"
data-src-key="{{ img.image_type }}"
data-src-friendly="{{ img.friendly_name }}">
<i class="bi bi-trash"></i> Delete
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div> </div>
</div> </div>
<div class="img-grid">
{% for img in images %}
{% set warn = img.lint.by_level.get('WARN', 0) if img.lint.available else 0 %}
{% set errs = img.lint.map_errors if img.lint.available else 0 %}
{% set zips = ((img.lint.by_level.get('ERROR', 0) + img.lint.by_level.get('CRITICAL', 0)) - img.lint.map_errors) if img.lint.available else 0 %}
<div class="img-card panel">
<div class="img-top">
<a class="img-name" href="{{ image_tab_url(img.image_type, 'overview') }}">{{ img.friendly_name | replace('GE Aerospace ', '') }}</a>
{% if errs %}{{ ui.chip(errs ~ ' error' ~ ('s' if errs != 1), 'err') }}{% endif %}
{% if zips %}{{ ui.chip(zips ~ ' zip' ~ ('s' if zips != 1) ~ ' missing', 'err') }}{% endif %}
{% if errs or zips %}
{% elif warn %}{{ ui.chip(warn ~ ' warning' ~ ('s' if warn != 1), 'warn') }}
{% elif img.lint.available and img.driver_count %}{{ ui.chip('Lint clean', 'ok') }}{% endif %}
{% call ui.more(img.friendly_name) %}
<a href="{{ image_tab_url(img.image_type, 'overview') }}">Open</a>
<a href="{{ image_tab_url(img.image_type, 'drivers') }}">Drivers</a>
<a href="{{ url_for('unattend_editor', image_type=img.image_type) }}">Unattend</a>
<button type="button" data-bs-toggle="modal" data-bs-target="#cloneImageModal"
data-src-key="{{ img.image_type }}" data-src-friendly="{{ img.friendly_name }}">Clone...</button>
<div class="sep"></div>
<button type="button" class="del" data-bs-toggle="modal" data-bs-target="#deleteImageModal"
data-src-key="{{ img.image_type }}" data-src-friendly="{{ img.friendly_name }}">Delete...</button>
{% endcall %}
</div>
<div class="sub">{{ img.os_label or 'No OS selected' }} | {{ img.driver_count }} driver entr{{ 'y' if img.driver_count == 1 else 'ies' }}</div>
{% if not img.has_content or not img.has_unattend %}
<div style="display: flex; flex-wrap: wrap; gap: 6px">
{% if not img.has_content %}{{ ui.chip('Deploy folder empty', 'off') }}{% endif %}
{% if not img.has_unattend %}{{ ui.chip('No unattend.xml', 'warn') }}{% endif %}
</div>
{% endif %}
<div class="sub mono trunc" title="{{ img.deploy_path }}">{{ img.image_type }}</div>
</div>
{% endfor %}
</div>
</section>
</div> </div>
<!-- New image type modal --> <!-- New image type modal -->
@@ -259,6 +204,17 @@
{% block extra_scripts %} {% block extra_scripts %}
<script> <script>
// The Overview floor refreshes every 15 s (the full floor page uses SSE).
(function () {
var url = "{{ url_for('imaging_tiles_partial', limit=6) }}";
setInterval(function () {
if (document.querySelector('#imaging-tiles details[open], #imaging-tiles details.more[open]')) return;
fetch(url, {credentials: 'same-origin'}).then(function (r) { return r.text(); }).then(function (html) {
var box = document.getElementById('imaging-tiles');
if (box) { box.innerHTML = html; if (window.renderAllQRs) window.renderAllQRs(); }
}).catch(function () {});
}, 15000);
})();
document.addEventListener('show.bs.modal', function(e) { document.addEventListener('show.bs.modal', function(e) {
var btn = e.relatedTarget; var btn = e.relatedTarget;
if (!btn) return; if (!btn) return;

View File

@@ -17,60 +17,52 @@ function cancelImagingReload() {
{% endblock %} {% endblock %}
{% block content %} {% block content %}
{% import '_ui.html' as ui %}
<div class="page-header"> {% call ui.head('Imaging floor', 'Operate') %}
<div> <span class="sub" style="display: inline-flex; align-items: center; gap: 6px"><span id="imaging-live-dot" class="dot" title="Live stream" style="background: var(--track-done)"></span>Live</span>
<h1>Imaging Progress</h1> {% if sessions %}
<small class="text-light">Live via SSE (15s polling fallback). Client pushes &rarr; <code class="mono">/imaging/status</code>; log-inferred bays in yellow.</small> {% call ui.more('the imaging floor') %}
{% if hidden %}
<div class="small mt-1">{{ hidden }} finished session{{ 's' if hidden != 1 }} older than {{ window_hours }} h hidden.
<a href="{{ url_for('imaging_dashboard', all='1') }}">Show all</a></div>
{% elif show_all %}
<div class="small mt-1">Showing finished sessions of every age. <a href="{{ url_for('imaging_dashboard') }}">Hide old finished sessions</a></div>
{% endif %}
</div>
<div class="header-actions">
<span class="status-indicator">
<span id="imaging-live-dot" class="status-dot" title="live stream" style="background-color:var(--secondary);"></span>
<span class="badge badge-secondary badge-lg"><span id="visible-count">{{ sessions|length }}</span>/<span id="total-count">{{ sessions|length }}</span></span>
</span>
{% if sessions %}
<form method="post" action="{{ url_for('imaging_delete_all') }}" <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.');" onsubmit="return confirm('Clear all {{ sessions|length }} imaging sessions? Every row is removed; PCs still imaging reappear on their next status push.');">
style="display:inline;">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="pxe-btn pxe-btn-danger pxe-btn-sm"> <button type="submit" class="del">Clear all sessions</button>
<i class="bi bi-trash"></i> Clear all
</button>
</form> </form>
{% endif %} {% endcall %}
{% endif %}
{% endcall %}
<section class="panel floor" aria-labelledby="floor-h">
<h2 id="floor-h" class="vh">PCs on the imaging network</h2>
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 10px; padding: 16px 24px 8px">
<div role="search" style="flex: 1 1 220px; min-width: 160px; display: flex">
<label for="imaging-search" class="vh">Filter by serial, hostname, PC type, machine number, Intune ID, MAC, IP or stage</label>
<input id="imaging-search" type="search" class="field" style="flex: 1; min-width: 0"
placeholder="Serial, hostname, PC type, machine #, MAC, IP or stage" autocomplete="off">
</div>
<div role="group" aria-label="Show only" id="floor-filters" style="display: flex; flex-wrap: wrap; gap: 6px">
<button type="button" class="fbtn" data-state="" aria-pressed="true">All <span class="n" data-count="all"></span></button>
<button type="button" class="fbtn" data-state="failed" aria-pressed="false">Failed <span class="n" data-count="failed"></span></button>
<button type="button" class="fbtn" data-state="action" aria-pressed="false">Needs action <span class="n" data-count="action"></span></button>
<button type="button" class="fbtn" data-state="running" aria-pressed="false">In progress <span class="n" data-count="running"></span></button>
<button type="button" class="fbtn" data-state="done" aria-pressed="false">Done <span class="n" data-count="done"></span></button>
</div>
</div> </div>
</div> {% if hidden %}
<div class="sub" style="padding: 0 24px 8px">{{ hidden }} finished session{{ 's' if hidden != 1 }} older than {{ window_hours }} h hidden.
<a href="{{ url_for('imaging_dashboard', all='1') }}">Show all</a></div>
{% elif show_all %}
<div class="sub" style="padding: 0 24px 8px">Showing finished sessions of every age. <a href="{{ url_for('imaging_dashboard') }}">Hide old finished sessions</a></div>
{% endif %}
<div class="fhead" aria-hidden="true"><span>Status</span><span>PC</span><span>Stage</span></div>
<div id="imaging-tiles">
{% include "_imaging_tiles.html" %}
</div>
<div class="hidden-bar" id="floor-nomatch" hidden><span>No PC matches the filter.</span></div>
</section>
<div class="form-field mb-3"> <details class="panel" style="margin-top: 22px; border-radius: 10px">
<input id="imaging-search" type="search" class="form-control form-control-sm" <summary class="disc" style="padding: 0 16px; min-height: 44px"><span class="chev" aria-hidden="true">&#9662;</span>How an imaging client pushes status</summary>
placeholder="Filter by serial, hostname, pctype, machine#, Intune id, MAC, IP, stage name, stage-N, status, source (client|inferred)" <pre class="mono" style="margin: 0; padding: 0 16px 16px 42px; white-space: pre-wrap; font-size: 12px">POST http://172.16.9.1:9009/imaging/status
autocomplete="off">
</div>
{% if not sessions %}
<div id="imaging-empty" class="pxe-card text-center text-light" style="padding: 3rem 1.25rem;">
<p class="mb-1"><i class="bi bi-hdd-network" style="font-size: 1.75rem; opacity: 0.5;"></i></p>
<p class="mb-1">No imaging sessions yet.</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>
{% endif %}
<div id="imaging-tiles">
{% include "_imaging_tiles.html" %}
</div>
<div class="section-card mt-3">
<div class="section-title">How to push status from an imaging client</div>
<div class="pxe-card-body">
<pre class="mono mb-0" style="white-space: pre-wrap;">POST http://172.16.9.1:9009/imaging/status
Content-Type: application/json Content-Type: application/json
{ {
@@ -85,9 +77,7 @@ Content-Type: application/json
"status": "in_progress", "status": "in_progress",
"log_lines": ["last few log lines from the stage"] "log_lines": ["last few log lines from the stage"]
}</pre> }</pre>
</div> </details>
</div>
{% endblock %} {% endblock %}
{% block extra_scripts %} {% block extra_scripts %}
@@ -149,19 +139,19 @@ Content-Type: application/json
function startSSE() { function startSSE() {
if (!window.EventSource) { if (!window.EventSource) {
setDot('var(--warning)', 'EventSource unsupported - polling only'); setDot('var(--act)', 'EventSource unsupported - polling only');
startPolling(); startPolling();
return; return;
} }
try { try {
sse = new EventSource(STREAM_URL); sse = new EventSource(STREAM_URL);
} catch (e) { } catch (e) {
setDot('var(--danger)', 'SSE failed - polling'); setDot('var(--fail)', 'SSE failed - polling');
startPolling(); startPolling();
return; return;
} }
sse.onopen = function() { sse.onopen = function() {
setDot('var(--success)', 'live stream connected'); setDot('var(--ok)', 'live stream connected');
stopPolling(); stopPolling();
}; };
sse.onmessage = function(ev) { sse.onmessage = function(ev) {
@@ -172,7 +162,7 @@ Content-Type: application/json
refreshTiles(false); refreshTiles(false);
}; };
sse.onerror = function() { sse.onerror = function() {
setDot('var(--danger)', 'live stream lost - polling fallback'); setDot('var(--fail)', 'live stream lost - polling fallback');
try { sse.close(); } catch (e) {} try { sse.close(); } catch (e) {}
sse = null; sse = null;
startPolling(); startPolling();
@@ -190,66 +180,7 @@ Content-Type: application/json
}); });
})(); })();
function copyText(text) { // Copy buttons (.copy-btn) are handled globally in static/app.js.
// Modern path - only works over HTTPS or localhost
if (navigator.clipboard && window.isSecureContext) {
return navigator.clipboard.writeText(text);
}
// Legacy fallback for plain HTTP
return new Promise(function(resolve, reject) {
var ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.left = '-9999px';
ta.style.top = '0';
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);
}
});
}
function flashCopied(btn, success) {
var origText = btn.dataset.origText || btn.textContent;
btn.dataset.origText = origText;
if (success) {
btn.textContent = 'copied!';
btn.classList.remove('btn-outline-secondary');
btn.classList.add('btn-success');
btn.style.transform = 'scale(1.15)';
setTimeout(function() {
btn.textContent = origText;
btn.classList.remove('btn-success');
btn.classList.add('btn-outline-secondary');
btn.style.transform = 'scale(1)';
}, 1200);
} else {
btn.textContent = 'failed';
btn.classList.remove('btn-outline-secondary');
btn.classList.add('btn-danger');
setTimeout(function() {
btn.textContent = origText;
btn.classList.remove('btn-danger');
btn.classList.add('btn-outline-secondary');
}, 1500);
}
}
document.addEventListener('click', function(e) {
var btn = e.target.closest('.copy-btn');
if (!btn) return;
var text = btn.getAttribute('data-copy-text');
if (!text) return;
copyText(text).then(function() { flashCopied(btn, true); })
.catch(function(err) { console.error('copy failed:', err); flashCopied(btn, false); });
});
// LAPS password QR. Persisted server-side per bay so it survives the // LAPS password QR. Persisted server-side per bay so it survives the
// 5s dashboard refresh. Stays put until the operator hits Clear (or // 5s dashboard refresh. Stays put until the operator hits Clear (or
@@ -353,14 +284,16 @@ document.addEventListener('keydown', function(e) {
var expanded = loadExpandedSet(); var expanded = loadExpandedSet();
document.querySelectorAll('.imaging-card').forEach(function(card) { document.querySelectorAll('.imaging-card').forEach(function(card) {
var serial = card.getAttribute('data-serial') || card.getAttribute('data-key'); var serial = card.getAttribute('data-serial') || card.getAttribute('data-key');
if (serial && expanded.has(serial)) card.open = true; var row = card.querySelector('details.imaging-toggle');
if (!card._toggleBound) { if (!row) return;
card.addEventListener('toggle', function() { if (serial && expanded.has(serial)) row.open = true;
if (!row._toggleBound) {
row.addEventListener('toggle', function() {
var s = loadExpandedSet(); var s = loadExpandedSet();
if (card.open) s.add(serial); else s.delete(serial); if (row.open) s.add(serial); else s.delete(serial);
saveExpandedSet(s); saveExpandedSet(s);
}); });
card._toggleBound = true; row._toggleBound = true;
} }
}); });
// The nested "Log tail" <details> is re-rendered closed on every // The nested "Log tail" <details> is re-rendered closed on every
@@ -408,22 +341,25 @@ document.addEventListener('keydown', function(e) {
if (typeof window.renderAllQRs === 'function') window.renderAllQRs(); if (typeof window.renderAllQRs === 'function') window.renderAllQRs();
} }
var stateFilter = '';
function applyFilter() { function applyFilter() {
var search = document.getElementById('imaging-search'); var search = document.getElementById('imaging-search');
var counter = document.getElementById('visible-count'); var q = search ? search.value.trim().toLowerCase() : '';
var total = document.getElementById('total-count'); var counts = {all: 0, failed: 0, action: 0, running: 0, done: 0};
if (!search) return; var visible = 0;
var q = search.value.trim().toLowerCase();
var visible = 0, totalN = 0;
document.querySelectorAll('.imaging-card').forEach(function(card) { document.querySelectorAll('.imaging-card').forEach(function(card) {
totalN++; var st = card.getAttribute('data-state') || '';
if (st !== 'logs') { counts.all++; if (counts[st] !== undefined) counts[st]++; }
var hay = card.getAttribute('data-filter') || ''; var hay = card.getAttribute('data-filter') || '';
var match = (q === '') || hay.indexOf(q) !== -1; var match = (q === '' || hay.indexOf(q) !== -1) && (stateFilter === '' || st === stateFilter);
card.style.display = match ? '' : 'none'; card.hidden = !match;
if (match) visible++; if (match) visible++;
}); });
if (counter) counter.textContent = visible; document.querySelectorAll('#floor-filters [data-count]').forEach(function(n) {
if (total) total.textContent = totalN; n.textContent = counts[n.getAttribute('data-count')];
});
var none = document.getElementById('floor-nomatch');
if (none) none.hidden = visible > 0 || (q === '' && stateFilter === '');
} }
window.imagingPostSwapHooks = function() { window.imagingPostSwapHooks = function() {
@@ -440,6 +376,15 @@ document.addEventListener('keydown', function(e) {
if (search) { if (search) {
search.addEventListener('input', applyFilter); search.addEventListener('input', applyFilter);
} }
document.querySelectorAll('#floor-filters .fbtn').forEach(function(b) {
b.addEventListener('click', function() {
stateFilter = b.getAttribute('data-state') || '';
document.querySelectorAll('#floor-filters .fbtn').forEach(function(x) {
x.setAttribute('aria-pressed', x === b ? 'true' : 'false');
});
applyFilter();
});
});
window.imagingPostSwapHooks(); window.imagingPostSwapHooks();
}); });
})(); })();