Files
pxe-server/webapp/templates/dashboard.html
cproudlock 0b86255711 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.
2026-10-14 13:44:07 -04:00

235 lines
12 KiB
HTML

{% extends "base.html" %}
{% 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') %}
<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 %}
<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 %}
<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 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>
<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 -->
<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="form-field">
<label class="form-label">Key</label>
<input type="text" class="form-control mono" 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="form-field">
<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> <span class="mono" id="cloneSrcKey"></span></p>
<div class="form-field">
<label class="form-label">New key</label>
<input type="text" class="form-control mono" name="dst_key"
pattern="[a-z][a-z0-9-]{1,63}" required>
</div>
<div class="form-field">
<label class="form-label">Friendly name (optional)</label>
<input type="text" class="form-control" name="friendly_name"
placeholder="leave blank for &lsquo;<src> (copy)&rsquo;">
</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> <span class="mono" id="deleteSrcKey"></span> 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>
// 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;
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 = "{{ url_for('images_clone', image_type='__K__') }}".replace('__K__', encodeURIComponent(srcKey));
} else if (e.target.id === 'deleteImageModal') {
e.target.querySelector('#deleteSrcKey').textContent = srcKey;
e.target.querySelector('#deleteSrcFriendly').textContent = srcFriendly;
document.getElementById('deleteForm').action = "{{ url_for('images_delete', image_type='__K__') }}".replace('__K__', encodeURIComponent(srcKey));
}
});
</script>
{% endblock %}