diff --git a/webapp/app.py b/webapp/app.py index 79a5a2d..f05cd9e 100644 --- a/webapp/app.py +++ b/webapp/app.py @@ -103,13 +103,30 @@ app.register_blueprint(images_unattend_views.bp) # Unattend review/save/undo @app.route("/") def dashboard(): 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")] + 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( "dashboard.html", images=image_list, services=services, image_types=config.IMAGE_TYPES, 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 if not show_all: 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) @@ -857,7 +874,15 @@ def imaging_tiles_partial(): sessions = imaging_status.list_sessions() if request.args.get("all") != "1": 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: diff --git a/webapp/services/imaging_status.py b/webapp/services/imaging_status.py index 201f17f..a7814c8 100644 --- a/webapp/services/imaging_status.py +++ b/webapp/services/imaging_status.py @@ -243,6 +243,42 @@ def hide_stale(sessions: list[dict], hours: float = ACTIVE_WINDOW_HOURS) -> tupl 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]: path = _path_for(serial) if not os.path.isfile(path): diff --git a/webapp/static/app.js b/webapp/static/app.js index 9db19a5..3d2c2b1 100644 --- a/webapp/static/app.js +++ b/webapp/static/app.js @@ -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); +}); diff --git a/webapp/static/pxe-ui.css b/webapp/static/pxe-ui.css index a793156..8ec9679 100644 --- a/webapp/static/pxe-ui.css +++ b/webapp/static/pxe-ui.css @@ -237,3 +237,43 @@ details.more .menu form { margin: 0; } /* 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); } + +/* -------------------------------------------------------------------------- + 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); } diff --git a/webapp/templates/_imaging_tiles.html b/webapp/templates/_imaging_tiles.html index a2d0f87..fbe0ea9 100644 --- a/webapp/templates/_imaging_tiles.html +++ b/webapp/templates/_imaging_tiles.html @@ -1,5 +1,9 @@ -{# Per-bay tiles. Rendered standalone by /imaging/tiles for SSE refresh, and - included by imaging.html for first paint. Receives `sessions`. #} +{# Imaging floor rows. Rendered standalone by /imaging/tiles for the SSE + 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 = { 1: ('Booting from PXE', 'WinPE loaded - applying Windows image to disk.'), 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.'), } %} -{% 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_blancco = (s.source == 'blancco' or s.pctype == 'blancco') %} {% 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 %} - {# Blancco erasing: fold the drive % ("... [75%]") into the bar between - the start of stage 4 and the start of stage 5. #} + {% set state = s.floor_state or 'running' %} {% set drive_pct = None %} {% 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) %} - {% 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 %} + {% set warn = False %} {% if is_inferred %} {% set friendly = inferred_stage_labels.get(s.current_stage, ('Inferred from logs', s.current_stage or '')) %} {% elif is_blancco %} @@ -66,11 +67,13 @@ {% else %} {% set friendly = blancco_stage_labels.get(stage_idx, ('Stage ' ~ stage_idx, '')) %} {% endif %} + {% if stage_idx == 4 and s.status != 'failed' %}{% set warn = True %}{% endif %} {% 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', + {% 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.') %} + {% set warn = True %} {% endif %} {% if stage_idx == 7 and s.current_stage %} {% set _cs = s.current_stage|lower %} @@ -86,144 +89,117 @@ {% 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 '')) %} -
- -
- {% if s.intune_device_id %} -
- {% else %} -
- {% if is_inferred %}log-only{% elif is_blancco %}WIPE{% if drive_pct is not none %}
{{ drive_pct }}%{% endif %}{% else %}no DeviceId{% endif %} -
- {% endif %} - -
-
- - {% if s.serial %}{{ s.serial }} - {% elif s.hostname_target %}{{ s.hostname_target }} - {% elif s.mac %}{{ s.mac }} - {% else %}{{ s.ip or '(unknown bay)' }}{% endif %} - - {% if s.hostname_target and s.serial %}{{ s.hostname_target }}{% endif %} - {% if s.ip %}{{ s.ip }}{% endif %} - {% if s.pctype %}{{ s.pctype }}{% endif %} - {% if s.machinenumber %}#{{ s.machinenumber }}{% endif %} - {% if is_inferred %}log-inferred{% endif %} - {{ s.status or 'in_progress' }} +
+ + {{ ui.chip(state_chip[state][0], state_chip[state][1]) }} + {{ title }}{{ ident | join(' | ') or ('MAC ' ~ s.mac if s.mac else '') }} + + {{ label }}{% if kind %}{{ kind }}{% endif %}{% if not is_inferred %}{{ stage_idx }}/{{ stage_total or '?' }}{% endif %} + {% if not is_inferred and stage_total %}{{ ui.track(stage_total, stage_idx, track_kind.get(state, 'run'), + label=('Done, all ' ~ stage_total ~ ' stages' if state == 'done' else 'Stage ' ~ stage_idx ~ ' of ' ~ stage_total)) }}{% endif %} + + +
+
+ {% if friendly[1] %}

{{ friendly[1] }}

{% endif %} + {% if s.error %}
Error: {{ s.error }}
{% endif %} +
+ Started {{ s.started_at | local_dt(true) }} + Last update {{ s.last_updated | local_dt(true) }} + MAC {{ s.mac or '-' }} + {% if s.current_stage %}Raw stage {{ s.current_stage }}{% endif %}
- -
-
- {{ friendly[0] }} - {% if is_blancco and stage_idx == 4 and s.current_stage %}{{ s.current_stage | replace('Erasing: ', '') }}{% endif %} - {% if not is_inferred %} - {{ stage_idx }}/{{ stage_total or '?' }} - {% endif %} -
- {% if not is_inferred %}{{ pct }}%{% endif %} -
- {% if not is_inferred %} -
-
+ {% if s.intune_device_id %} +
Intune device {{ s.intune_device_id }} + + Set category + ARTS request
{% endif %} -
-
-
- -
- {% if friendly[1] %}
{{ friendly[1] }}
{% endif %} - - {% if s.intune_device_id %} -
- Intune - {{ s.intune_device_id }} - - set category - ARTS request -
- {% endif %} - -
- started {{ s.started_at | local_dt(true) }} - last {{ s.last_updated | local_dt(true) }} - MAC {{ s.mac or '-' }} - {% if s.current_stage %}{{ s.current_stage }}{% endif %} -
- - {% if s.error %} -
- Error: {{ s.error }} -
- {% endif %} - - {% if is_blancco %} - - {% endif %} - - {% if not is_inferred and not is_blancco %} -
-
LAPS password QR (paste -> scan on bay - persists until cleared)
-
- {# Masked: the tile is on a shared screen; Show reveals it while typing. #} - - - - -
-
-
- {% endif %} - - {% if s.log_tail %} -
- {% if is_inferred %}Evidence{% else %}Log tail{% endif %} ({{ s.log_tail | length }} line{{ 's' if s.log_tail | length != 1 }}) -
{% for line in s.log_tail %}{{ line }}
+          {% if compact and not is_inferred and not is_blancco %}
+          
LAPS QR and the live log are on the imaging floor.
+ {% elif not is_inferred and not is_blancco %} +
+
LAPS password QR: paste, then scan on the bay. Kept until cleared.
+
+ {# Masked: the floor is on a shared screen; Show reveals it while typing. #} + + + + +
+
+
+ {% endif %} + {% if s.log_tail %} +
+ {% if is_inferred %}Evidence{% else %}Log tail{% endif %} ({{ s.log_tail | length }} line{{ 's' if s.log_tail | length != 1 }}) +
{% for line in s.log_tail %}{{ line }}
 {% endfor %}
-
- {% endif %} - - {% if not is_inferred and s.serial %} -
- - Details / full log - -
- - -
+
+ {% endif %} +
+ {% if s.intune_device_id %} + + {% endif %} +
+
+ {% if state == 'action' and s.intune_device_id %} + Set category + {% elif is_blancco and state in ('done', 'failed') %} + Report + {% elif not is_inferred and s.serial %} + {{ 'Full log' if state == 'failed' else 'Details' }} + {% endif %} + {% if not is_inferred and s.serial %} + {% call ui.more(title) %} + Details and full log + {% if s.intune_device_id %} + ARTS request{% endif %} + {% if is_blancco %}Erasure reports{% endif %} +
+
+ + +
+ {% endcall %} {% endif %}
- -{% 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 %} +
+ {{ logs | length }} seen in DHCP/TFTP logs only, no status pushed yet + {% for s in logs %}{{ floor_row(s) }}{% endfor %} +
+{% endif %} +{% if compact and more %} +
{{ more }} more on the floorOpen imaging floor
+{% endif %} +{% if not sessions %} +
No PCs on the imaging network right now. A PC appears here once it touches DHCP, TFTP or boot.wim.
+{% endif %} diff --git a/webapp/templates/base.html b/webapp/templates/base.html index ba17462..4951f58 100644 --- a/webapp/templates/base.html +++ b/webapp/templates/base.html @@ -98,7 +98,7 @@ {% endif %} {% 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 = [] %} {% 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 %} diff --git a/webapp/templates/dashboard.html b/webapp/templates/dashboard.html index ea14827..e68f578 100644 --- a/webapp/templates/dashboard.html +++ b/webapp/templates/dashboard.html @@ -1,151 +1,96 @@ {% extends "base.html" %} -{% block title %}Dashboard - PXE Server Manager{% endblock %} +{% import '_ui.html' as ui %} +{% block title %}Overview - PXE Manager{% endblock %} {% block content %} - +{% set services_up = services | selectattr('active') | list | length %} +{% call ui.head('Overview', 'West Jefferson') %} +
+ + + {{ 'Services OK' if services_up == services|length else (services|length - services_up) ~ ' service' ~ ('s' if services|length - services_up != 1) ~ ' down' }} + + + +
+{% endcall %} - -
- {% set services_up = services | selectattr('active') | list | length %} - {% set services_total = services | list | length %} -
-
Services running
-
{{ services_up }} / {{ services_total }}
-
- {% set images_total = images | list | length %} -
-
Image types
-
{{ images_total }}
-
- {% set images_ready = images | selectattr('has_content') | list | length %} -
-
Images with content
-
{{ images_ready }}
-
- {% set unattend_missing = images | rejectattr('has_unattend') | list | length %} -
-
Missing unattend.xml
-
{{ unattend_missing }}
-
-
+
+{% if blancco_todo.total %} +{% set parts = [] %} +{% 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.evidence %}{% set _ = parts.append(blancco_todo.evidence ~ ' evidence pack' ~ ('s' if blancco_todo.evidence != 1) ~ ' to upload') %}{% endif %} +{% if blancco_todo.not_in_cloud %}{% set _ = parts.append(blancco_todo.not_in_cloud ~ ' not in the Blancco cloud') %}{% endif %} +{% if blancco_todo.hardware %}{% set _ = parts.append(blancco_todo.hardware ~ ' hardware gap' ~ ('s' if blancco_todo.hardware != 1)) %}{% endif %} + + To-do + {{ blancco_todo.total }} open. {{ parts | join(', ') }} + Open to-do + +{% endif %} - -
-
- PXE Services -
-
-
- - - - - - - - - - {% for svc in services %} - - - - - - {% endfor %} - -
ServiceStatusState
- {{ svc.name }} - - - {{ "Running" if svc.active else "Stopped" }} - {{ svc.state }}
+
+
+

Imaging floor

+
+ {% for key, label in (('failed', 'failed'), ('action', 'need action'), ('running', 'in progress'), ('done', 'done')) %} + {{ floor_counts[key] }} {{ label }} + {% endfor %}
-
+ +
{% include "_imaging_tiles.html" %}
+ - -
-
- Deployment Images - -
-
-
- - - - - - - - - - - - {% for img in images %} - - - - - - - - {% endfor %} - -
ImageDeploy Contentunattend.xmlPathActions
- {{ img.friendly_name }}
- {{ img.image_type }} -
- {% if img.has_content %} - Present - {% else %} - Empty - {% endif %} - - {% if img.has_unattend %} - Exists - {% else %} - Missing - {% endif %} - {{ img.deploy_path }} - - Open - - - Unattend - - - -
+
+
+

Images

+
+ Import content +
+
+ {% 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 %} +
+
+ {{ img.friendly_name | replace('GE Aerospace ', '') }} + {% 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) %} + Open + Drivers + Unattend + +
+ + {% endcall %} +
+
{{ img.os_label or 'No OS selected' }} | {{ img.driver_count }} driver entr{{ 'y' if img.driver_count == 1 else 'ies' }}
+ {% if not img.has_content or not img.has_unattend %} +
+ {% if not img.has_content %}{{ ui.chip('Deploy folder empty', 'off') }}{% endif %} + {% if not img.has_unattend %}{{ ui.chip('No unattend.xml', 'warn') }}{% endif %} +
+ {% endif %} +
{{ img.image_type }}
+
+ {% endfor %} +
+
@@ -259,6 +204,17 @@ {% block extra_scripts %}