Files
pxe-server/webapp/templates/base.html
cproudlock 9122b28c31 webapp: imaging progress dashboard + serial column on reports list
Adds end-to-end progress tracking for PXE imaging sessions and surfaces
each Blancco report's BIOS serial in the report list.

webapp:
  * services/imaging_status.py - JSON-per-serial state store under
    IMAGING_DIR (default /var/log/pxe-imaging). Atomic write via
    tempfile + rename. log_tail capped at 50 lines. Merges partial
    updates so clients can post just the current_stage tick.
  * config.py - new IMAGING_DIR env-overridable path.
  * services/csrf.py - explicit exempt list for machine-to-machine
    endpoints; /imaging/status is the first entry. Air-gapped LAN;
    trust-by-network for client posts.
  * app.py - four new routes:
      GET  /imaging               dashboard (renders all sessions)
      POST /imaging/status        client status push (JSON body)
      GET  /imaging/<serial>.json raw session JSON for ad-hoc polling
      POST /imaging/delete/<s>    clear a session from the dashboard
    Also parses each Blancco XML in the /reports list to surface
    system.serial + system.model columns.
  * templates/imaging.html - Bootstrap dashboard with per-session
    cards (state badge, progress bar, stage idx/total, mac, elapsed,
    log tail). meta http-equiv refresh=5 for auto-tick.
  * templates/base.html - new "Imaging Progress" nav entry.
  * templates/reports.html - Serial + Model columns added.

playbook:
  * shopfloor-setup/Shopfloor/lib/Send-PxeStatus.ps1 - new helper.
    Dot-source this then call Send-PxeStatus -Stage X -StageIndex N
    -StageTotal M from any stage script. BIOS serial via CIM, MAC via
    Get-NetAdapter, pctype + machinenumber from C:\Enrollment.
    Failures are swallowed to a local log so a network blip doesn't
    block imaging.
  * shopfloor-setup/Run-ShopfloorSetup.ps1 - dot-sources helper +
    posts at three coarse milestones (start, PPKG enrollment,
    handoff to Monitor-IntuneProgress).
  * shopfloor-setup/gea-shopfloor-keyence/09-Setup-Keyence.ps1 -
    posts at session start + after Install-FromManifest with
    succeeded/failed status derived from $rc. Other 09-Setup-*.ps1
    scripts can follow the same pattern.

ID is BIOS serial (stable across WinPE -> Windows transition and
across reboots, unlike hostname which is random pre-PPKG). Operator
already knows the serial of the bay they imaged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 10:07:18 -04:00

227 lines
7.1 KiB
HTML

<!DOCTYPE html>
<html lang="en" data-bs-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{% block title %}PXE Server Manager{% endblock %}</title>
<link rel="icon" href="{{ url_for('static', filename='favicon.ico') }}" type="image/x-icon">
<link href="{{ url_for('static', filename='bootstrap.min.css') }}" rel="stylesheet">
<style>
:root {
--sidebar-width: 280px;
}
body {
min-height: 100vh;
}
.sidebar {
width: var(--sidebar-width);
min-height: 100vh;
background-color: #1a1d21;
position: fixed;
top: 0;
left: 0;
z-index: 100;
overflow-y: auto;
}
.sidebar .nav-link {
color: #adb5bd;
padding: 0.6rem 1.25rem;
font-size: 0.9rem;
border-radius: 0;
}
.sidebar .nav-link:hover,
.sidebar .nav-link.active {
color: #fff;
background-color: rgba(255,255,255,0.08);
}
.sidebar-heading {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: #6c757d;
padding: 1rem 1.25rem 0.4rem;
font-weight: 600;
}
.sidebar .brand {
padding: 1.2rem 1.25rem;
font-size: 1.1rem;
font-weight: 700;
color: #fff;
border-bottom: 1px solid rgba(255,255,255,0.08);
display: flex;
flex-direction: column;
align-items: center;
gap: 0.3rem;
}
.main-content {
margin-left: var(--sidebar-width);
padding: 2rem;
}
.status-dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
}
.status-dot.active {
background-color: #198754;
}
.status-dot.inactive {
background-color: #dc3545;
}
.status-dot.unknown {
background-color: #ffc107;
}
.card {
border: 1px solid #dee2e6;
}
.card-header {
font-weight: 600;
}
.table th {
font-weight: 600;
}
.btn-row-action {
padding: 0.2rem 0.5rem;
font-size: 0.8rem;
}
.drag-handle {
cursor: grab;
color: #6c757d;
}
.drag-handle:active {
cursor: grabbing;
}
.nav-section-divider {
border-top: 1px solid rgba(255,255,255,0.06);
margin-top: 0.5rem;
}
</style>
{% block extra_head %}{% endblock %}
</head>
<body>
<!-- Sidebar -->
<nav class="sidebar d-flex flex-column">
<div class="brand">
<img src="{{ url_for('static', filename='ge-aerospace-logo.svg') }}" alt="GE Aerospace" style="height: 28px; filter: brightness(0) invert(1);">
PXE Manager
</div>
<ul class="nav flex-column mt-2">
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'dashboard' %}active{% endif %}"
href="{{ url_for('dashboard') }}">
Dashboard
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'images_import' %}active{% endif %}"
href="{{ url_for('images_import') }}">
Image Import
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'imaging_dashboard' %}active{% endif %}"
href="{{ url_for('imaging_dashboard') }}">
Imaging Progress
</a>
</li>
</ul>
<div class="nav-section-divider"></div>
<div class="sidebar-heading">Tools</div>
<ul class="nav flex-column">
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'startnet_editor' %}active{% endif %}"
href="{{ url_for('startnet_editor') }}">
startnet.cmd
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'clonezilla_backups' %}active{% endif %}"
href="{{ url_for('clonezilla_backups') }}">
Clonezilla Backups
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'blancco_reports' %}active{% endif %}"
href="{{ url_for('blancco_reports') }}">
Blancco Reports
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'enrollment' %}active{% endif %}"
href="{{ url_for('enrollment') }}">
Enrollment
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'audit_log' %}active{% endif %}"
href="{{ url_for('audit_log') }}">
Audit Log
</a>
</li>
</ul>
<div class="nav-section-divider"></div>
<div class="sidebar-heading">GE Aerospace Images</div>
<ul class="nav flex-column">
{% for it in all_image_types if it.startswith('gea-') %}
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'unattend_editor' and image_type is defined and image_type == it %}active{% endif %}"
href="{{ url_for('unattend_editor', image_type=it) }}">
{{ all_friendly_names[it] }}
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'image_config' and image_type is defined and image_type == it %}active{% endif %}"
href="{{ url_for('image_config', image_type=it) }}" style="padding-left: 2.5rem; font-size: 0.82rem;">
Configuration
</a>
</li>
{% endfor %}
</ul>
<div class="nav-section-divider"></div>
<div class="sidebar-heading">GE Legacy Images</div>
<ul class="nav flex-column">
{% for it in all_image_types if it.startswith('ge-') and not it.startswith('gea-') %}
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'unattend_editor' and image_type is defined and image_type == it %}active{% endif %}"
href="{{ url_for('unattend_editor', image_type=it) }}">
{{ all_friendly_names[it] }}
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'image_config' and image_type is defined and image_type == it %}active{% endif %}"
href="{{ url_for('image_config', image_type=it) }}" style="padding-left: 2.5rem; font-size: 0.82rem;">
Configuration
</a>
</li>
{% endfor %}
</ul>
</nav>
<!-- Main content -->
<div class="main-content">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</div>
<script src="{{ url_for('static', filename='bootstrap.bundle.min.js') }}"></script>
<script src="{{ url_for('static', filename='app.js') }}"></script>
{% block extra_scripts %}{% endblock %}
</body>
</html>