- winpe-status-push.ps1: check-bios.cmd stages the firmware update before the imaging menu, but startnet's 'BIOS firmware update' push only comes at the end of WinPE, so builds showed a plain 'Applying image' for the whole apply. The first push now reports the staged update when X:\bios-fired.flag exists, and the floor shows 'Applying image - BIOS update staged' (do not power off during the next reboot). Also: CIM fallback for the serial where wmic is missing, and standard/engineer always report 5 steps. - Network pill: cdc_ncm (e.g. the WisdPI RTL8157 5G adapter) keeps link speed in a 32-bit bits/s field, so 5 Gb/s reads 705 Mb/s; show the real speed with a note. Tested in the win11 VM (with and without the BIOS flag, and the late startnet push).
282 lines
16 KiB
HTML
282 lines
16 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') %}
|
|
{% if nic %}
|
|
{% set nic_dot = {'ok': 'var(--ok)', 'warn': 'var(--act)', 'bad': 'var(--fail)'}[nic.state] %}
|
|
{% set role_name = {'imaging': 'Imaging', 'ge': 'GE network', 'other': 'Other'} %}
|
|
{% set verdict_chip = {'isolated': ('Isolated', 'ok'), 'ge': ('GE network', 'neutral'), 'foreign-dhcp': ('Other DHCP server', 'err'),
|
|
'no-link': ('No link', 'off'), 'error': ('Check failed', 'warn')} %}
|
|
<details class="more">
|
|
<summary class="fbtn" style="background: var(--surface); min-height: 36px">
|
|
<span class="dot" style="background: {{ nic_dot }}"></span>
|
|
{{ 'Network OK' if nic.state == 'ok' else ('Network: check' if nic.state == 'warn' else 'Network: DANGER') }}
|
|
<span class="chev" aria-hidden="true">▾</span>
|
|
</summary>
|
|
<div class="menu" style="width: min(460px, calc(100vw - 32px)); padding: 12px 14px; gap: 10px; font-size: 13px">
|
|
{% for i in nic.interfaces %}
|
|
{% set bad_here = (i.role == 'imaging' and i.verdict in ('ge', 'foreign-dhcp')) or (i.role != 'imaging' and i.dnsmasq_bound) %}
|
|
<div style="display: flex; flex-direction: column; gap: 4px">
|
|
<div style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap">
|
|
<span class="dot" style="background: {{ 'var(--fail)' if bad_here else ('var(--ok)' if i.link == 'up' else 'var(--act)') }}"></span>
|
|
<strong>{{ role_name.get(i.role, i.role) }}</strong>
|
|
<span class="mono muted">{{ i.iface }}</span>
|
|
{% if i.verdict and i.verdict in verdict_chip %}{% set vc = verdict_chip[i.verdict] %}{{ ui.chip(vc[0], 'err' if bad_here else vc[1]) }}{% endif %}
|
|
</div>
|
|
<div class="muted" style="padding-left: 18px; white-space: normal">
|
|
{{ 'Up' if i.link == 'up' else 'No link' }}{% if i.speed_mbps %}, {% if i.speed_mbps >= 1000 %}{{ (i.speed_mbps // 1000) if i.speed_mbps % 1000 == 0 else (i.speed_mbps / 1000) | round(1) }} Gb/s{% else %}{{ i.speed_mbps }} Mb/s{% endif %}{% endif %}
|
|
{% if i.addrs %}<span class="mono">{{ i.addrs | join(', ') }}</span>{% endif %}
|
|
{% if i.dnsmasq_bound is not none %}<br>DHCP/PXE {{ 'served here' if i.dnsmasq_bound else 'not served here' }}{% endif %}
|
|
{% for e in i.evidence %}<br>{{ e }}{% endfor %}{% for n in i.notes or [] %}<br><span class="sub">{{ n }}</span>{% endfor %}
|
|
</div>
|
|
</div>
|
|
{% else %}
|
|
<div class="muted">No network interfaces found.</div>
|
|
{% endfor %}
|
|
<div class="sub" style="white-space: normal; border-top: 1px solid var(--line); padding-top: 8px">
|
|
{% if nic.source == 'check' and not nic.stale %}Checked {{ nic.checked_at | local_dt }} by pxe-nic-check.
|
|
{% elif nic.source == 'check' %}The network check is stale (last {{ nic.checked_at | local_dt }}): showing link and addresses only.
|
|
{% else %}Link and addresses only; pxe-nic-check (the GE-network detector) is not installed yet.{% endif %}
|
|
</div>
|
|
</div>
|
|
</details>
|
|
{% endif %}
|
|
<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 nic and nic.problems %}
|
|
<div class="notice{{ ' bad' if nic.state == 'bad' else '' }}" role="{{ 'alert' if nic.state == 'bad' else 'status' }}">
|
|
<span>{% if nic.state == 'bad' %}<strong>Network danger.</strong> {% endif %}{% for level, text in nic.problems %}{{ text }}{% if not loop.last %} {% endif %}{% endfor %}</span>
|
|
</div>
|
|
{% endif %}
|
|
{% 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.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 %}
|