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.
235 lines
12 KiB
HTML
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">▾</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 ‘<src> (copy)’">
|
|
</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 %}
|