Files
pxe-server/webapp/templates/dashboard.html
cproudlock 2ed2cbe2e9 Webapp redesign: fixes from the functional regression test
- Unattend: a command's '...' menu stayed open after Move up/down and
  covered the next row's menu, so the next click could stage removing the
  wrong command. The menu now closes and focus returns to its button.
- '...' menus close on an outside click, on Escape and when another
  opens (static/app.js), for every page.
- Drivers: Remove asks to confirm again, as before the redesign. Long zip
  paths in a row body wrap (the page scrolled sideways at 390 px).
- Rollback review returns to the page it was opened from (Dell page or
  Updates overview), for both Cancel and the rollback itself.
- To-do: the ServiceNow period offers a year again (366 days).
- Imaging floor: a search that matches a log-only bay opens that group;
  its open state survives the live refresh. 'Clear all' states the
  number of session files it deletes, hidden finished ones included.
- Dashboard: the image key pattern is valid under the browser's v flag
  (client-side validation was silently skipped).
- Import: allowed_import_source() compares real paths, so
  '<upload dir>/../..' is refused. This path traversal predates the
  redesign; the import moves the source tree, so it mattered.
2026-10-14 14:48:10 -04:00

238 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.hidden || document.querySelector('#imaging-tiles details[open], #imaging-tiles details.more[open]')) return;
fetch(url, {credentials: 'same-origin'}).then(function (r) {
if (!r.ok) throw new Error('HTTP ' + r.status); // keep the last good rows
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 %}