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

@@ -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 %}
<div class="page-header">
<h1>Dashboard</h1>
<div class="header-actions">
<a href="{{ url_for('images_import') }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-box-arrow-in-down"></i> Image Import
</a>
<button type="button" class="btn btn-success btn-sm" data-bs-toggle="modal" data-bs-target="#newImageModal">
<i class="bi bi-plus-lg"></i> New image type
</button>
<button class="btn btn-outline-secondary btn-sm" onclick="location.reload()">
<i class="bi bi-arrow-clockwise"></i> Refresh
</button>
</div>
</div>
{% set services_up = services | selectattr('active') | list | length %}
{% call ui.head('Overview', 'West Jefferson') %}
<details class="more">
<summary class="fbtn" style="background: var(--surface); min-height: 36px">
<span class="dot" style="background: {{ 'var(--ok)' if services_up == services|length else 'var(--fail)' }}"></span>
{{ 'Services OK' if services_up == services|length else (services|length - services_up) ~ ' service' ~ ('s' if services|length - services_up != 1) ~ ' down' }}
<span class="chev" aria-hidden="true">&#9662;</span>
</summary>
<div class="menu" style="padding: 10px 14px; gap: 6px; font-size: 13px">
{% for svc in services %}
<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>
{% endfor %}
</div>
</details>
{% endcall %}
<!-- At-a-glance stats -->
<div class="dashboard-grid mb-4">
{% set services_up = services | selectattr('active') | list | length %}
{% set services_total = services | list | length %}
<div class="stat-card {{ 'success' if services_up == services_total else 'danger' }}">
<div class="label"><i class="bi bi-hdd-network"></i> Services running</div>
<div class="value">{{ services_up }} / {{ services_total }}</div>
</div>
{% set images_total = images | list | length %}
<div class="stat-card info">
<div class="label"><i class="bi bi-hdd-stack"></i> Image types</div>
<div class="value">{{ images_total }}</div>
</div>
{% set images_ready = images | selectattr('has_content') | list | length %}
<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>
<div class="ui-stack">
{% 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 %}
<a class="ui-todo" href="{{ url_for('blancco_todo') }}" style="margin: 0">
<span class="chip c-neutral">To-do</span>
<span class="trunc" style="flex: 1 1 auto"><strong>{{ blancco_todo.total }} open.</strong> <span class="muted">{{ parts | join(', ') }}</span></span>
<span style="font-weight: 600; color: var(--accent); white-space: nowrap">Open to-do</span>
</a>
{% endif %}
<!-- Services -->
<div class="card">
<div class="card-header">
<span><i class="bi bi-hdd-network"></i> PXE Services</span>
</div>
<div class="card-body p-0">
<div class="table-container">
<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>
<section class="panel floor" aria-labelledby="floor-h">
<div style="display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 10px; padding: 18px 24px 8px">
<h2 id="floor-h" class="sec">Imaging floor</h2>
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 6px">
{% for key, label in (('failed', 'failed'), ('action', 'need action'), ('running', 'in progress'), ('done', 'done')) %}
<a class="fbtn" href="{{ url_for('imaging_dashboard') }}"><strong>{{ floor_counts[key] }}</strong> {{ label }}</a>
{% endfor %}
</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 -->
<div class="card">
<div class="card-header">
<span><i class="bi bi-hdd-stack"></i> Deployment Images</span>
<button type="button" class="btn btn-sm btn-success" data-bs-toggle="modal" data-bs-target="#newImageModal">
<i class="bi bi-plus-lg"></i> New image type
</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>
<section aria-labelledby="img-h" style="display: flex; flex-direction: column; gap: 12px">
<div style="display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 10px">
<h2 id="img-h" class="sec">Images</h2>
<div style="display: flex; gap: 8px">
<a class="ui-btn" href="{{ url_for('images_import') }}">Import content</a>
<button type="button" class="ui-btn" data-bs-toggle="modal" data-bs-target="#newImageModal">New image type</button>
</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>
<!-- New image type modal -->
@@ -259,6 +204,17 @@
{% block extra_scripts %}
<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) {
var btn = e.relatedTarget;
if (!btn) return;