webapp: reskin to shopdb-flask design system

Adopt the shopdb-flask visual language across the PXE webapp (presentation only, Flask/Jinja logic unchanged):
- New static/pxe-theme.css: GE Aerospace palette (atmosphere-blue sidebar, sky-blue primary, avionics-green), Inter font stack, light/dark theming via data-theme + localStorage (key pxe-theme) with system-pref fallback, and card/button/table/form/badge/alert component styles layered over Bootstrap.
- base.html: shopdb-style sidebar (logo + title, nav sections, footer light/dark toggle) + theme boot script.
- All 13 content templates restyled to the new page-header + card/table/badge vocabulary; unattend_editor grouped per unattend-UX research.
- Fixed a pre-existing CRITICAL bug found during review: nested <form>s in image_config.html made Adopt submit the delete form and Delete-selected post every orphan filename regardless of checkboxes; split into standalone forms wired via the form= attribute.
Built by a Fable-orchestrated Opus workflow (17 agents). All 14 templates parse clean under Jinja2.
This commit is contained in:
cproudlock
2026-07-23 10:05:09 -04:00
parent 8df30017aa
commit 18db077475
15 changed files with 3446 additions and 2412 deletions

928
webapp/static/pxe-theme.css Normal file
View File

@@ -0,0 +1,928 @@
/* ==========================================================================
pxe-theme.css - GE Aerospace shopdb-flask design port for the PXE webapp
Load AFTER bootstrap.min.css so these token+component rules win.
Hand-written, dependency-free, air-gap safe (no CDN, no external fonts).
========================================================================== */
/* --------------------------------------------------------------------------
Tokens - LIGHT is the default on :root. Dark overrides live in the
[data-theme="dark"] block. Brand tokens are theme-invariant.
-------------------------------------------------------------------------- */
:root {
/* GE brand (theme-invariant) */
--ge-atmosphere-blue: #00003d;
--ge-sky-blue: #4181ff;
--ge-avionics-green: #0ad64f;
--ge-tungsten: #eaeaea;
/* accents (same in both themes unless noted) */
--primary: #4181ff;
--primary-dark: #2d6ce0;
--secondary: #6c757d;
--secondary-dark: #82503f;
--success: #0ad64f;
--success-dark: #019e4c;
--warning: #ff9500;
--warning-dark: #e67c02;
--danger: #dc3545;
--danger-dark: #e62c51;
--info: #4181ff;
--info-dark: #039ce0;
--purple: #9c27b0;
--link: #4181ff;
/* surfaces + text (LIGHT) */
--bg: #f5f5f5;
--bg-card: #ffffff;
--bg-card-solid: #ffffff;
--bg-popover: #111111;
--text: #1a1a2e;
--text-light: #6c757d;
--border: #dee2e6;
/* sidebar (same in both themes) */
--sidebar-bg: #00003d;
--sidebar-text: #ffffff;
--sidebar-width: 250px;
/* custom caret for selects (light) */
--select-caret: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23666' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
}
[data-theme="dark"] {
--danger: #f5365c;
--bg: #0a0a1a;
--bg-card: rgba(0, 0, 61, 0.4);
--bg-card-solid: #0d0d2b;
--text: rgba(255, 255, 255, 0.9);
--text-light: rgba(255, 255, 255, 0.6);
--border: rgba(65, 129, 255, 0.2);
--select-caret: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23999' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
}
/* Belt-and-suspenders: honour OS dark before JS stamps the attribute,
to avoid an unstyled flash on dark-OS machines. */
@media (prefers-color-scheme: dark) {
:root:not([data-theme]) {
--danger: #f5365c;
--bg: #0a0a1a;
--bg-card: rgba(0, 0, 61, 0.4);
--bg-card-solid: #0d0d2b;
--text: rgba(255, 255, 255, 0.9);
--text-light: rgba(255, 255, 255, 0.6);
--border: rgba(65, 129, 255, 0.2);
--select-caret: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23999' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
}
}
/* --------------------------------------------------------------------------
Base / reset
-------------------------------------------------------------------------- */
* { box-sizing: border-box; }
body {
font-family: 'Inter Variable', 'Inter', system-ui, -apple-system, 'Segoe UI', sans-serif;
font-size: 15px;
line-height: 1.6;
letter-spacing: 0.5px;
background-color: var(--bg);
color: var(--text);
min-height: 100vh;
}
a { color: var(--link); }
.mono,
.mac, .ip, .id-cell {
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
font-size: 13px;
}
/* Bootstrap .text-light is near-white always - remap to muted token so it
reads in BOTH themes (templates use it as "muted text") */
.text-light { color: var(--text-light) !important; }
/* Signature type treatment helper */
.eyebrow {
text-transform: uppercase;
letter-spacing: 1px;
font-size: 11px;
font-weight: 600;
color: var(--text-light);
}
/* --------------------------------------------------------------------------
Layout: sidebar rail + main content
-------------------------------------------------------------------------- */
.sidebar {
width: var(--sidebar-width);
height: 100vh;
background: var(--sidebar-bg);
color: var(--sidebar-text);
position: fixed;
top: 0;
left: 0;
overflow-y: auto;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
z-index: 1000;
padding: 0;
}
.main-content {
margin-left: var(--sidebar-width);
padding: 20px 10px 70px 10px;
}
/* Sidebar header ---------------------------------------------------------- */
.sidebar-header {
padding: 1rem;
text-align: center;
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
}
.sidebar-logo {
width: 180px;
max-width: 100%;
filter: brightness(0) invert(1);
}
.sidebar-header h1 {
font-size: 14px;
font-weight: 400;
text-transform: uppercase;
letter-spacing: 1px;
margin: 0.5rem 0 0;
color: #fff;
}
/* Sidebar search ---------------------------------------------------------- */
.sidebar-search {
padding: 10px 15px;
}
.sidebar-search input {
width: 100%;
height: 34px;
border: 0;
border-radius: 0.25rem;
background: rgba(255, 255, 255, 0.2);
color: #fff;
padding: 0 0.75rem;
font-size: 13px;
}
.sidebar-search input::placeholder {
color: rgba(255, 255, 255, 0.5);
font-size: 13px;
}
.sidebar-search input:focus {
outline: none;
background: rgba(0, 0, 0, 0.2);
box-shadow: 0 0 0 0.2rem rgba(255, 255, 255, 0.45);
}
/* Sidebar nav ------------------------------------------------------------- */
.sidebar-nav { list-style: none; margin: 0; padding: 0; }
.sidebar-nav .nav-link,
.sidebar .nav-link {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.7rem 1.25rem;
color: rgba(255, 255, 255, 0.85);
font-size: 14px;
text-decoration: none;
transition: all 0.2s;
border-radius: 0;
}
.sidebar-nav .nav-link i,
.sidebar .nav-link i {
font-size: 16px;
margin-right: 0.5rem;
}
.sidebar-nav .nav-link:hover,
.sidebar-nav .nav-link.active,
.sidebar .nav-link:hover,
.sidebar .nav-link.active {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
.sidebar-nav .nav-link.active,
.sidebar .nav-link.active {
border-left: 3px solid var(--primary);
}
/* sub-link (Configuration under an image) */
.sidebar-nav .nav-sublink {
padding-left: 2.5rem;
font-size: 0.82rem;
}
.nav-section,
.sidebar-heading {
padding: 0.75rem 1.25rem 0.5rem;
color: rgba(255, 255, 255, 0.5);
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
margin-top: 0.5rem;
}
.sidebar-nav .nav-link.external::after {
content: '\2197';
margin-left: auto;
opacity: 0.7;
}
/* Sidebar footer ---------------------------------------------------------- */
.sidebar-footer {
margin-top: auto;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.theme-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.75rem 1.25rem;
background: transparent;
border: none;
color: rgba(255, 255, 255, 0.7);
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
}
.theme-toggle:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
.user-menu {
padding: 1rem 1.25rem;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.user-menu .avatar {
width: 34px;
height: 34px;
border-radius: 50%;
background: var(--primary);
color: #fff;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 600;
}
.user-menu .username {
font-size: 14px;
color: rgba(255, 255, 255, 0.85);
}
.user-menu .user-sub {
font-size: 0.72rem;
color: rgba(255, 255, 255, 0.5);
}
.user-menu .icon-btn {
flex: 1;
padding: 0.4rem 0.5rem;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 6px;
color: rgba(255, 255, 255, 0.8);
font-size: 12px;
cursor: pointer;
text-align: center;
text-decoration: none;
}
.user-menu .icon-btn:hover {
background: rgba(255, 255, 255, 0.16);
color: #fff;
}
/* --------------------------------------------------------------------------
Page header
-------------------------------------------------------------------------- */
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.page-header h1,
.page-header h2 {
font-size: 20px;
font-weight: 500;
line-height: 20px;
margin: 0;
}
.header-actions {
display: flex;
gap: 0.5rem;
}
/* --------------------------------------------------------------------------
Card (also overrides Bootstrap .card)
-------------------------------------------------------------------------- */
.pxe-card,
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 0.25rem;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08);
padding: 0;
margin-bottom: 25px;
color: var(--text);
}
/* pxe-card with padded body baked in */
.pxe-card { padding: 1.25rem; }
[data-theme="light"] .pxe-card,
[data-theme="light"] .card { border: 1px solid var(--border); }
[data-theme="dark"] .pxe-card,
[data-theme="dark"] .card { border: none; }
.pxe-card-header,
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1.25rem;
border-bottom: 1px solid var(--border);
font-weight: 600;
font-size: 14px;
background: transparent;
}
.pxe-card-header h3,
.card-header h3 { font-size: 16px; font-weight: 600; margin: 0; }
.pxe-card-body,
.card-body { padding: 1.25rem; }
.pxe-card-footer,
.card-footer {
padding: 0.75rem 1.25rem;
border-top: 1px solid var(--border);
background: transparent;
}
.card-title,
.pxe-card-title { font-size: 16px; font-weight: 600; margin-bottom: 0.75rem; }
/* Section-card variant */
.section-card {
background: var(--bg-card);
border-radius: 0.25rem;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08);
padding: 1.25rem;
margin-bottom: 25px;
}
[data-theme="light"] .section-card { border: 1px solid var(--border); }
[data-theme="dark"] .section-card { border: none; }
.section-title {
font-size: 14px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
border-bottom: 1px solid var(--border);
padding-bottom: 0.75rem;
margin-bottom: 1rem;
}
/* --------------------------------------------------------------------------
Buttons (overrides Bootstrap .btn + variants)
-------------------------------------------------------------------------- */
.pxe-btn,
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 9px 19px;
border: none;
border-radius: 0.25rem;
font-size: 11px;
font-weight: 500;
letter-spacing: 1px;
text-transform: uppercase;
cursor: pointer;
transition: all 0.2s;
line-height: 1;
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
text-decoration: none;
}
.pxe-btn:focus,
.btn:focus { box-shadow: none; outline: none; }
.pxe-btn-primary, .btn-primary { background: var(--primary); color: #fff; border-color: var(--primary); }
.pxe-btn-primary:hover, .btn-primary:hover { background: var(--primary-dark); color: #fff; }
.pxe-btn-secondary, .btn-secondary { background: var(--secondary); color: #fff; border-color: var(--secondary); }
.pxe-btn-secondary:hover, .btn-secondary:hover { background: var(--secondary-dark); color: #fff; }
.pxe-btn-success, .btn-success { background: var(--success); color: #fff; border-color: var(--success); }
.pxe-btn-success:hover, .btn-success:hover { background: var(--success-dark); color: #fff; }
.pxe-btn-info, .btn-info { background: var(--info); color: #fff; border-color: var(--info); }
.pxe-btn-info:hover, .btn-info:hover { background: var(--info-dark); color: #fff; }
.pxe-btn-warning, .btn-warning { background: var(--warning); color: #fff; border-color: var(--warning); }
.pxe-btn-warning:hover, .btn-warning:hover { background: var(--warning-dark); color: #fff; }
.pxe-btn-danger, .btn-danger { background: var(--danger); color: #fff; border-color: var(--danger); }
.pxe-btn-danger:hover, .btn-danger:hover { background: var(--danger-dark); color: #fff; }
/* Ghost / outline buttons keep the brand shape but read as secondary chrome */
.pxe-btn-ghost,
.btn-outline-primary,
.btn-outline-secondary,
.btn-outline-danger,
.btn-outline-success,
.btn-outline-info {
background: transparent;
box-shadow: none;
border: 1px solid var(--border);
color: var(--text);
}
.pxe-btn-ghost:hover { background: var(--bg); color: var(--text); }
.btn-outline-primary { color: var(--primary); border-color: var(--primary); }
.btn-outline-primary:hover { background: var(--primary); color: #fff; }
.btn-outline-danger { color: var(--danger); border-color: var(--danger); }
.btn-outline-danger:hover { background: var(--danger); color: #fff; }
.btn-outline-secondary:hover { background: var(--secondary); color: #fff; }
.btn-outline-success { color: var(--success); border-color: var(--success); }
.btn-outline-success:hover { background: var(--success); color: #fff; }
.btn-outline-info { color: var(--info); border-color: var(--info); }
.btn-outline-info:hover { background: var(--info); color: #fff; }
.pxe-btn-link,
.btn-link {
background: transparent;
border: none;
box-shadow: none;
color: var(--info);
text-transform: none;
letter-spacing: normal;
}
/* Sizes */
.pxe-btn-sm, .btn-sm { font-size: 10px; padding: 6px 15px; }
.pxe-btn-lg, .btn-lg { font-size: 14px; padding: 12px 38px; }
/* small icon-only row action buttons in tables keep a compact footprint */
.btn-row-action { padding: 6px 10px; font-size: 10px; }
/* --------------------------------------------------------------------------
Tables (overrides Bootstrap .table)
-------------------------------------------------------------------------- */
.table-container { overflow-x: auto; white-space: nowrap; }
.data-table,
table.table,
.table {
width: 100%;
border-collapse: collapse;
color: var(--text);
font-variant-numeric: tabular-nums;
background: transparent;
}
.data-table th,
.table th {
font-weight: 600;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 1px;
text-align: left;
padding: 0.75rem;
border-bottom: 1px solid var(--border);
border-top: none;
color: var(--text);
}
.data-table td,
.table td {
padding: 0.75rem;
font-size: 13px;
vertical-align: middle;
white-space: nowrap;
border-top: 1px solid var(--border);
color: var(--text);
}
.data-table tbody tr:hover,
.table tbody tr:hover { background: rgba(65, 129, 255, 0.1); }
/* sortable header */
.data-table th.sortable,
.table th.sortable { cursor: pointer; }
.data-table th.sortable:hover,
.table th.sortable:hover { color: var(--primary); }
.sort-arrow { font-size: 10px; }
/* truncating cell */
.cell-truncate td {
max-width: 32rem;
overflow: hidden;
text-overflow: ellipsis;
}
/* action column: keep it a real table cell, space the buttons */
td.actions { display: table-cell; }
td.actions .btn + .btn,
.actions .pxe-btn + .pxe-btn { margin-left: 0.25rem; }
.clickable-row { cursor: pointer; }
.clickable-row:hover td { background: var(--bg); }
/* drag handle used by app.js command tables */
.drag-handle { cursor: grab; color: var(--text-light); }
.drag-handle:active { cursor: grabbing; }
/* --------------------------------------------------------------------------
Forms (overrides Bootstrap .form-control / .form-select)
-------------------------------------------------------------------------- */
.form-field,
.form-group { margin-bottom: 1rem; }
.form-field label,
.form-group label,
.form-label {
display: block;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 0.5rem;
color: var(--text);
}
.form-control {
width: 100%;
padding: 0.625rem 0.875rem;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 0.9375rem;
background: var(--bg-card);
color: var(--text);
transition: border-color 0.15s, box-shadow 0.15s;
}
.form-control:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(25, 118, 210, 0.15);
}
.form-control::placeholder { color: var(--text-light); opacity: 0.7; }
.form-control-sm { padding: 0.375rem 0.6rem; font-size: 0.85rem; }
[data-theme="dark"] .form-control {
background-color: var(--bg);
}
[data-theme="dark"] .form-control:focus {
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.2);
}
/* select */
select.form-control,
.form-select {
width: 100%;
padding: 0.625rem 0.875rem;
padding-right: 2.25rem;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 0.9375rem;
background: var(--bg-card);
color: var(--text);
appearance: none;
-webkit-appearance: none;
cursor: pointer;
background-image: var(--select-caret);
background-repeat: no-repeat;
background-position: right 0.75rem center;
}
select.form-control:focus,
.form-select:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(25, 118, 210, 0.15);
}
[data-theme="dark"] select.form-control,
[data-theme="dark"] .form-select {
background-color: var(--bg);
}
select.form-control option,
.form-select option { background: var(--bg-card); }
[data-theme="dark"] select.form-control option,
[data-theme="dark"] .form-select option { background: var(--bg-card-solid); }
/* textarea */
textarea.form-control {
min-height: 100px;
resize: vertical;
line-height: 1.5;
}
/* checkboxes / radios */
.form-check-input,
input[type="checkbox"],
input[type="radio"] {
width: 1.125rem;
height: 1.125rem;
accent-color: var(--primary);
}
/* --------------------------------------------------------------------------
Badges (overrides Bootstrap .badge + bg-* utilities)
-------------------------------------------------------------------------- */
.badge {
display: inline-block;
padding: 0.25em 0.5em;
border-radius: 0.25rem;
font-size: 11px;
font-weight: 500;
line-height: 1;
text-align: center;
white-space: nowrap;
color: #fff;
}
.badge-success, .badge.bg-success { background: var(--success); color: #fff; }
.badge-warning, .badge.bg-warning { background: var(--warning); color: #fff; }
.badge-danger, .badge.bg-danger { background: var(--danger); color: #fff; }
.badge-info, .badge.bg-info { background: var(--info); color: #fff; }
.badge-primary, .badge.bg-primary { background: var(--primary); color: #fff; }
.badge-secondary,.badge.bg-secondary { background: var(--secondary); color: #fff; }
.badge-printer, .badge.bg-purple { background: var(--purple); color: #fff; }
.badge-lg { padding: 0.35rem 0.75rem; font-size: 12px; }
/* --------------------------------------------------------------------------
Status pill / dot / toggle
-------------------------------------------------------------------------- */
.status-dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--secondary);
}
.status-dot.inactive { background: var(--secondary); }
.status-dot.warning,
.status-dot.unknown { background: var(--warning); }
.status-dot.pending { background: var(--primary); }
.status-dot.success,
.status-dot.active { background: var(--success); }
.status-indicator {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem;
background: var(--bg);
border-radius: 4px;
color: var(--text-light);
}
.toggle-btn {
position: relative;
width: 50px;
height: 26px;
border-radius: 13px;
background: var(--secondary);
border: none;
cursor: pointer;
transition: background 0.2s;
}
.toggle-btn.active { background: var(--success); }
.toggle-slider {
position: absolute;
top: 3px;
left: 3px;
width: 20px;
height: 20px;
border-radius: 50%;
background: #fff;
transition: transform 0.2s;
}
.toggle-btn.active .toggle-slider { transform: translateX(24px); }
/* --------------------------------------------------------------------------
Alerts (overrides Bootstrap .alert) + notification banners
-------------------------------------------------------------------------- */
.alert {
padding: 0.75rem 1rem;
border-radius: 0.25rem;
font-size: 14px;
border: 1px solid transparent;
margin-bottom: 1rem;
}
.error-message,
.alert-danger,
.alert-error {
background: rgba(245, 54, 92, 0.2);
color: var(--danger);
border: 1px solid rgba(245, 54, 92, 0.3);
}
.settings-success,
.alert-success {
background: var(--success);
color: #fff;
border-color: var(--success);
}
.alert-warning {
background: rgba(255, 136, 0, 0.15);
color: var(--warning-dark);
border: 1px solid rgba(255, 136, 0, 0.3);
}
.alert-info,
.alert-primary {
background: rgba(65, 129, 255, 0.12);
color: var(--primary);
border: 1px solid rgba(65, 129, 255, 0.3);
}
/* Flask flashes commonly use "message" category */
.alert-message {
background: rgba(65, 129, 255, 0.12);
color: var(--primary);
border: 1px solid rgba(65, 129, 255, 0.3);
}
.notification-item {
flex: 1;
min-width: 250px;
padding: 0.625rem 0.875rem;
border-radius: 6px;
border-left: 4px solid var(--border);
background: var(--bg-card);
}
.notification-item.type-incident { border-left-color: var(--danger); background: rgba(245, 54, 92, 0.06); }
.notification-item.type-change { border-left-color: var(--warning); background: rgba(255, 136, 0, 0.06); }
.notification-item.type-awareness { border-left-color: var(--success); background: rgba(4, 185, 98, 0.06); }
[data-theme="dark"] .notification-item.type-incident { background: rgba(245, 54, 92, 0.1); }
[data-theme="dark"] .notification-item.type-change { background: rgba(255, 136, 0, 0.1); }
[data-theme="dark"] .notification-item.type-awareness { background: rgba(4, 185, 98, 0.1); }
/* --------------------------------------------------------------------------
Toasts
-------------------------------------------------------------------------- */
.toast-stack {
position: fixed;
bottom: 1.25rem;
right: 1.25rem;
z-index: 3000;
display: flex;
flex-direction: column;
gap: 0.6rem;
max-width: min(420px, 100vw - 2.5rem);
}
.toast {
display: flex;
gap: 0.75rem;
padding: 0.75rem 0.9rem;
border-radius: 8px;
background: var(--bg-card);
color: var(--text);
border-left: 4px solid var(--secondary);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
font-size: 0.9rem;
transition: opacity 0.2s, transform 0.2s;
}
.toast-success { border-left-color: var(--success); }
.toast-error { border-left-color: var(--danger); }
.toast-info { border-left-color: var(--primary); }
.toast .toast-close {
background: transparent;
border: none;
color: var(--text-light);
font-size: 1.1rem;
cursor: pointer;
margin-left: auto;
}
/* --------------------------------------------------------------------------
Modal (overrides Bootstrap-manual markup; use .modal-overlay wrapper)
-------------------------------------------------------------------------- */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1050;
}
.modal-overlay .modal,
.pxe-modal {
background: var(--bg-card-solid);
border-radius: 0.25rem;
max-width: 500px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 16px 38px -12px rgba(0, 0, 0, 0.56),
0 4px 25px rgba(0, 0, 0, 0.12),
0 8px 10px -5px rgba(0, 0, 0, 0.2);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--border);
}
.modal-header h3 { font-size: 16px; font-weight: 600; margin: 0; }
.modal-body { padding: 1.25rem; }
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding: 1rem 1.25rem;
border-top: 1px solid var(--border);
}
/* --------------------------------------------------------------------------
Pagination
-------------------------------------------------------------------------- */
.pagination-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 1rem;
padding-top: 0.75rem;
border-top: 1px solid var(--border);
}
.pagination { display: flex; gap: 0.25rem; list-style: none; margin: 0; padding: 0; }
.pagination button,
.pagination .page-btn {
padding: 0.5rem 0.75rem;
border: 1px solid var(--border);
background: transparent;
border-radius: 0.25rem;
font-size: 13px;
color: var(--text);
cursor: pointer;
}
.pagination button:hover:not(:disabled),
.pagination .page-btn:hover:not(.disabled) {
background: var(--bg);
border-color: var(--primary);
}
.pagination .active { background: var(--primary); color: #fff; border-color: var(--primary); }
.pagination button:disabled,
.pagination .disabled { opacity: 0.4; cursor: default; }
.perpage-select { padding: 0.375rem 0.5rem; font-size: 13px; }
/* --------------------------------------------------------------------------
Stat cards / dashboard grid
-------------------------------------------------------------------------- */
.dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 25px;
}
.stat-card {
background: var(--bg-card);
border-radius: 0.25rem;
padding: 1.25rem;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
[data-theme="light"] .stat-card { border: 1px solid var(--border); }
[data-theme="dark"] .stat-card { border: none; }
.stat-card .label { font-size: 14px; color: var(--text-light); margin-bottom: 0.5rem; }
.stat-card .value { font-size: 2rem; font-weight: 600; color: var(--text); }
.stat-card.success .value { color: var(--success); }
.stat-card.warning .value { color: var(--warning); }
.stat-card.danger .value { color: var(--danger); }
.stat-card.info .value { color: var(--info); }
/* --------------------------------------------------------------------------
Tabs (editor-tabs used by app.js; overrides Bootstrap .nav-tabs)
-------------------------------------------------------------------------- */
.editor-tabs.nav-tabs,
.nav-tabs {
border-bottom: 1px solid var(--border);
gap: 0.25rem;
}
.editor-tabs .nav-link,
.nav-tabs .nav-link {
border: none;
border-bottom: 2px solid transparent;
border-radius: 0;
background: transparent;
color: var(--text-light);
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
padding: 0.6rem 1rem;
}
.editor-tabs .nav-link:hover,
.nav-tabs .nav-link:hover { color: var(--text); }
.editor-tabs .nav-link.active,
.nav-tabs .nav-link.active {
color: var(--primary);
border-bottom-color: var(--primary);
background: transparent;
}
/* --------------------------------------------------------------------------
Content two-column multicol detail flow
-------------------------------------------------------------------------- */
.content-grid { column-count: 2; column-gap: 25px; }
.content-grid .section-card { break-inside: avoid; }
/* --------------------------------------------------------------------------
Responsive: collapse the fixed rail under 768px, keep colors/spacing
-------------------------------------------------------------------------- */
@media (max-width: 768px) {
.sidebar {
transform: translateX(-100%);
transition: transform 0.2s;
}
.sidebar.open { transform: translateX(0); }
.main-content { margin-left: 0; }
.content-grid { column-count: 1; }
}

View File

@@ -58,7 +58,8 @@
{% endif %} {% endif %}
{% endif %} {% endif %}
{% set card_key = s.serial or ('mac-' ~ (s.mac or 'unknown') ~ '-' ~ (s.ip or '')) %} {% set card_key = s.serial or ('mac-' ~ (s.mac or 'unknown') ~ '-' ~ (s.ip or '')) %}
<details class="card border-{{ border }} mb-2 shadow-sm imaging-card{% if is_inferred %} imaging-inferred{% endif %}" <details class="card mb-2 imaging-card{% if is_inferred %} imaging-inferred{% endif %}"
style="border-left:4px solid var(--{{ border }});"
data-serial="{{ s.serial or '' }}" data-serial="{{ s.serial or '' }}"
data-key="{{ card_key }}" data-key="{{ card_key }}"
data-mac="{{ s.mac or '' }}" data-mac="{{ s.mac or '' }}"
@@ -72,8 +73,8 @@
style="line-height:0; flex-shrink:0;" style="line-height:0; flex-shrink:0;"
title="Intune Device ID: {{ s.intune_device_id }}"></div> title="Intune Device ID: {{ s.intune_device_id }}"></div>
{% else %} {% else %}
<div class="d-flex align-items-center justify-content-center bg-light text-muted small" <div class="d-flex align-items-center justify-content-center eyebrow"
style="width:96px; height:96px; border-radius:0.25rem; flex-shrink:0; text-align:center; padding:0.25rem;"> style="width:96px; height:96px; border-radius:0.25rem; flex-shrink:0; text-align:center; padding:0.25rem; background:var(--bg); border:1px solid var(--border);">
{% if is_inferred %}log-only{% else %}no DeviceId{% endif %} {% if is_inferred %}log-only{% else %}no DeviceId{% endif %}
</div> </div>
{% endif %} {% endif %}
@@ -86,27 +87,27 @@
{% elif s.mac %}{{ s.mac }} {% elif s.mac %}{{ s.mac }}
{% else %}{{ s.ip or '(unknown bay)' }}{% endif %} {% else %}{{ s.ip or '(unknown bay)' }}{% endif %}
</strong> </strong>
{% if s.hostname_target and s.serial %}<code class="text-muted small">{{ s.hostname_target }}</code>{% endif %} {% if s.hostname_target and s.serial %}<code class="mono" style="color:var(--text-light);">{{ s.hostname_target }}</code>{% endif %}
{% if s.ip %}<code class="text-muted small">{{ s.ip }}</code>{% endif %} {% if s.ip %}<code class="mono" style="color:var(--text-light);">{{ s.ip }}</code>{% endif %}
{% if s.pctype %}<span class="badge bg-info text-dark">{{ s.pctype }}</span>{% endif %} {% if s.pctype %}<span class="badge badge-info">{{ s.pctype }}</span>{% endif %}
{% if s.machinenumber %}<span class="badge bg-secondary">#{{ s.machinenumber }}</span>{% endif %} {% if s.machinenumber %}<span class="badge badge-secondary">#{{ s.machinenumber }}</span>{% endif %}
{% if is_inferred %}<span class="badge bg-warning text-dark">log-inferred</span>{% endif %} {% if is_inferred %}<span class="badge badge-warning">log-inferred</span>{% endif %}
<span class="badge bg-{{ border }} ms-auto">{{ s.status or 'in_progress' }}</span> <span class="badge badge-{{ border }} ms-auto">{{ s.status or 'in_progress' }}</span>
</div> </div>
<div class="d-flex justify-content-between align-items-baseline mt-1"> <div class="d-flex justify-content-between align-items-baseline mt-2">
<div> <div class="d-flex align-items-center gap-2">
<strong>{{ friendly[0] }}</strong> <strong>{{ friendly[0] }}</strong>
{% if not is_inferred %} {% if not is_inferred %}
<span class="badge bg-secondary ms-1">{{ stage_idx }}/{{ stage_total or '?' }}</span> <span class="badge badge-secondary">{{ stage_idx }}/{{ stage_total or '?' }}</span>
{% endif %} {% endif %}
</div> </div>
{% if not is_inferred %}<span class="text-muted small">{{ pct }}%</span>{% endif %} {% if not is_inferred %}<span class="eyebrow">{{ pct }}%</span>{% endif %}
</div> </div>
{% if not is_inferred %} {% if not is_inferred %}
<div class="progress mt-1" style="height:0.7rem;"> <div class="progress mt-1" style="height:0.7rem; background:var(--bg); border-radius:0.25rem;">
<div class="progress-bar bg-{{ border }} {% if not is_done and not is_failed %}progress-bar-striped progress-bar-animated{% endif %}" <div class="progress-bar bg-{{ border }} {% if not is_done and not is_failed %}progress-bar-striped progress-bar-animated{% endif %}"
role="progressbar" style="width: {{ pct }}%;" role="progressbar" style="width: {{ pct }}%; background-color:var(--{{ border }});"
aria-valuenow="{{ pct }}" aria-valuemin="0" aria-valuemax="100"></div> aria-valuenow="{{ pct }}" aria-valuemin="0" aria-valuemax="100"></div>
</div> </div>
{% endif %} {% endif %}
@@ -114,46 +115,44 @@
</div> </div>
</summary> </summary>
<div class="card-body pt-0 pb-3 border-top"> <div class="card-body pt-0 pb-3" style="border-top:1px solid var(--border);">
{% if friendly[1] %}<div class="small text-muted mt-2">{{ friendly[1] }}</div>{% endif %} {% if friendly[1] %}<div class="mt-2" style="color:var(--text-light); font-size:0.85rem;">{{ friendly[1] }}</div>{% endif %}
{% if s.intune_device_id %} {% if s.intune_device_id %}
<div class="small mt-2" style="font-size:0.75rem;"> <div class="d-flex flex-wrap align-items-center gap-2 mt-3">
<span class="text-muted">Intune:</span> <code>{{ s.intune_device_id }}</code> <span class="eyebrow">Intune</span>
<button type="button" class="btn btn-sm btn-outline-secondary py-0 px-1 copy-btn" <code class="mono">{{ s.intune_device_id }}</code>
style="font-size:0.65rem; line-height:1; transition: all 0.2s;" <button type="button" class="btn btn-sm btn-outline-secondary btn-row-action copy-btn"
data-copy-text="{{ s.intune_device_id }}">copy</button> data-copy-text="{{ s.intune_device_id }}">copy</button>
<a class="btn btn-sm btn-outline-primary py-0 px-1" <a class="btn btn-sm btn-outline-primary btn-row-action"
style="font-size:0.65rem; line-height:1;"
target="_blank" rel="noopener" target="_blank" rel="noopener"
href="https://portal.azure.us/?feature.msaljs=false#view/Microsoft_Intune_Devices/DeviceSettingsMenuBlade/~/properties/aadDeviceId/{{ s.intune_device_id }}">set category</a> href="https://portal.azure.us/?feature.msaljs=false#view/Microsoft_Intune_Devices/DeviceSettingsMenuBlade/~/properties/aadDeviceId/{{ s.intune_device_id }}">set category</a>
<a class="btn btn-sm btn-outline-warning py-0 px-1" <a class="btn btn-sm btn-outline-warning btn-row-action"
style="font-size:0.65rem; line-height:1;"
target="_blank" rel="noopener" target="_blank" rel="noopener"
href="https://arts.dw.geaerospace.net/requests/type">ARTS request</a> href="https://arts.dw.geaerospace.net/requests/type">ARTS request</a>
</div> </div>
{% endif %} {% endif %}
<div class="text-muted mt-1" style="font-size:0.7rem;"> <div class="mt-2 d-flex flex-wrap gap-3" style="color:var(--text-light); font-size:0.75rem;">
<span class="me-3">started <code>{{ s.started_at or '-' }}</code></span> <span>started <code class="mono">{{ s.started_at or '-' }}</code></span>
<span class="me-3">last <code>{{ s.last_updated or '-' }}</code></span> <span>last <code class="mono">{{ s.last_updated or '-' }}</code></span>
<span class="me-3">MAC <code>{{ s.mac or '-' }}</code></span> <span>MAC <code class="mono">{{ s.mac or '-' }}</code></span>
{% if s.current_stage %}<span style="font-family:monospace;">{{ s.current_stage }}</span>{% endif %} {% if s.current_stage %}<span class="mono">{{ s.current_stage }}</span>{% endif %}
</div> </div>
{% if s.error %} {% if s.error %}
<div class="alert alert-danger small py-2 mb-2 mt-3"> <div class="alert alert-danger py-2 mb-2 mt-3" style="font-size:0.85rem;">
<strong>Error:</strong> {{ s.error }} <strong>Error:</strong> {{ s.error }}
</div> </div>
{% endif %} {% endif %}
{% if not is_inferred %} {% if not is_inferred %}
<div class="mt-3 laps-card" data-serial="{{ s.serial }}"> <div class="mt-3 laps-card" data-serial="{{ s.serial }}">
<div class="text-muted small mb-1">LAPS password QR (paste -> scan on bay - persists until cleared)</div> <div class="eyebrow mb-2">LAPS password QR (paste -> scan on bay - persists until cleared)</div>
<div class="d-flex align-items-center gap-2"> <div class="d-flex flex-wrap align-items-center gap-2">
<input type="text" <input type="text"
class="form-control form-control-sm laps-input" class="form-control form-control-sm laps-input mono"
style="font-family: monospace; max-width: 22rem;" style="max-width: 22rem;"
placeholder="paste LAPS password from Intune portal here" placeholder="paste LAPS password from Intune portal here"
autocomplete="off" autocomplete="off"
value="{{ s.laps_password or '' }}"> value="{{ s.laps_password or '' }}">
@@ -166,8 +165,8 @@
{% if s.log_tail %} {% if s.log_tail %}
<details class="mt-3"> <details class="mt-3">
<summary class="text-muted small">{% if is_inferred %}Evidence{% else %}Log tail{% endif %} ({{ s.log_tail | length }} line{{ 's' if s.log_tail | length != 1 }})</summary> <summary class="eyebrow" style="cursor:pointer;">{% if is_inferred %}Evidence{% else %}Log tail{% endif %} ({{ s.log_tail | length }} line{{ 's' if s.log_tail | length != 1 }})</summary>
<pre class="bg-light p-2 mt-2 small mb-0" style="max-height: 12rem; overflow-y: auto;">{% for line in s.log_tail %}{{ line }} <pre class="mono p-2 mt-2 mb-0" style="max-height: 12rem; overflow-y: auto; background:var(--bg); border:1px solid var(--border); border-radius:0.25rem; font-size:0.78rem;">{% for line in s.log_tail %}{{ line }}
{% endfor %}</pre> {% endfor %}</pre>
</details> </details>
{% endif %} {% endif %}

View File

@@ -1,61 +1,60 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Audit Log - PXE Server Manager{% endblock %} {% block title %}Audit Log - PXE Server Manager{% endblock %}
{% block content %} {% block content %}
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="page-header">
<h2 class="mb-0">Audit Log</h2> <h2>Audit Log</h2>
<span class="badge bg-secondary fs-6">{{ entries|length }} entries</span> <div class="header-actions">
</div> <span class="badge badge-secondary badge-lg">{{ entries|length }} entries</span>
</div>
<div class="card"> </div>
<div class="card-header d-flex align-items-center">
Activity History <div class="section-card">
</div> <div class="section-title">Activity History</div>
<div class="card-body p-0"> {% if entries %}
{% if entries %} <div class="table-container">
<div class="table-responsive"> <table class="data-table">
<table class="table table-hover table-sm mb-0"> <thead>
<thead class="table-light"> <tr>
<tr> <th style="width: 180px;">Timestamp</th>
<th style="width: 180px;">Timestamp</th> <th style="width: 130px;">Source</th>
<th style="width: 130px;">Source</th> <th style="width: 180px;">Action</th>
<th style="width: 180px;">Action</th> <th>Details</th>
<th>Details</th> </tr>
</tr> </thead>
</thead> <tbody>
<tbody> {% for entry in entries %}
{% for entry in entries %} <tr>
<tr> {% set parts = entry.split(' ', 1) %}
{% set parts = entry.split(' ', 1) %} {% if parts|length == 2 %}
{% if parts|length == 2 %} {% set meta = parts[1].split('] ', 1) %}
{% set meta = parts[1].split('] ', 1) %} <td class="mono">{{ parts[0] }}</td>
<td><small class="text-muted">{{ parts[0] }}</small></td> {% if meta|length == 2 %}
{% if meta|length == 2 %} <td class="mono">{{ meta[0].lstrip('[') }}</td>
<td><code>{{ meta[0].lstrip('[') }}</code></td> {% set action_detail = meta[1].split(': ', 1) %}
{% set action_detail = meta[1].split(': ', 1) %} {% if action_detail|length == 2 %}
{% if action_detail|length == 2 %} <td><span class="badge badge-primary">{{ action_detail[0] }}</span></td>
<td><span class="badge bg-primary">{{ action_detail[0] }}</span></td> <td>{{ action_detail[1] }}</td>
<td>{{ action_detail[1] }}</td> {% else %}
{% else %} <td colspan="2">{{ meta[1] }}</td>
<td colspan="2">{{ meta[1] }}</td> {% endif %}
{% endif %} {% else %}
{% else %} <td colspan="3">{{ parts[1] }}</td>
<td colspan="3">{{ parts[1] }}</td> {% endif %}
{% endif %} {% else %}
{% else %} <td colspan="4">{{ entry }}</td>
<td colspan="4">{{ entry }}</td> {% endif %}
{% endif %} </tr>
</tr> {% endfor %}
{% endfor %} </tbody>
</tbody> </table>
</table> </div>
</div> {% else %}
{% else %} <div class="text-center text-muted py-5">
<div class="text-center text-muted py-5"> <i class="bi bi-list-check" style="font-size: 2rem; opacity: 0.4;"></i>
<p class="mt-2">No audit log entries yet.</p> <p class="mt-2 mb-1">No audit log entries yet.</p>
<p class="small">Actions like image imports, unattend edits, and backup operations will be logged here.</p> <p class="small mb-0">Actions like image imports, unattend edits, and backup operations will be logged here.</p>
</div> </div>
{% endif %} {% endif %}
</div> </div>
</div> {% endblock %}
{% endblock %}

View File

@@ -1,132 +1,140 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Clonezilla Backups - PXE Server Manager{% endblock %} {% block title %}Clonezilla Backups - PXE Server Manager{% endblock %}
{% block content %} {% block content %}
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="page-header">
<h2 class="mb-0">Clonezilla Backups</h2> <h2>Clonezilla Backups</h2>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#uploadModal"> <div class="header-actions">
Upload Backup <button class="pxe-btn pxe-btn-primary" data-bs-toggle="modal" data-bs-target="#uploadModal">
</button> <i class="bi bi-upload"></i> Upload Backup
</div> </button>
</div>
<div class="card"> </div>
<div class="card-header d-flex align-items-center">
Machine Backups <div class="card">
<span class="badge bg-secondary ms-2">{{ backups|length }}</span> <div class="card-header d-flex align-items-center">
</div> <i class="bi bi-hdd-stack me-2"></i>
<div class="card-body p-0"> Machine Backups
{% if backups %} <span class="badge badge-secondary ms-2">{{ backups|length }}</span>
<table class="table table-hover mb-0"> </div>
<thead class="table-light"> <div class="card-body p-0">
<tr> {% if backups %}
<th>Machine #</th> <div class="table-container">
<th>Filename</th> <table class="data-table mb-0">
<th>Size</th> <thead>
<th>Last Modified</th> <tr>
<th class="text-end">Actions</th> <th>Machine #</th>
</tr> <th>Filename</th>
</thead> <th>Size</th>
<tbody> <th>Last Modified</th>
{% for b in backups %} <th class="text-end">Actions</th>
<tr> </tr>
<td><strong>{{ b.machine }}</strong></td> </thead>
<td><code>{{ b.filename }}</code></td> <tbody>
<td>{{ "%.1f"|format(b.size / 1073741824) }} GB</td> {% for b in backups %}
<td>{{ b.modified | timestamp_fmt }}</td> <tr>
<td class="text-end text-nowrap"> <td><strong>{{ b.machine }}</strong></td>
<a href="{{ url_for('clonezilla_download', filename=b.filename) }}" <td><span class="mono">{{ b.filename }}</span></td>
class="btn btn-sm btn-outline-primary" title="Download"> <td>{{ "%.1f"|format(b.size / 1073741824) }} GB</td>
Download <td>{{ b.modified | timestamp_fmt }}</td>
</a> <td class="actions text-end text-nowrap">
<button type="button" class="btn btn-sm btn-outline-danger" <a href="{{ url_for('clonezilla_download', filename=b.filename) }}"
data-bs-toggle="modal" data-bs-target="#deleteModal" class="pxe-btn pxe-btn-secondary btn-row-action" title="Download">
data-filename="{{ b.filename }}" data-machine="{{ b.machine }}" title="Delete"> <i class="bi bi-download"></i> Download
Delete </a>
</button> <button type="button" class="pxe-btn pxe-btn-danger btn-row-action"
</td> data-bs-toggle="modal" data-bs-target="#deleteModal"
</tr> data-filename="{{ b.filename }}" data-machine="{{ b.machine }}" title="Delete">
{% endfor %} <i class="bi bi-trash"></i> Delete
</tbody> </button>
</table> </td>
{% else %} </tr>
<div class="text-center text-muted py-5"> {% endfor %}
<p class="mt-2">No backups found. Upload a Clonezilla backup .zip to get started.</p> </tbody>
</div> </table>
{% endif %} </div>
</div> {% else %}
</div> <div class="text-center text-muted py-5">
<i class="bi bi-hdd-stack" style="font-size: 2rem; opacity: 0.4;"></i>
<div class="card mt-3"> <p class="mt-2 mb-0">No backups found. Upload a Clonezilla backup .zip to get started.</p>
<div class="card-body"> </div>
<h6 class="card-title">Backup Naming Convention</h6> {% endif %}
<p class="card-text mb-0"> </div>
Name backup files with the machine number (e.g., <code>6501.zip</code>). </div>
The Samba share <code>\\pxe-server\clonezilla</code> is also available on the network for direct Clonezilla save/restore operations.
</p> <div class="section-card mt-3">
</div> <div class="section-title">Backup Naming Convention</div>
</div> <p class="mb-0">
Name backup files with the machine number (e.g., <span class="mono">6501.zip</span>).
<!-- Upload Modal --> The Samba share <span class="mono">\\pxe-server\clonezilla</span> is also available on the network for direct Clonezilla save/restore operations.
<div class="modal fade" id="uploadModal" tabindex="-1"> </p>
<div class="modal-dialog"> </div>
<div class="modal-content">
<form action="{{ url_for('clonezilla_upload') }}" method="post" enctype="multipart/form-data"> <!-- Upload Modal -->
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}"> <div class="modal fade" id="uploadModal" tabindex="-1">
<div class="modal-header"> <div class="modal-dialog">
<h5 class="modal-title">Upload Backup</h5> <div class="modal-content">
<button type="button" class="btn-close" data-bs-dismiss="modal"></button> <form action="{{ url_for('clonezilla_upload') }}" method="post" enctype="multipart/form-data">
</div> <input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="modal-body"> <div class="modal-header">
<div class="mb-3"> <h5 class="modal-title">Upload Backup</h5>
<label for="backupFile" class="form-label">Backup File (.zip)</label> <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
<input type="file" class="form-control" id="backupFile" name="backup_file" </div>
accept=".zip" required> <div class="modal-body">
<div class="form-text"> <div class="form-field mb-0">
Name the file with the machine number (e.g., <code>6501.zip</code>). <label for="backupFile" class="form-label">Backup File (.zip)</label>
</div> <input type="file" class="form-control" id="backupFile" name="backup_file"
</div> accept=".zip" required>
</div> <div class="form-text">
<div class="modal-footer"> Name the file with the machine number (e.g., <span class="mono">6501.zip</span>).
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> </div>
<button type="submit" class="btn btn-primary">Upload</button> </div>
</div> </div>
</form> <div class="modal-footer">
</div> <button type="button" class="pxe-btn pxe-btn-ghost" data-bs-dismiss="modal">Cancel</button>
</div> <button type="submit" class="pxe-btn pxe-btn-primary">
</div> <i class="bi bi-upload"></i> Upload
</button>
<!-- Delete Confirmation Modal --> </div>
<div class="modal fade" id="deleteModal" tabindex="-1"> </form>
<div class="modal-dialog"> </div>
<div class="modal-content"> </div>
<form id="deleteForm" method="post"> </div>
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="modal-header"> <!-- Delete Confirmation Modal -->
<h5 class="modal-title">Confirm Delete</h5> <div class="modal fade" id="deleteModal" tabindex="-1">
<button type="button" class="btn-close" data-bs-dismiss="modal"></button> <div class="modal-dialog">
</div> <div class="modal-content">
<div class="modal-body"> <form id="deleteForm" method="post">
<p>Are you sure you want to delete the backup for machine <strong id="deleteMachine"></strong>?</p> <input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<p class="text-muted mb-0">This action cannot be undone.</p> <div class="modal-header">
</div> <h5 class="modal-title">Confirm Delete</h5>
<div class="modal-footer"> <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> </div>
<button type="submit" class="btn btn-danger">Delete</button> <div class="modal-body">
</div> <p>Are you sure you want to delete the backup for machine <strong id="deleteMachine"></strong>?</p>
</form> <p class="text-muted mb-0">This action cannot be undone.</p>
</div> </div>
</div> <div class="modal-footer">
</div> <button type="button" class="pxe-btn pxe-btn-ghost" data-bs-dismiss="modal">Cancel</button>
{% endblock %} <button type="submit" class="pxe-btn pxe-btn-danger">
<i class="bi bi-trash"></i> Delete
{% block extra_scripts %} </button>
<script> </div>
document.getElementById('deleteModal').addEventListener('show.bs.modal', function (event) { </form>
var btn = event.relatedTarget; </div>
var filename = btn.getAttribute('data-filename'); </div>
var machine = btn.getAttribute('data-machine'); </div>
document.getElementById('deleteMachine').textContent = machine; {% endblock %}
document.getElementById('deleteForm').action = '/backups/delete/' + encodeURIComponent(filename);
}); {% block extra_scripts %}
</script> <script>
{% endblock %} document.getElementById('deleteModal').addEventListener('show.bs.modal', function (event) {
var btn = event.relatedTarget;
var filename = btn.getAttribute('data-filename');
var machine = btn.getAttribute('data-machine');
document.getElementById('deleteMachine').textContent = machine;
document.getElementById('deleteForm').action = '/backups/delete/' + encodeURIComponent(filename);
});
</script>
{% endblock %}

View File

@@ -1,228 +1,178 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" data-bs-theme="light"> <html lang="en" data-bs-theme="light">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token() }}"> <meta name="csrf-token" content="{{ csrf_token() }}">
<title>{% block title %}PXE Server Manager{% endblock %}</title> <title>{% block title %}PXE Server Manager{% endblock %}</title>
<link rel="icon" href="{{ url_for('static', filename='favicon.ico') }}" type="image/x-icon"> <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"> <link href="{{ url_for('static', filename='bootstrap.min.css') }}" rel="stylesheet">
<style> <link href="{{ url_for('static', filename='bootstrap-icons.min.css') }}" rel="stylesheet">
:root { <!-- pxe-theme.css loads AFTER bootstrap so its token+component rules win -->
--sidebar-width: 280px; <link href="{{ url_for('static', filename='pxe-theme.css') }}" rel="stylesheet">
} <script>
body { // Set the theme before first paint to avoid an unstyled flash.
min-height: 100vh; (function () {
} var KEY = 'pxe-theme';
.sidebar { var stored = null;
width: var(--sidebar-width); try { stored = localStorage.getItem(KEY); } catch (e) {}
min-height: 100vh; var theme = (stored === 'light' || stored === 'dark')
background-color: #1a1d21; ? stored
position: fixed; : (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
top: 0; document.documentElement.setAttribute('data-theme', theme);
left: 0; document.documentElement.setAttribute('data-bs-theme', theme);
z-index: 100; })();
overflow-y: auto; </script>
} {% block extra_head %}{% endblock %}
.sidebar .nav-link { </head>
color: #adb5bd; <body>
padding: 0.6rem 1.25rem;
font-size: 0.9rem; <!-- Sidebar -->
border-radius: 0; <nav class="sidebar d-flex flex-column">
} <div class="sidebar-header">
.sidebar .nav-link:hover, <img class="sidebar-logo" src="{{ url_for('static', filename='ge-aerospace-logo.svg') }}" alt="GE Aerospace">
.sidebar .nav-link.active { <h1>PXE Manager</h1>
color: #fff; </div>
background-color: rgba(255,255,255,0.08);
} <ul class="sidebar-nav nav flex-column mt-2">
.sidebar-heading { <li class="nav-item">
font-size: 0.7rem; <a class="nav-link {% if request.endpoint == 'dashboard' %}active{% endif %}"
text-transform: uppercase; href="{{ url_for('dashboard') }}">
letter-spacing: 0.1em; <i class="bi bi-speedometer2"></i> Dashboard
color: #6c757d; </a>
padding: 1rem 1.25rem 0.4rem; </li>
font-weight: 600; <li class="nav-item">
} <a class="nav-link {% if request.endpoint == 'images_import' %}active{% endif %}"
.sidebar .brand { href="{{ url_for('images_import') }}">
padding: 1.2rem 1.25rem; <i class="bi bi-box-arrow-in-down"></i> Image Import
font-size: 1.1rem; </a>
font-weight: 700; </li>
color: #fff; <li class="nav-item">
border-bottom: 1px solid rgba(255,255,255,0.08); <a class="nav-link {% if request.endpoint == 'imaging_dashboard' %}active{% endif %}"
display: flex; href="{{ url_for('imaging_dashboard') }}">
flex-direction: column; <i class="bi bi-activity"></i> Imaging Progress
align-items: center; </a>
gap: 0.3rem; </li>
} </ul>
.main-content {
margin-left: var(--sidebar-width); <div class="nav-section">Tools</div>
padding: 2rem; <ul class="sidebar-nav nav flex-column">
} <li class="nav-item">
.status-dot { <a class="nav-link {% if request.endpoint == 'startnet_editor' %}active{% endif %}"
display: inline-block; href="{{ url_for('startnet_editor') }}">
width: 10px; <i class="bi bi-terminal"></i> startnet.cmd
height: 10px; </a>
border-radius: 50%; </li>
} <li class="nav-item">
.status-dot.active { <a class="nav-link {% if request.endpoint == 'clonezilla_backups' %}active{% endif %}"
background-color: #198754; href="{{ url_for('clonezilla_backups') }}">
} <i class="bi bi-hdd-stack"></i> Clonezilla Backups
.status-dot.inactive { </a>
background-color: #dc3545; </li>
} <li class="nav-item">
.status-dot.unknown { <a class="nav-link {% if request.endpoint == 'blancco_reports' %}active{% endif %}"
background-color: #ffc107; href="{{ url_for('blancco_reports') }}">
} <i class="bi bi-shield-check"></i> Blancco Reports
.card { </a>
border: 1px solid #dee2e6; </li>
} <li class="nav-item">
.card-header { <a class="nav-link {% if request.endpoint == 'enrollment' %}active{% endif %}"
font-weight: 600; href="{{ url_for('enrollment') }}">
} <i class="bi bi-pc-display"></i> Enrollment
.table th { </a>
font-weight: 600; </li>
} <li class="nav-item">
.btn-row-action { <a class="nav-link {% if request.endpoint == 'audit_log' %}active{% endif %}"
padding: 0.2rem 0.5rem; href="{{ url_for('audit_log') }}">
font-size: 0.8rem; <i class="bi bi-list-check"></i> Audit Log
} </a>
.drag-handle { </li>
cursor: grab; </ul>
color: #6c757d;
} <div class="nav-section">GE Aerospace Images</div>
.drag-handle:active { <ul class="sidebar-nav nav flex-column">
cursor: grabbing; {% for it in all_image_types if it.startswith('gea-') %}
} <li class="nav-item">
.nav-section-divider { <a class="nav-link {% if request.endpoint == 'unattend_editor' and image_type is defined and image_type == it %}active{% endif %}"
border-top: 1px solid rgba(255,255,255,0.06); href="{{ url_for('unattend_editor', image_type=it) }}">
margin-top: 0.5rem; <i class="bi bi-windows"></i> {{ all_friendly_names[it] }}
} </a>
</style> </li>
{% block extra_head %}{% endblock %} <li class="nav-item">
</head> <a class="nav-link nav-sublink {% if request.endpoint == 'image_config' and image_type is defined and image_type == it %}active{% endif %}"
<body> href="{{ url_for('image_config', image_type=it) }}">
<i class="bi bi-gear"></i> Configuration
<!-- Sidebar --> </a>
<nav class="sidebar d-flex flex-column"> </li>
<div class="brand"> {% endfor %}
<img src="{{ url_for('static', filename='ge-aerospace-logo.svg') }}" alt="GE Aerospace" style="height: 28px; filter: brightness(0) invert(1);"> </ul>
PXE Manager
</div> <!-- Footer pinned to bottom -->
<ul class="nav flex-column mt-2"> <div class="sidebar-footer mt-auto">
<li class="nav-item"> <button type="button" id="themeToggle" class="theme-toggle">
<a class="nav-link {% if request.endpoint == 'dashboard' %}active{% endif %}" <i class="bi bi-moon-stars" id="themeToggleIcon"></i>
href="{{ url_for('dashboard') }}"> <span id="themeToggleLabel">Dark</span>
Dashboard </button>
</a> </div>
</li> </nav>
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'images_import' %}active{% endif %}" <!-- Main content -->
href="{{ url_for('images_import') }}"> <div class="main-content">
Image Import {% with messages = get_flashed_messages(with_categories=true) %}
</a> {% if messages %}
</li> {% for category, message in messages %}
<li class="nav-item"> <div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
<a class="nav-link {% if request.endpoint == 'imaging_dashboard' %}active{% endif %}" {{ message }}
href="{{ url_for('imaging_dashboard') }}"> <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
Imaging Progress </div>
</a> {% endfor %}
</li> {% endif %}
</ul> {% endwith %}
<div class="nav-section-divider"></div> {% block content %}{% endblock %}
<div class="sidebar-heading">Tools</div> </div>
<ul class="nav flex-column">
<li class="nav-item"> <script>
<a class="nav-link {% if request.endpoint == 'startnet_editor' %}active{% endif %}" // Theme toggle: flips data-theme (and data-bs-theme) and persists to localStorage.
href="{{ url_for('startnet_editor') }}"> (function () {
startnet.cmd var KEY = 'pxe-theme';
</a> var btn = document.getElementById('themeToggle');
</li> var icon = document.getElementById('themeToggleIcon');
<li class="nav-item"> var label = document.getElementById('themeToggleLabel');
<a class="nav-link {% if request.endpoint == 'clonezilla_backups' %}active{% endif %}"
href="{{ url_for('clonezilla_backups') }}"> function paintToggle(theme) {
Clonezilla Backups // Label/icon advertise the theme you would switch TO.
</a> if (theme === 'dark') {
</li> if (icon) icon.className = 'bi bi-sun';
<li class="nav-item"> if (label) label.textContent = 'Light';
<a class="nav-link {% if request.endpoint == 'blancco_reports' %}active{% endif %}" } else {
href="{{ url_for('blancco_reports') }}"> if (icon) icon.className = 'bi bi-moon-stars';
Blancco Reports if (label) label.textContent = 'Dark';
</a> }
</li> }
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'enrollment' %}active{% endif %}" function apply(theme) {
href="{{ url_for('enrollment') }}"> document.documentElement.setAttribute('data-theme', theme);
Enrollment document.documentElement.setAttribute('data-bs-theme', theme);
</a> try { localStorage.setItem(KEY, theme); } catch (e) {}
</li> paintToggle(theme);
<li class="nav-item"> }
<a class="nav-link {% if request.endpoint == 'audit_log' %}active{% endif %}"
href="{{ url_for('audit_log') }}"> paintToggle(document.documentElement.getAttribute('data-theme') || 'light');
Audit Log
</a> if (btn) {
</li> btn.addEventListener('click', function () {
</ul> var current = document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
apply(current === 'dark' ? 'light' : 'dark');
<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-') %} </script>
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'unattend_editor' and image_type is defined and image_type == it %}active{% endif %}" <script src="{{ url_for('static', filename='bootstrap.bundle.min.js') }}"></script>
href="{{ url_for('unattend_editor', image_type=it) }}"> <script src="{{ url_for('static', filename='app.js') }}"></script>
{{ all_friendly_names[it] }} <script src="{{ url_for('static', filename='qrcode.min.js') }}"></script>
</a> <script src="{{ url_for('static', filename='qr-render.js') }}"></script>
</li> {% block extra_scripts %}{% endblock %}
<li class="nav-item"> </body>
<a class="nav-link {% if request.endpoint == 'image_config' and image_type is defined and image_type == it %}active{% endif %}" </html>
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>
<script src="{{ url_for('static', filename='qrcode.min.js') }}"></script>
<script src="{{ url_for('static', filename='qr-render.js') }}"></script>
{% block extra_scripts %}{% endblock %}
</body>
</html>

View File

@@ -1,241 +1,278 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Dashboard - PXE Server Manager{% endblock %} {% block title %}Dashboard - PXE Server Manager{% endblock %}
{% block content %} {% block content %}
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="page-header">
<h2 class="mb-0">Dashboard</h2> <h2>Dashboard</h2>
<button class="btn btn-outline-secondary btn-sm" onclick="location.reload()"> <div class="header-actions">
Refresh <a href="{{ url_for('images_import') }}" class="btn btn-outline-secondary btn-sm">
</button> <i class="bi bi-box-arrow-in-down"></i> Image Import
</div> </a>
<button type="button" class="btn btn-success btn-sm" data-bs-toggle="modal" data-bs-target="#newImageModal">
<!-- Services --> <i class="bi bi-plus-lg"></i> New image type
<div class="card mb-4"> </button>
<div class="card-header d-flex align-items-center"> <button class="btn btn-outline-secondary btn-sm" onclick="location.reload()">
PXE Services <i class="bi bi-arrow-clockwise"></i> Refresh
</div> </button>
<div class="card-body p-0"> </div>
<table class="table table-hover mb-0"> </div>
<thead class="table-light">
<tr> <!-- At-a-glance stats -->
<th>Service</th> <div class="dashboard-grid mb-4">
<th>Status</th> {% set services_up = services | selectattr('active') | list | length %}
<th>State</th> {% set services_total = services | list | length %}
</tr> <div class="stat-card {{ 'success' if services_up == services_total else 'danger' }}">
</thead> <div class="label"><i class="bi bi-hdd-network"></i> Services running</div>
<tbody> <div class="value">{{ services_up }} / {{ services_total }}</div>
{% for svc in services %} </div>
<tr> {% set images_total = images | list | length %}
<td> <div class="stat-card info">
<strong>{{ svc.name }}</strong> <div class="label"><i class="bi bi-hdd-stack"></i> Image types</div>
</td> <div class="value">{{ images_total }}</div>
<td> </div>
<span class="status-dot {{ 'active' if svc.active else 'inactive' }}"></span> {% set images_ready = images | selectattr('has_content') | list | length %}
{{ "Running" if svc.active else "Stopped" }} <div class="stat-card success">
</td> <div class="label"><i class="bi bi-check2-circle"></i> Images with content</div>
<td><code>{{ svc.state }}</code></td> <div class="value">{{ images_ready }}</div>
</tr> </div>
{% endfor %} {% set unattend_missing = images | rejectattr('has_unattend') | list | length %}
</tbody> <div class="stat-card {{ 'warning' if unattend_missing else 'success' }}">
</table> <div class="label"><i class="bi bi-file-earmark-code"></i> Missing unattend.xml</div>
</div> <div class="value">{{ unattend_missing }}</div>
</div> </div>
</div>
<!-- Images -->
<div class="card"> <!-- Services -->
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card">
<span>Deployment Images</span> <div class="card-header">
<button type="button" class="btn btn-sm btn-success" data-bs-toggle="modal" data-bs-target="#newImageModal"> <span><i class="bi bi-hdd-network"></i> PXE Services</span>
+ New image type </div>
</button> <div class="card-body p-0">
</div> <div class="table-container">
<div class="card-body p-0"> <table class="table mb-0">
<table class="table table-hover mb-0"> <thead>
<thead class="table-light"> <tr>
<tr> <th>Service</th>
<th>Image</th> <th>Status</th>
<th>Deploy Content</th> <th>State</th>
<th>unattend.xml</th> </tr>
<th>Path</th> </thead>
<th class="text-end">Actions</th> <tbody>
</tr> {% for svc in services %}
</thead> <tr>
<tbody> <td>
{% for img in images %} <strong>{{ svc.name }}</strong>
<tr> </td>
<td> <td>
<strong>{{ img.friendly_name }}</strong><br> <span class="status-dot {{ 'active' if svc.active else 'inactive' }}"></span>
<small class="text-muted">{{ img.image_type }}</small> {{ "Running" if svc.active else "Stopped" }}
</td> </td>
<td> <td><span class="mono">{{ svc.state }}</span></td>
{% if img.has_content %} </tr>
<span class="badge bg-success">Present</span> {% endfor %}
{% else %} </tbody>
<span class="badge bg-secondary">Empty</span> </table>
{% endif %} </div>
</td> </div>
<td> </div>
{% if img.has_unattend %}
<span class="badge bg-success">Exists</span> <!-- Images -->
{% else %} <div class="card">
<span class="badge bg-warning text-dark">Missing</span> <div class="card-header">
{% endif %} <span><i class="bi bi-hdd-stack"></i> Deployment Images</span>
</td> <button type="button" class="btn btn-sm btn-success" data-bs-toggle="modal" data-bs-target="#newImageModal">
<td><code class="small">{{ img.deploy_path }}</code></td> <i class="bi bi-plus-lg"></i> New image type
<td class="text-end"> </button>
<a href="{{ url_for('image_config', image_type=img.image_type) }}" </div>
class="btn btn-sm btn-outline-secondary me-1"> <div class="card-body p-0">
Config <div class="table-container">
</a> <table class="table mb-0">
<a href="{{ url_for('unattend_editor', image_type=img.image_type) }}" <thead>
class="btn btn-sm btn-outline-primary me-1"> <tr>
Unattend <th>Image</th>
</a> <th>Deploy Content</th>
<button type="button" class="btn btn-sm btn-outline-secondary me-1" <th>unattend.xml</th>
data-bs-toggle="modal" data-bs-target="#cloneImageModal" <th>Path</th>
data-src-key="{{ img.image_type }}" <th class="text-end">Actions</th>
data-src-friendly="{{ img.friendly_name }}"> </tr>
Clone </thead>
</button> <tbody>
<button type="button" class="btn btn-sm btn-outline-danger" {% for img in images %}
data-bs-toggle="modal" data-bs-target="#deleteImageModal" <tr>
data-src-key="{{ img.image_type }}" <td>
data-src-friendly="{{ img.friendly_name }}"> <strong>{{ img.friendly_name }}</strong><br>
Delete <small class="text-muted mono">{{ img.image_type }}</small>
</button> </td>
</td> <td>
</tr> {% if img.has_content %}
{% endfor %} <span class="badge bg-success">Present</span>
</tbody> {% else %}
</table> <span class="badge bg-secondary">Empty</span>
</div> {% endif %}
</div> </td>
<td>
<!-- New image type modal --> {% if img.has_unattend %}
<div class="modal fade" id="newImageModal" tabindex="-1"> <span class="badge bg-success">Exists</span>
<div class="modal-dialog"> {% else %}
<form method="post" action="{{ url_for('images_new') }}"> <span class="badge bg-warning">Missing</span>
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}"> {% endif %}
<div class="modal-content"> </td>
<div class="modal-header"> <td><span class="mono">{{ img.deploy_path }}</span></td>
<h5 class="modal-title">Create image type</h5> <td class="actions text-end">
<button type="button" class="btn-close" data-bs-dismiss="modal"></button> <a href="{{ url_for('image_config', image_type=img.image_type) }}"
</div> class="btn btn-sm btn-outline-secondary btn-row-action">
<div class="modal-body"> <i class="bi bi-gear"></i> Config
<div class="mb-3"> </a>
<label class="form-label">Key</label> <a href="{{ url_for('unattend_editor', image_type=img.image_type) }}"
<input type="text" class="form-control font-monospace" name="key" class="btn btn-sm btn-outline-primary btn-row-action">
pattern="[a-z][a-z0-9-]{1,63}" required <i class="bi bi-file-earmark-code"></i> Unattend
placeholder="gea-shopfloor-newtype"> </a>
<div class="form-text">Lowercase + hyphens. Used as directory name + URL path. 2-64 chars, must start with a letter.</div> <button type="button" class="btn btn-sm btn-outline-secondary btn-row-action"
</div> data-bs-toggle="modal" data-bs-target="#cloneImageModal"
<div class="mb-3"> data-src-key="{{ img.image_type }}"
<label class="form-label">Friendly name</label> data-src-friendly="{{ img.friendly_name }}">
<input type="text" class="form-control" name="friendly_name" required <i class="bi bi-files"></i> Clone
placeholder="GE Aerospace Shop Floor (newtype)"> </button>
</div> <button type="button" class="btn btn-sm btn-outline-danger btn-row-action"
<div class="alert alert-info small"> data-bs-toggle="modal" data-bs-target="#deleteImageModal"
Empty image type. Populate via Image Import or Clone afterwards. data-src-key="{{ img.image_type }}"
</div> data-src-friendly="{{ img.friendly_name }}">
</div> <i class="bi bi-trash"></i> Delete
<div class="modal-footer"> </button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> </td>
<button type="submit" class="btn btn-success">Create</button> </tr>
</div> {% endfor %}
</div> </tbody>
</form> </table>
</div> </div>
</div> </div>
</div>
<!-- Clone image modal -->
<div class="modal fade" id="cloneImageModal" tabindex="-1"> <!-- New image type modal -->
<div class="modal-dialog"> <div class="modal fade" id="newImageModal" tabindex="-1">
<form method="post" id="cloneForm"> <div class="modal-dialog">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}"> <form method="post" action="{{ url_for('images_new') }}">
<div class="modal-content"> <input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="modal-header"> <div class="modal-content">
<h5 class="modal-title">Clone image type</h5> <div class="modal-header">
<button type="button" class="btn-close" data-bs-dismiss="modal"></button> <h5 class="modal-title">Create image type</h5>
</div> <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
<div class="modal-body"> </div>
<p>Source: <strong id="cloneSrcFriendly"></strong> <code id="cloneSrcKey"></code></p> <div class="modal-body">
<div class="mb-3"> <div class="form-field">
<label class="form-label">New key</label> <label class="form-label">Key</label>
<input type="text" class="form-control font-monospace" name="dst_key" <input type="text" class="form-control mono" name="key"
pattern="[a-z][a-z0-9-]{1,63}" required> pattern="[a-z][a-z0-9-]{1,63}" required
</div> placeholder="gea-shopfloor-newtype">
<div class="mb-3"> <div class="form-text">Lowercase + hyphens. Used as directory name + URL path. 2-64 chars, must start with a letter.</div>
<label class="form-label">Friendly name (optional)</label> </div>
<input type="text" class="form-control" name="friendly_name" <div class="form-field">
placeholder="leave blank for &lsquo;<src> (copy)&rsquo;"> <label class="form-label">Friendly name</label>
</div> <input type="text" class="form-control" name="friendly_name" required
<div class="alert alert-info small"> placeholder="GE Aerospace Shop Floor (newtype)">
Copies Deploy/ tree (Control + Tools + unattend) and preserves </div>
symlinks to shared dirs (Out-of-box Drivers, Operating Systems, <div class="alert alert-info small">
Packages). Disk usage stays low because shared content is not Empty image type. Populate via Image Import or Clone afterwards.
duplicated. </div>
</div> </div>
</div> <div class="modal-footer">
<div class="modal-footer"> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> <button type="submit" class="btn btn-success">Create</button>
<button type="submit" class="btn btn-success">Clone</button> </div>
</div> </div>
</div> </form>
</form> </div>
</div> </div>
</div>
<!-- Clone image modal -->
<!-- Delete image modal --> <div class="modal fade" id="cloneImageModal" tabindex="-1">
<div class="modal fade" id="deleteImageModal" tabindex="-1"> <div class="modal-dialog">
<div class="modal-dialog"> <form method="post" id="cloneForm">
<form method="post" id="deleteForm"> <input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}"> <div class="modal-content">
<div class="modal-content"> <div class="modal-header">
<div class="modal-header"> <h5 class="modal-title">Clone image type</h5>
<h5 class="modal-title">Delete image type</h5> <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button> </div>
</div> <div class="modal-body">
<div class="modal-body"> <p>Source: <strong id="cloneSrcFriendly"></strong> <span class="mono" id="cloneSrcKey"></span></p>
<p>Remove <strong id="deleteSrcFriendly"></strong> <code id="deleteSrcKey"></code> from the registry.</p> <div class="form-field">
<div class="form-check mb-3"> <label class="form-label">New key</label>
<input class="form-check-input" type="checkbox" name="delete_content" value="1" id="deleteContentCheck"> <input type="text" class="form-control mono" name="dst_key"
<label class="form-check-label" for="deleteContentCheck"> pattern="[a-z][a-z0-9-]{1,63}" required>
Also wipe on-disk Deploy/Tools/etc (symlinked shared dirs are unlinked, not followed) </div>
</label> <div class="form-field">
</div> <label class="form-label">Friendly name (optional)</label>
<div class="alert alert-warning small"> <input type="text" class="form-control" name="friendly_name"
Removing from registry hides the image from the UI and Ansible placeholder="leave blank for &lsquo;<src> (copy)&rsquo;">
playbook list. Existing PXE-imaged clients are unaffected. Wiping </div>
content is irreversible. <div class="alert alert-info small">
</div> Copies Deploy/ tree (Control + Tools + unattend) and preserves
</div> symlinks to shared dirs (Out-of-box Drivers, Operating Systems,
<div class="modal-footer"> Packages). Disk usage stays low because shared content is not
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> duplicated.
<button type="submit" class="btn btn-danger">Delete</button> </div>
</div> </div>
</div> <div class="modal-footer">
</form> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
</div> <button type="submit" class="btn btn-success">Clone</button>
</div> </div>
</div>
{% endblock %} </form>
</div>
{% block extra_scripts %} </div>
<script>
document.addEventListener('show.bs.modal', function(e) { <!-- Delete image modal -->
var btn = e.relatedTarget; <div class="modal fade" id="deleteImageModal" tabindex="-1">
if (!btn) return; <div class="modal-dialog">
var srcKey = btn.getAttribute('data-src-key') || ''; <form method="post" id="deleteForm">
var srcFriendly = btn.getAttribute('data-src-friendly') || ''; <input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
if (e.target.id === 'cloneImageModal') { <div class="modal-content">
e.target.querySelector('#cloneSrcKey').textContent = srcKey; <div class="modal-header">
e.target.querySelector('#cloneSrcFriendly').textContent = srcFriendly; <h5 class="modal-title">Delete image type</h5>
document.getElementById('cloneForm').action = '/images/' + encodeURIComponent(srcKey) + '/clone'; <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
} else if (e.target.id === 'deleteImageModal') { </div>
e.target.querySelector('#deleteSrcKey').textContent = srcKey; <div class="modal-body">
e.target.querySelector('#deleteSrcFriendly').textContent = srcFriendly; <p>Remove <strong id="deleteSrcFriendly"></strong> <span class="mono" id="deleteSrcKey"></span> from the registry.</p>
document.getElementById('deleteForm').action = '/images/' + encodeURIComponent(srcKey) + '/delete'; <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">
</script> Also wipe on-disk Deploy/Tools/etc (symlinked shared dirs are unlinked, not followed)
{% endblock %} </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>
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 = '/images/' + encodeURIComponent(srcKey) + '/clone';
} else if (e.target.id === 'deleteImageModal') {
e.target.querySelector('#deleteSrcKey').textContent = srcKey;
e.target.querySelector('#deleteSrcFriendly').textContent = srcFriendly;
document.getElementById('deleteForm').action = '/images/' + encodeURIComponent(srcKey) + '/delete';
}
});
</script>
{% endblock %}

View File

@@ -1,135 +1,138 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Enrollment Packages - PXE Server Manager{% endblock %} {% block title %}Enrollment Packages - PXE Server Manager{% endblock %}
{% block content %} {% block content %}
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="page-header">
<h2 class="mb-0">Enrollment Packages</h2> <h2><i class="bi bi-pc-display"></i> Enrollment Packages</h2>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#uploadModal"> <div class="header-actions">
Upload Package <button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#uploadModal">
</button> <i class="bi bi-upload"></i> Upload Package
</div> </button>
</div>
<div class="card"> </div>
<div class="card-header d-flex align-items-center">
GCCH Provisioning Packages <div class="card">
<span class="badge bg-secondary ms-2">{{ packages|length }}</span> <div class="card-header">
</div> <span><i class="bi bi-box-seam"></i> GCCH Provisioning Packages</span>
<div class="card-body p-0"> <span class="badge badge-secondary">{{ packages|length }}</span>
{% if packages %} </div>
<table class="table table-hover mb-0"> <div class="card-body p-0">
<thead class="table-light"> {% if packages %}
<tr> <div class="table-container">
<th>Filename</th> <table class="table table-hover mb-0">
<th>Size</th> <thead>
<th>Last Modified</th> <tr>
<th class="text-end">Actions</th> <th>Filename</th>
</tr> <th>Size</th>
</thead> <th>Last Modified</th>
<tbody> <th class="text-end">Actions</th>
{% for p in packages %} </tr>
<tr> </thead>
<td><code>{{ p.filename }}</code></td> <tbody>
<td>{{ "%.1f"|format(p.size / 1048576) }} MB</td> {% for p in packages %}
<td>{{ p.modified | timestamp_fmt }}</td> <tr>
<td class="text-end text-nowrap"> <td><span class="mono"><i class="bi bi-file-earmark-zip"></i> {{ p.filename }}</span></td>
<a href="{{ url_for('enrollment_download', filename=p.filename) }}" <td>{{ "%.1f"|format(p.size / 1048576) }} MB</td>
class="btn btn-sm btn-outline-primary" title="Download"> <td>{{ p.modified | timestamp_fmt }}</td>
Download <td class="actions text-end text-nowrap">
</a> <a href="{{ url_for('enrollment_download', filename=p.filename) }}"
<button type="button" class="btn btn-sm btn-outline-danger" class="btn btn-sm btn-outline-primary btn-row-action" title="Download">
data-bs-toggle="modal" data-bs-target="#deleteModal" <i class="bi bi-download"></i> Download
data-filename="{{ p.filename }}" title="Delete"> </a>
Delete <button type="button" class="btn btn-sm btn-outline-danger btn-row-action"
</button> data-bs-toggle="modal" data-bs-target="#deleteModal"
</td> data-filename="{{ p.filename }}" title="Delete">
</tr> <i class="bi bi-trash"></i> Delete
{% endfor %} </button>
</tbody> </td>
</table> </tr>
{% else %} {% endfor %}
<div class="text-center text-muted py-5"> </tbody>
<p class="mt-2">No enrollment packages found. Upload a <code>.ppkg</code> file to get started.</p> </table>
</div> </div>
{% endif %} {% else %}
</div> <div class="text-center text-muted py-5">
</div> <i class="bi bi-inbox" style="font-size: 2.5rem; opacity: 0.5;"></i>
<p class="mt-2 mb-0">No enrollment packages found. Upload a <span class="mono">.ppkg</span> file to get started.</p>
<div class="card mt-3"> </div>
<div class="card-body"> {% endif %}
<h6 class="card-title">About Enrollment Packages</h6> </div>
<p class="card-text"> </div>
GCCH enrollment <code>.ppkg</code> provisioning packages are copied to
<code>C:\Enrollment\</code> on the target machine after imaging. <div class="pxe-card">
At OOBE, connect to a network with internet, press <strong>Windows key 5 times</strong>, <h3 class="pxe-card-title"><i class="bi bi-info-circle"></i> About Enrollment Packages</h3>
then browse to <code>C:\Enrollment\</code> and select the package. No USB stick needed. <p>
</p> GCCH enrollment <span class="mono">.ppkg</span> provisioning packages are copied to
<p class="card-text mb-0"> <span class="mono">C:\Enrollment\</span> on the target machine after imaging.
<strong>Naming convention:</strong> Use <code>with-office.ppkg</code> and At OOBE, connect to a network with internet, press <strong>Windows key 5 times</strong>,
<code>without-office.ppkg</code> to match the WinPE enrollment menu options. then browse to <span class="mono">C:\Enrollment\</span> and select the package. No USB stick needed.
</p> </p>
</div> <p class="mb-0">
</div> <strong>Naming convention:</strong> Use <span class="mono">with-office.ppkg</span> and
<span class="mono">without-office.ppkg</span> to match the WinPE enrollment menu options.
<!-- Upload Modal --> </p>
<div class="modal fade" id="uploadModal" tabindex="-1"> </div>
<div class="modal-dialog">
<div class="modal-content"> <!-- Upload Modal -->
<form action="{{ url_for('enrollment_upload') }}" method="post" enctype="multipart/form-data"> <div class="modal fade" id="uploadModal" tabindex="-1">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}"> <div class="modal-dialog">
<div class="modal-header"> <div class="modal-content">
<h5 class="modal-title">Upload Enrollment Package</h5> <form action="{{ url_for('enrollment_upload') }}" method="post" enctype="multipart/form-data">
<button type="button" class="btn-close" data-bs-dismiss="modal"></button> <input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
</div> <div class="modal-header">
<div class="modal-body"> <h5 class="modal-title">Upload Enrollment Package</h5>
<div class="mb-3"> <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
<label for="ppkgFile" class="form-label">Provisioning Package (.ppkg)</label> </div>
<input type="file" class="form-control" id="ppkgFile" name="ppkg_file" <div class="modal-body">
accept=".ppkg" required> <div class="form-field">
<div class="form-text"> <label for="ppkgFile" class="form-label">Provisioning Package (.ppkg)</label>
Use <code>with-office.ppkg</code> or <code>without-office.ppkg</code> to match the WinPE boot menu. <input type="file" class="form-control" id="ppkgFile" name="ppkg_file"
</div> accept=".ppkg" required>
</div> <div class="form-text">
</div> Use <span class="mono">with-office.ppkg</span> or <span class="mono">without-office.ppkg</span> to match the WinPE boot menu.
<div class="modal-footer"> </div>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> </div>
<button type="submit" class="btn btn-primary">Upload</button> </div>
</div> <div class="modal-footer">
</form> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
</div> <button type="submit" class="btn btn-primary"><i class="bi bi-upload"></i> Upload</button>
</div> </div>
</div> </form>
</div>
<!-- Delete Confirmation Modal --> </div>
<div class="modal fade" id="deleteModal" tabindex="-1"> </div>
<div class="modal-dialog">
<div class="modal-content"> <!-- Delete Confirmation Modal -->
<form id="deleteForm" method="post"> <div class="modal fade" id="deleteModal" tabindex="-1">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}"> <div class="modal-dialog">
<div class="modal-header"> <div class="modal-content">
<h5 class="modal-title">Confirm Delete</h5> <form id="deleteForm" method="post">
<button type="button" class="btn-close" data-bs-dismiss="modal"></button> <input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
</div> <div class="modal-header">
<div class="modal-body"> <h5 class="modal-title">Confirm Delete</h5>
<p>Are you sure you want to delete <strong id="deleteFilename"></strong>?</p> <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
<p class="text-muted mb-0">This action cannot be undone.</p> </div>
</div> <div class="modal-body">
<div class="modal-footer"> <p>Are you sure you want to delete <strong id="deleteFilename" class="mono"></strong>?</p>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> <p class="text-muted mb-0">This action cannot be undone.</p>
<button type="submit" class="btn btn-danger">Delete</button> </div>
</div> <div class="modal-footer">
</form> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
</div> <button type="submit" class="btn btn-danger"><i class="bi bi-trash"></i> Delete</button>
</div> </div>
</div> </form>
{% endblock %} </div>
</div>
{% block extra_scripts %} </div>
<script> {% endblock %}
document.getElementById('deleteModal').addEventListener('show.bs.modal', function (event) {
var btn = event.relatedTarget; {% block extra_scripts %}
var filename = btn.getAttribute('data-filename'); <script>
document.getElementById('deleteFilename').textContent = filename; document.getElementById('deleteModal').addEventListener('show.bs.modal', function (event) {
document.getElementById('deleteForm').action = '/enrollment/delete/' + encodeURIComponent(filename); var btn = event.relatedTarget;
}); var filename = btn.getAttribute('data-filename');
</script> document.getElementById('deleteFilename').textContent = filename;
{% endblock %} document.getElementById('deleteForm').action = '/enrollment/delete/' + encodeURIComponent(filename);
});
</script>
{% endblock %}

View File

@@ -1,458 +1,492 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{{ friendly_name }} - Configuration{% endblock %} {% block title %}{{ friendly_name }} - Configuration{% endblock %}
{% block extra_head %} {% block extra_head %}
<style> <style>
.section-card { margin-bottom: 1.5rem; } /* image_config-specific tweaks layered on top of pxe-theme.css tokens */
.section-card .card-header { padding: 0.6rem 1rem; font-size: 0.95rem; } .config-table td, .config-table th { vertical-align: middle; }
.badge-disk { font-size: 0.75rem; } .config-table .form-control-sm { min-width: 120px; }
.orphan-section { background-color: #fff8e1; } .badge-disk { font-size: 11px; }
.config-table td, .config-table th { vertical-align: middle; } .text-truncate-cell {
.config-table .form-control-sm { min-width: 120px; } max-width: 250px;
.text-truncate-cell { max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } overflow: hidden;
</style> text-overflow: ellipsis;
{% endblock %} white-space: nowrap;
}
{% block content %} /* Unregistered-drivers strip: warning-tinted footer that reads in both themes */
<div class="d-flex justify-content-between align-items-center mb-4"> .orphan-section {
<div> background: rgba(255, 149, 0, 0.08);
<h2 class="mb-1">{{ friendly_name }}</h2> border-top: 1px solid var(--border);
<small class="text-muted"> }
Image Configuration [data-theme="dark"] .orphan-section { background: rgba(255, 149, 0, 0.10); }
&mdash; OS Selection: <strong>{{ config.os_selection or 'Not set' }}</strong> .orphan-toggle { color: var(--text); text-decoration: none; }
</small> .orphan-toggle:hover { color: var(--primary); }
</div> .section-count { background: var(--secondary); }
<a href="{{ url_for('unattend_editor', image_type=image_type) }}" class="btn btn-outline-secondary btn-sm"> .card-header .header-actions { flex: 0 0 auto; }
Edit Unattend </style>
</a> {% endblock %}
</div>
{% block content %}
{# ==================== SECTION 1: Hardware Models ==================== #} <div class="page-header">
<div class="card section-card"> <div>
<div class="card-header d-flex justify-content-between align-items-center"> <h1>{{ friendly_name }}</h1>
<span>Hardware Models <div class="eyebrow" style="margin-top:0.35rem;">
<span class="badge bg-secondary ms-1">{{ config.hardware_models|length }}</span> Image Configuration &mdash; OS Selection:
</span> <strong>{{ config.os_selection or 'Not set' }}</strong>
<div> </div>
<button type="button" class="btn btn-sm btn-outline-primary" id="addHwModel"> </div>
Add <div class="header-actions">
</button> <a href="{{ url_for('unattend_editor', image_type=image_type) }}" class="btn btn-outline-secondary btn-sm">
<button type="button" class="btn btn-sm btn-success ms-1" id="saveHwModels"> <i class="bi bi-pencil-square"></i> Edit Unattend
Save </a>
</button> </div>
</div> </div>
</div>
<div class="card-body p-0"> {# ==================== SECTION 1: Hardware Models ==================== #}
<form method="POST" action="{{ url_for('image_config_save', image_type=image_type) }}" id="hwModelsForm"> <div class="card">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}"> <div class="card-header">
<input type="hidden" name="section" value="hardware_models"> <span class="d-flex align-items-center gap-2">
<input type="hidden" name="payload" id="hwModelsData" value="[]"> <i class="bi bi-cpu"></i> Hardware Models
</form> <span class="badge section-count">{{ config.hardware_models|length }}</span>
<table class="table table-sm table-hover mb-0 config-table" id="hwModelsTable"> </span>
<thead class="table-light"> <div class="header-actions">
<tr> <button type="button" class="btn btn-sm btn-outline-primary" id="addHwModel">
<th style="width:40px">#</th> <i class="bi bi-plus-lg"></i> Add
<th>Model</th> </button>
<th>Driver Family ID</th> <button type="button" class="btn btn-sm btn-success" id="saveHwModels">
<th style="width:90px">On Disk</th> <i class="bi bi-check-lg"></i> Save
<th style="width:60px"></th> </button>
</tr> </div>
</thead> </div>
<tbody> <div class="card-body p-0">
{% for hm in config.hardware_models %} <form method="POST" action="{{ url_for('image_config_save', image_type=image_type) }}" id="hwModelsForm">
<tr> <input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<td class="order-num">{{ loop.index }}</td> <input type="hidden" name="section" value="hardware_models">
<td><input type="text" class="form-control form-control-sm" data-field="Model" value="{{ hm.Model }}"></td> <input type="hidden" name="payload" id="hwModelsData" value="[]">
<td><input type="text" class="form-control form-control-sm" data-field="Id" value="{{ hm.Id }}"></td> </form>
<td> <div class="table-container">
{% if hm._on_disk %} <table class="table table-hover mb-0 config-table" id="hwModelsTable">
<span class="badge bg-success badge-disk">Yes</span> <thead>
{% else %} <tr>
<span class="badge bg-danger badge-disk">No</span> <th style="width:40px">#</th>
{% endif %} <th>Model</th>
</td> <th>Driver Family ID</th>
<td> <th style="width:90px">On Disk</th>
<button type="button" class="btn btn-outline-danger btn-row-action remove-row"> <th style="width:60px"></th>
Remove </tr>
</button> </thead>
</td> <tbody>
</tr> {% for hm in config.hardware_models %}
{% endfor %} <tr>
</tbody> <td class="order-num">{{ loop.index }}</td>
</table> <td><input type="text" class="form-control form-control-sm" data-field="Model" value="{{ hm.Model }}"></td>
{% if not config.hardware_models %} <td><input type="text" class="form-control form-control-sm" data-field="Id" value="{{ hm.Id }}"></td>
<div class="text-center text-muted py-3 empty-message" id="hwModelsEmpty"> <td>
No hardware models configured. {% if hm._on_disk %}
</div> <span class="badge badge-success badge-disk">Yes</span>
{% endif %} {% else %}
</div> <span class="badge badge-danger badge-disk">No</span>
</div> {% endif %}
</td>
{# ==================== SECTION 2: Driver Packs ==================== #} <td class="actions">
<div class="card section-card"> <button type="button" class="btn btn-outline-danger btn-row-action remove-row">
<div class="card-header d-flex justify-content-between align-items-center"> <i class="bi bi-trash"></i> Remove
<span>Driver Packs </button>
<span class="badge bg-secondary ms-1">{{ config.drivers|length }}</span> </td>
</span> </tr>
<div> {% endfor %}
<button type="button" class="btn btn-sm btn-outline-primary" </tbody>
data-bs-toggle="modal" data-bs-target="#driverUploadModal"> </table>
Upload .zip </div>
</button> {% if not config.hardware_models %}
<button type="button" class="btn btn-sm btn-success ms-1" id="saveDrivers"> <div class="text-center text-muted py-3 empty-message" id="hwModelsEmpty">
Save No hardware models configured.
</button> </div>
</div> {% endif %}
</div> </div>
<div class="card-body p-0"> </div>
<form method="POST" action="{{ url_for('image_config_save', image_type=image_type) }}" id="driversForm">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}"> {# ==================== SECTION 2: Driver Packs ==================== #}
<input type="hidden" name="section" value="drivers"> <div class="card">
<input type="hidden" name="payload" id="driversData" value="[]"> <div class="card-header">
</form> <span class="d-flex align-items-center gap-2">
<div class="table-responsive"> <i class="bi bi-hdd-network"></i> Driver Packs
<table class="table table-sm table-hover mb-0 config-table" id="driversTable"> <span class="badge section-count">{{ config.drivers|length }}</span>
<thead class="table-light"> </span>
<tr> <div class="header-actions">
<th style="width:40px">#</th> <button type="button" class="btn btn-sm btn-outline-primary"
<th>Family</th> data-bs-toggle="modal" data-bs-target="#driverUploadModal">
<th>Models</th> <i class="bi bi-upload"></i> Upload .zip
<th>File Name</th> </button>
<th style="width:70px">OS IDs</th> <button type="button" class="btn btn-sm btn-success" id="saveDrivers">
<th style="width:90px">On Disk</th> <i class="bi bi-check-lg"></i> Save
<th style="width:60px"></th> </button>
</tr> </div>
</thead> </div>
<tbody> <div class="card-body p-0">
{% for drv in config.drivers %} <form method="POST" action="{{ url_for('image_config_save', image_type=image_type) }}" id="driversForm">
<tr data-json='{{ drv | tojson }}'> <input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<td class="order-num">{{ loop.index }}</td> <input type="hidden" name="section" value="drivers">
<td class="text-truncate-cell" title="{{ drv.family }}">{{ drv.family }}</td> <input type="hidden" name="payload" id="driversData" value="[]">
<td class="text-truncate-cell" title="{{ drv.models }}">{{ drv.models }}</td> </form>
<td class="text-truncate-cell" title="{{ drv.FileName or drv.get('fileName','') }}"> <div class="table-container">
<small>{{ drv.FileName or drv.get('fileName','') }}</small> <table class="table table-hover mb-0 config-table" id="driversTable">
</td> <thead>
<td><small>{{ drv.osId }}</small></td> <tr>
<td> <th style="width:40px">#</th>
{% if drv._on_disk %} <th>Family</th>
<span class="badge bg-success badge-disk">Yes</span> <th>Models</th>
{% else %} <th>File Name</th>
<span class="badge bg-danger badge-disk">No</span> <th style="width:70px">OS IDs</th>
{% endif %} <th style="width:90px">On Disk</th>
</td> <th style="width:60px"></th>
<td> </tr>
<button type="button" class="btn btn-outline-danger btn-row-action remove-row"> </thead>
Remove <tbody>
</button> {% for drv in config.drivers %}
</td> <tr data-json='{{ drv | tojson }}'>
</tr> <td class="order-num">{{ loop.index }}</td>
{% endfor %} <td class="text-truncate-cell" title="{{ drv.family }}">{{ drv.family }}</td>
</tbody> <td class="text-truncate-cell" title="{{ drv.models }}">{{ drv.models }}</td>
</table> <td class="text-truncate-cell mono" title="{{ drv.FileName or drv.get('fileName','') }}">
</div> {{ drv.FileName or drv.get('fileName','') }}
{% if not config.drivers %} </td>
<div class="text-center text-muted py-3 empty-message" id="driversEmpty"> <td class="mono">{{ drv.osId }}</td>
No driver packs configured. <td>
</div> {% if drv._on_disk %}
{% endif %} <span class="badge badge-success badge-disk">Yes</span>
</div> {% else %}
<span class="badge badge-danger badge-disk">No</span>
{# Orphan drivers sub-section: zips on disk that aren't referenced by #} {% endif %}
{# HardwareDriver.json. Each row has an inline Adopt form (family + dest #} </td>
{# inputs -> adds an entry to HardwareDriver.json). Bulk remove deletes #} <td class="actions">
{# the selected .zip files from Out-of-box Drivers/. #} <button type="button" class="btn btn-outline-danger btn-row-action remove-row">
{% if config.orphan_drivers %} <i class="bi bi-trash"></i> Remove
<div class="card-footer orphan-section p-0"> </button>
<div class="px-3 py-2 d-flex justify-content-between align-items-center"> </td>
<a class="text-decoration-none" data-bs-toggle="collapse" href="#orphanDrivers" role="button"> </tr>
<strong>Unregistered Drivers ({{ config.orphan_drivers|length }})</strong> {% endfor %}
<small class="text-muted ms-1">.zip files on disk, no JSON entry</small> </tbody>
</a> </table>
</div> </div>
<div class="collapse show" id="orphanDrivers"> {% if not config.drivers %}
<form method="POST" action="{{ url_for('images_drivers_orphans_delete', image_type=image_type) }}" <div class="text-center text-muted py-3 empty-message" id="driversEmpty">
onsubmit="return confirm('Delete the selected unregistered driver .zip(s) from disk? Cannot be undone.');"> No driver packs configured.
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}"> </div>
<table class="table table-sm mb-0 align-middle"> {% endif %}
<thead class="table-light"> </div>
<tr>
<th style="width:30px"><input type="checkbox" id="orphanSelectAll"></th> {# Orphan drivers sub-section: zips on disk that aren't referenced by #}
<th>File Name</th> {# HardwareDriver.json. Each row has an inline Adopt form (family + dest #}
<th>Relative Path</th> {# inputs -> adds an entry to HardwareDriver.json). Bulk remove deletes #}
<th style="width:300px">Adopt into HardwareDriver.json</th> {# the selected .zip files from Out-of-box Drivers/. #}
</tr> {% if config.orphan_drivers %}
</thead> <div class="orphan-section">
<tbody> <div class="px-3 py-2 d-flex justify-content-between align-items-center">
{% for orph in config.orphan_drivers %} <a class="orphan-toggle d-flex align-items-center gap-2" data-bs-toggle="collapse" href="#orphanDrivers" role="button">
<tr> <i class="bi bi-exclamation-triangle"></i>
<td><input type="checkbox" class="orphan-select" name="filename" value="{{ orph.fileName }}"></td> <strong>Unregistered Drivers ({{ config.orphan_drivers|length }})</strong>
<td><small>{{ orph.fileName }}</small></td> <span class="text-muted" style="font-size:0.82rem;">.zip files on disk, no JSON entry</span>
<td><small class="text-muted">{{ orph.relPath }}</small></td> </a>
<td> </div>
<form method="POST" action="{{ url_for('images_drivers_adopt', image_type=image_type) }}" <div class="collapse show" id="orphanDrivers">
class="d-flex gap-1"> <form method="POST" action="{{ url_for('images_drivers_orphans_delete', image_type=image_type) }}"
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}"> onsubmit="return confirm('Delete the selected unregistered driver .zip(s) from disk? Cannot be undone.');">
<input type="hidden" name="filename" value="{{ orph.fileName }}"> <input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<input type="text" class="form-control form-control-sm" name="family" <div class="table-container">
placeholder="family id (e.g. Optiplex_7060)" required style="width:11rem;"> <table class="table mb-0 align-middle">
<input type="text" class="form-control form-control-sm" name="destination_dir" <thead>
placeholder="destinationDir" required style="width:11rem;"> <tr>
<button type="submit" class="btn btn-sm btn-success">Adopt</button> <th style="width:30px"><input type="checkbox" id="orphanSelectAll"></th>
</form> <th>File Name</th>
</td> <th>Relative Path</th>
</tr> <th style="width:300px">Adopt into HardwareDriver.json</th>
{% endfor %} </tr>
</tbody> </thead>
</table> <tbody>
<div class="px-3 py-2 text-end"> {% for orph in config.orphan_drivers %}
<button type="submit" class="btn btn-sm btn-outline-danger"> <tr>
Delete selected <td><input type="checkbox" class="orphan-select" name="filename" value="{{ orph.fileName }}"></td>
</button> <td class="mono">{{ orph.fileName }}</td>
</div> <td class="mono text-muted">{{ orph.relPath }}</td>
</form> <td>
</div> {# adopt inputs target standalone adoptForm below - form-in-form is dropped by parser and hijacked the delete form #}
</div> <div class="d-flex gap-1">
{% endif %} <input type="text" class="form-control form-control-sm" name="family"
</div> form="adoptForm{{ loop.index }}"
placeholder="family id (e.g. Optiplex_7060)" required style="width:11rem;">
<!-- Driver upload modal --> <input type="text" class="form-control form-control-sm" name="destination_dir"
<div class="modal fade" id="driverUploadModal" tabindex="-1"> form="adoptForm{{ loop.index }}"
<div class="modal-dialog"> placeholder="destinationDir" required style="width:11rem;">
<form method="POST" action="{{ url_for('images_drivers_upload', image_type=image_type) }}" enctype="multipart/form-data"> <button type="submit" class="btn btn-sm btn-success" form="adoptForm{{ loop.index }}">Adopt</button>
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}"> </div>
<div class="modal-content"> </td>
<div class="modal-header"> </tr>
<h5 class="modal-title">Upload driver .zip</h5> {% endfor %}
<button type="button" class="btn-close" data-bs-dismiss="modal"></button> </tbody>
</div> </table>
<div class="modal-body"> </div>
<div class="mb-3"> <div class="px-3 py-2 text-end">
<label class="form-label">Driver .zip file</label> <button type="submit" class="btn btn-sm btn-outline-danger">
<input type="file" class="form-control" name="driver_file" accept=".zip" required> <i class="bi bi-trash"></i> Delete selected
<div class="form-text">Lands in <code>{{ image_type }}/Deploy/Out-of-box Drivers/</code>.</div> </button>
</div> </div>
<div class="mb-3"> </form>
<label class="form-label">Family ID (optional)</label> {# one adopt form per orphan row, kept OUTSIDE the delete form #}
<input type="text" class="form-control font-monospace" name="family" {% for orph in config.orphan_drivers %}
placeholder="Optiplex_7060"> <form method="POST" id="adoptForm{{ loop.index }}"
<div class="form-text">Matches a HardwareModelSelection.Id. Leave blank to land as orphan + adopt later.</div> action="{{ url_for('images_drivers_adopt', image_type=image_type) }}">
</div> <input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="mb-3"> <input type="hidden" name="filename" value="{{ orph.fileName }}">
<label class="form-label">Destination directory (optional)</label> </form>
<input type="text" class="form-control font-monospace" name="destination_dir" {% endfor %}
placeholder="*destinationdir*\Drivers\Optiplex"> </div>
<div class="form-text">Where the .zip extracts at deploy time. Required if Family ID is set.</div> </div>
</div> {% endif %}
<div class="form-check"> </div>
<input class="form-check-input" type="checkbox" name="overwrite" value="1" id="driverOverwriteCheck">
<label class="form-check-label" for="driverOverwriteCheck">Overwrite if same filename exists</label> <!-- Driver upload modal -->
</div> <div class="modal fade" id="driverUploadModal" tabindex="-1">
</div> <div class="modal-dialog">
<div class="modal-footer"> <form method="POST" action="{{ url_for('images_drivers_upload', image_type=image_type) }}" enctype="multipart/form-data">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> <input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-primary">Upload</button> <div class="modal-content">
</div> <div class="modal-header">
</div> <h3 class="modal-title">Upload driver .zip</h3>
</form> <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div> </div>
</div> <div class="modal-body">
<div class="form-field">
{# ==================== SECTION 3: Operating Systems ==================== #} <label class="form-label">Driver .zip file</label>
<div class="card section-card"> <input type="file" class="form-control" name="driver_file" accept=".zip" required>
<div class="card-header d-flex justify-content-between align-items-center"> <div class="form-text">Lands in <code>{{ image_type }}/Deploy/Out-of-box Drivers/</code>.</div>
<span>Operating Systems </div>
<span class="badge bg-secondary ms-1">{{ config.operating_systems|length }}</span> <div class="form-field">
</span> <label class="form-label">Family ID (optional)</label>
<div> <input type="text" class="form-control mono" name="family"
<button type="button" class="btn btn-sm btn-success" id="saveOs"> placeholder="Optiplex_7060">
Save <div class="form-text">Matches a HardwareModelSelection.Id. Leave blank to land as orphan + adopt later.</div>
</button> </div>
</div> <div class="form-field">
</div> <label class="form-label">Destination directory (optional)</label>
<div class="card-body p-0"> <input type="text" class="form-control mono" name="destination_dir"
<form method="POST" action="{{ url_for('image_config_save', image_type=image_type) }}" id="osForm"> placeholder="*destinationdir*\Drivers\Optiplex">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}"> <div class="form-text">Where the .zip extracts at deploy time. Required if Family ID is set.</div>
<input type="hidden" name="section" value="operating_systems"> </div>
<input type="hidden" name="payload" id="osData" value="[]"> <div class="form-check">
</form> <input class="form-check-input" type="checkbox" name="overwrite" value="1" id="driverOverwriteCheck">
<table class="table table-sm table-hover mb-0 config-table" id="osTable"> <label class="form-check-label" for="driverOverwriteCheck">Overwrite if same filename exists</label>
<thead class="table-light"> </div>
<tr> </div>
<th style="width:40px">#</th> <div class="modal-footer">
<th>Product Name</th> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<th>Version</th> <button type="submit" class="btn btn-primary">Upload</button>
<th>Build</th> </div>
<th style="width:60px">ID</th> </div>
<th style="width:70px">Active</th> </form>
<th style="width:90px">WIM On Disk</th> </div>
<th style="width:60px"></th> </div>
</tr>
</thead> {# ==================== SECTION 3: Operating Systems ==================== #}
<tbody> <div class="card">
{% for entry in config.operating_systems %} <div class="card-header">
{% set osv = entry.operatingSystemVersion %} <span class="d-flex align-items-center gap-2">
<tr data-json='{{ entry | tojson }}'> <i class="bi bi-windows"></i> Operating Systems
<td class="order-num">{{ loop.index }}</td> <span class="badge section-count">{{ config.operating_systems|length }}</span>
<td>{{ osv.productName }}</td> </span>
<td>{{ osv.versionNumber }}</td> <div class="header-actions">
<td>{{ osv.buildNumber }}</td> <button type="button" class="btn btn-sm btn-success" id="saveOs">
<td>{{ osv.id }}</td> <i class="bi bi-check-lg"></i> Save
<td> </button>
{% if osv.isActive %} </div>
<span class="badge bg-success badge-disk">Active</span> </div>
{% else %} <div class="card-body p-0">
<span class="badge bg-secondary badge-disk">Inactive</span> <form method="POST" action="{{ url_for('image_config_save', image_type=image_type) }}" id="osForm">
{% endif %} <input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
</td> <input type="hidden" name="section" value="operating_systems">
<td> <input type="hidden" name="payload" id="osData" value="[]">
{% if entry._on_disk %} </form>
<span class="badge bg-success badge-disk">Yes</span> <div class="table-container">
{% else %} <table class="table table-hover mb-0 config-table" id="osTable">
<span class="badge bg-danger badge-disk">No</span> <thead>
{% endif %} <tr>
</td> <th style="width:40px">#</th>
<td> <th>Product Name</th>
<button type="button" class="btn btn-outline-danger btn-row-action remove-row"> <th>Version</th>
Remove <th>Build</th>
</button> <th style="width:60px">ID</th>
</td> <th style="width:70px">Active</th>
</tr> <th style="width:90px">WIM On Disk</th>
{% endfor %} <th style="width:60px"></th>
</tbody> </tr>
</table> </thead>
{% if not config.operating_systems %} <tbody>
<div class="text-center text-muted py-3 empty-message" id="osEmpty"> {% for entry in config.operating_systems %}
No operating systems configured. {% set osv = entry.operatingSystemVersion %}
</div> <tr data-json='{{ entry | tojson }}'>
{% endif %} <td class="order-num">{{ loop.index }}</td>
</div> <td>{{ osv.productName }}</td>
</div> <td>{{ osv.versionNumber }}</td>
<td>{{ osv.buildNumber }}</td>
{# ==================== SECTION 4: Packages ==================== #} <td class="mono">{{ osv.id }}</td>
<div class="card section-card"> <td>
<div class="card-header d-flex justify-content-between align-items-center"> {% if osv.isActive %}
<span>Packages <span class="badge badge-success badge-disk">Active</span>
<span class="badge bg-secondary ms-1">{{ config.packages|length }}</span> {% else %}
</span> <span class="badge badge-secondary badge-disk">Inactive</span>
<div> {% endif %}
<button type="button" class="btn btn-sm btn-outline-primary" </td>
data-bs-toggle="modal" data-bs-target="#packageUploadModal"> <td>
Upload file {% if entry._on_disk %}
</button> <span class="badge badge-success badge-disk">Yes</span>
<button type="button" class="btn btn-sm btn-success ms-1" id="savePackages"> {% else %}
Save <span class="badge badge-danger badge-disk">No</span>
</button> {% endif %}
</div> </td>
</div> <td class="actions">
<div class="card-body p-0"> <button type="button" class="btn btn-outline-danger btn-row-action remove-row">
<form method="POST" action="{{ url_for('image_config_save', image_type=image_type) }}" id="packagesForm"> <i class="bi bi-trash"></i> Remove
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}"> </button>
<input type="hidden" name="section" value="packages"> </td>
<input type="hidden" name="payload" id="packagesData" value="[]"> </tr>
</form> {% endfor %}
<div class="table-responsive"> </tbody>
<table class="table table-sm table-hover mb-0 config-table" id="packagesTable"> </table>
<thead class="table-light"> </div>
<tr> {% if not config.operating_systems %}
<th style="width:40px">#</th> <div class="text-center text-muted py-3 empty-message" id="osEmpty">
<th>Name</th> No operating systems configured.
<th>Comment</th> </div>
<th>File</th> {% endif %}
<th style="width:70px">OS IDs</th> </div>
<th style="width:80px">Enabled</th> </div>
<th style="width:90px">On Disk</th>
<th style="width:60px"></th> {# ==================== SECTION 4: Packages ==================== #}
</tr> <div class="card">
</thead> <div class="card-header">
<tbody> <span class="d-flex align-items-center gap-2">
{% for pkg in config.packages %} <i class="bi bi-box-seam"></i> Packages
<tr data-json='{{ pkg | tojson }}'> <span class="badge section-count">{{ config.packages|length }}</span>
<td class="order-num">{{ loop.index }}</td> </span>
<td class="text-truncate-cell" title="{{ pkg.name }}"><small>{{ pkg.name }}</small></td> <div class="header-actions">
<td class="text-truncate-cell" title="{{ pkg.comment }}"><small>{{ pkg.comment }}</small></td> <button type="button" class="btn btn-sm btn-outline-primary"
<td class="text-truncate-cell" title="{{ pkg.fileName or pkg.get('FileName','') }}"> data-bs-toggle="modal" data-bs-target="#packageUploadModal">
<small>{{ pkg.fileName or pkg.get('FileName','') }}</small> <i class="bi bi-upload"></i> Upload file
</td> </button>
<td><small>{{ pkg.osId }}</small></td> <button type="button" class="btn btn-sm btn-success" id="savePackages">
<td> <i class="bi bi-check-lg"></i> Save
{% if pkg.enabled %} </button>
<span class="badge bg-success badge-disk">Yes</span> </div>
{% else %} </div>
<span class="badge bg-secondary badge-disk">No</span> <div class="card-body p-0">
{% endif %} <form method="POST" action="{{ url_for('image_config_save', image_type=image_type) }}" id="packagesForm">
</td> <input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<td> <input type="hidden" name="section" value="packages">
{% if pkg._on_disk %} <input type="hidden" name="payload" id="packagesData" value="[]">
<span class="badge bg-success badge-disk">Yes</span> </form>
{% else %} <div class="table-container">
<span class="badge bg-danger badge-disk">No</span> <table class="table table-hover mb-0 config-table" id="packagesTable">
{% endif %} <thead>
</td> <tr>
<td> <th style="width:40px">#</th>
<button type="button" class="btn btn-outline-danger btn-row-action remove-row"> <th>Name</th>
Remove <th>Comment</th>
</button> <th>File</th>
</td> <th style="width:70px">OS IDs</th>
</tr> <th style="width:80px">Enabled</th>
{% endfor %} <th style="width:90px">On Disk</th>
</tbody> <th style="width:60px"></th>
</table> </tr>
</div> </thead>
{% if not config.packages %} <tbody>
<div class="text-center text-muted py-3 empty-message" id="packagesEmpty"> {% for pkg in config.packages %}
No packages configured. <tr data-json='{{ pkg | tojson }}'>
</div> <td class="order-num">{{ loop.index }}</td>
{% endif %} <td class="text-truncate-cell" title="{{ pkg.name }}">{{ pkg.name }}</td>
</div> <td class="text-truncate-cell" title="{{ pkg.comment }}">{{ pkg.comment }}</td>
</div> <td class="text-truncate-cell mono" title="{{ pkg.fileName or pkg.get('FileName','') }}">
{{ pkg.fileName or pkg.get('FileName','') }}
<!-- Package upload modal --> </td>
<div class="modal fade" id="packageUploadModal" tabindex="-1"> <td class="mono">{{ pkg.osId }}</td>
<div class="modal-dialog"> <td>
<form method="POST" action="{{ url_for('images_packages_upload', image_type=image_type) }}" enctype="multipart/form-data"> {% if pkg.enabled %}
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}"> <span class="badge badge-success badge-disk">Yes</span>
<div class="modal-content"> {% else %}
<div class="modal-header"> <span class="badge badge-secondary badge-disk">No</span>
<h5 class="modal-title">Upload package</h5> {% endif %}
<button type="button" class="btn-close" data-bs-dismiss="modal"></button> </td>
</div> <td>
<div class="modal-body"> {% if pkg._on_disk %}
<div class="mb-3"> <span class="badge badge-success badge-disk">Yes</span>
<label class="form-label">Package file</label> {% else %}
<input type="file" class="form-control" name="package_file" required> <span class="badge badge-danger badge-disk">No</span>
<div class="form-text">Lands in <code>{{ image_type }}/Deploy/Packages/</code>.</div> {% endif %}
</div> </td>
<div class="mb-3"> <td class="actions">
<label class="form-label">Destination directory (optional)</label> <button type="button" class="btn btn-outline-danger btn-row-action remove-row">
<input type="text" class="form-control font-monospace" name="destination_dir" <i class="bi bi-trash"></i> Remove
placeholder="*destinationdir*\Packages"> </button>
<div class="form-text">Setting this also appends an entry to <code>packages.json</code>.</div> </td>
</div> </tr>
<div class="form-check"> {% endfor %}
<input class="form-check-input" type="checkbox" name="overwrite" value="1" id="pkgOverwriteCheck"> </tbody>
<label class="form-check-label" for="pkgOverwriteCheck">Overwrite if same filename exists</label> </table>
</div> </div>
</div> {% if not config.packages %}
<div class="modal-footer"> <div class="text-center text-muted py-3 empty-message" id="packagesEmpty">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> No packages configured.
<button type="submit" class="btn btn-primary">Upload</button> </div>
</div> {% endif %}
</div> </div>
</form> </div>
</div>
</div> <!-- Package upload modal -->
<div class="modal fade" id="packageUploadModal" tabindex="-1">
{% endblock %} <div class="modal-dialog">
<form method="POST" action="{{ url_for('images_packages_upload', image_type=image_type) }}" enctype="multipart/form-data">
{% block extra_scripts %} <input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<script> <div class="modal-content">
// Orphan drivers: select-all checkbox toggles every row's selector. <div class="modal-header">
document.addEventListener('click', function(e) { <h3 class="modal-title">Upload package</h3>
if (e.target && e.target.id === 'orphanSelectAll') { <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
document.querySelectorAll('.orphan-select').forEach(function(cb) { </div>
cb.checked = e.target.checked; <div class="modal-body">
}); <div class="form-field">
} <label class="form-label">Package file</label>
}); <input type="file" class="form-control" name="package_file" required>
</script> <div class="form-text">Lands in <code>{{ image_type }}/Deploy/Packages/</code>.</div>
{% endblock %} </div>
<div class="form-field">
<label class="form-label">Destination directory (optional)</label>
<input type="text" class="form-control mono" name="destination_dir"
placeholder="*destinationdir*\Packages">
<div class="form-text">Setting this also appends an entry to <code>packages.json</code>.</div>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="overwrite" value="1" id="pkgOverwriteCheck">
<label class="form-check-label" for="pkgOverwriteCheck">Overwrite if same filename exists</label>
</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-primary">Upload</button>
</div>
</div>
</form>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
// Orphan drivers: select-all checkbox toggles every row's selector.
document.addEventListener('click', function(e) {
if (e.target && e.target.id === 'orphanSelectAll') {
document.querySelectorAll('.orphan-select').forEach(function(cb) {
cb.checked = e.target.checked;
});
}
});
</script>
{% endblock %}

View File

@@ -18,38 +18,41 @@ function cancelImagingReload() {
{% block content %} {% block content %}
<div class="d-flex justify-content-between align-items-center mb-2"> <div class="page-header">
<div> <div>
<h2 class="mb-0">Imaging Progress</h2> <h1>Imaging Progress</h1>
<small class="text-muted">Live via SSE (15s polling fallback). Client pushes -> <code>/imaging/status</code>; log-inferred bays in yellow.</small> <small class="text-light">Live via SSE (15s polling fallback). Client pushes &rarr; <code class="mono">/imaging/status</code>; log-inferred bays in yellow.</small>
</div> </div>
<div class="d-flex align-items-center gap-2"> <div class="header-actions">
<span id="imaging-live-dot" class="status-dot" title="live stream" style="background-color:#6c757d;"></span> <span class="status-indicator">
<span class="badge bg-secondary fs-6"><span id="visible-count">{{ sessions|length }}</span>/<span id="total-count">{{ sessions|length }}</span></span> <span id="imaging-live-dot" class="status-dot" title="live stream" style="background-color:var(--secondary);"></span>
<span class="badge badge-secondary badge-lg"><span id="visible-count">{{ sessions|length }}</span>/<span id="total-count">{{ sessions|length }}</span></span>
</span>
{% if sessions %} {% if sessions %}
<form method="post" action="{{ url_for('imaging_delete_all') }}" <form method="post" action="{{ url_for('imaging_delete_all') }}"
onsubmit="return confirm('Clear all {{ sessions|length }} imaging session(s)? This wipes every tile from the dashboard. Live re-images will repopulate on next status push.');" onsubmit="return confirm('Clear all {{ sessions|length }} imaging session(s)? This wipes every tile from the dashboard. Live re-images will repopulate on next status push.');"
style="display:inline;"> style="display:inline;">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-danger">Clear all</button> <button type="submit" class="pxe-btn pxe-btn-danger pxe-btn-sm">
<i class="bi bi-trash"></i> Clear all
</button>
</form> </form>
{% endif %} {% endif %}
</div> </div>
</div> </div>
<div class="mb-3"> <div class="form-field mb-3">
<input id="imaging-search" type="search" class="form-control form-control-sm" <input id="imaging-search" type="search" class="form-control form-control-sm"
placeholder="Filter by serial, hostname, pctype, machine#, Intune id, MAC, IP, stage name, stage-N, status, source (client|inferred)" placeholder="Filter by serial, hostname, pctype, machine#, Intune id, MAC, IP, stage name, stage-N, status, source (client|inferred)"
autocomplete="off"> autocomplete="off">
</div> </div>
{% if not sessions %} {% if not sessions %}
<div id="imaging-empty" class="card"> <div id="imaging-empty" class="pxe-card text-center text-light" style="padding: 3rem 1.25rem;">
<div class="card-body text-center text-muted py-5"> <p class="mb-1"><i class="bi bi-hdd-network" style="font-size: 1.75rem; opacity: 0.5;"></i></p>
<p class="mb-1">No imaging sessions yet.</p> <p class="mb-1">No imaging sessions yet.</p>
<p class="small mb-0">A PC being imaged will post status here, or appear <p class="small mb-0">A PC being imaged will post status here, or appear
automatically once it touches DHCP / TFTP / boot.wim.</p> automatically once it touches DHCP / TFTP / boot.wim.</p>
</div>
</div> </div>
{% endif %} {% endif %}
@@ -58,10 +61,10 @@ function cancelImagingReload() {
</div> </div>
<div class="card mt-3"> <div class="section-card mt-3">
<div class="card-body small text-muted"> <div class="section-title">How to push status from an imaging client</div>
<strong>How to push status from an imaging client:</strong> <div class="pxe-card-body">
<pre class="mb-0 mt-2">POST http://172.16.9.1:9009/imaging/status <pre class="mono mb-0" style="white-space: pre-wrap;">POST http://172.16.9.1:9009/imaging/status
Content-Type: application/json Content-Type: application/json
{ {
@@ -140,19 +143,19 @@ Content-Type: application/json
function startSSE() { function startSSE() {
if (!window.EventSource) { if (!window.EventSource) {
setDot('#ffc107', 'EventSource unsupported - polling only'); setDot('var(--warning)', 'EventSource unsupported - polling only');
startPolling(); startPolling();
return; return;
} }
try { try {
sse = new EventSource(STREAM_URL); sse = new EventSource(STREAM_URL);
} catch (e) { } catch (e) {
setDot('#dc3545', 'SSE failed - polling'); setDot('var(--danger)', 'SSE failed - polling');
startPolling(); startPolling();
return; return;
} }
sse.onopen = function() { sse.onopen = function() {
setDot('#198754', 'live stream connected'); setDot('var(--success)', 'live stream connected');
stopPolling(); stopPolling();
}; };
sse.onmessage = function(ev) { sse.onmessage = function(ev) {
@@ -163,7 +166,7 @@ Content-Type: application/json
refreshTiles(false); refreshTiles(false);
}; };
sse.onerror = function() { sse.onerror = function() {
setDot('#dc3545', 'live stream lost - polling fallback'); setDot('var(--danger)', 'live stream lost - polling fallback');
try { sse.close(); } catch (e) {} try { sse.close(); } catch (e) {}
sse = null; sse = null;
startPolling(); startPolling();

View File

@@ -2,23 +2,23 @@
{% block title %}{{ session.serial }} - Imaging Session{% endblock %} {% block title %}{{ session.serial }} - Imaging Session{% endblock %}
{% block content %} {% block content %}
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="page-header">
<div> <div>
<h2 class="mb-0">{{ session.serial }} <h2>{{ session.serial }}
{% if session.hostname_target %}<small class="text-muted">{{ session.hostname_target }}</small>{% endif %} {% if session.hostname_target %}<span class="text-light" style="font-size:14px; font-weight:400;">{{ session.hostname_target }}</span>{% endif %}
</h2> </h2>
<small class="text-muted"> <div style="margin-top:0.5rem; display:flex; gap:0.4rem; flex-wrap:wrap;">
{% if session.pctype %}<span class="badge bg-info text-dark">{{ session.pctype }}</span>{% endif %} {% if session.pctype %}<span class="badge badge-info">{{ session.pctype }}</span>{% endif %}
{% if session.machinenumber %}<span class="badge bg-secondary">#{{ session.machinenumber }}</span>{% endif %} {% if session.machinenumber %}<span class="badge badge-secondary">#{{ session.machinenumber }}</span>{% endif %}
<span class="badge bg-{{ 'success' if session.status == 'succeeded' else ('danger' if session.status == 'failed' else 'primary') }}">{{ session.status or 'in_progress' }}</span> <span class="badge badge-{{ 'success' if session.status == 'succeeded' else ('danger' if session.status == 'failed' else 'primary') }}">{{ session.status or 'in_progress' }}</span>
</small> </div>
</div> </div>
<div> <div class="header-actions">
<a href="{{ url_for('imaging_dashboard') }}" class="btn btn-outline-secondary btn-sm"> <a href="{{ url_for('imaging_dashboard') }}" class="btn btn-outline-secondary btn-sm">
&laquo; Back to dashboard <i class="bi bi-arrow-left"></i> Back to dashboard
</a> </a>
<button type="button" class="btn btn-outline-primary btn-sm copy-summary-btn"> <button type="button" class="btn btn-primary btn-sm copy-summary-btn">
Copy support summary <i class="bi bi-clipboard"></i> Copy support summary
</button> </button>
</div> </div>
</div> </div>
@@ -26,101 +26,97 @@
<div class="row"> <div class="row">
<div class="col-lg-5"> <div class="col-lg-5">
<div class="card mb-3"> <div class="section-card">
<div class="card-header">Session metadata</div> <div class="section-title">Session metadata</div>
<div class="card-body"> <dl class="row mb-0">
<dl class="row mb-0 small"> <dt class="col-4 text-light">Serial</dt><dd class="col-8"><span class="mono">{{ session.serial }}</span></dd>
<dt class="col-4">Serial</dt><dd class="col-8"><code>{{ session.serial }}</code></dd> <dt class="col-4 text-light">Hostname</dt><dd class="col-8"><span class="mono">{{ session.hostname_target or '-' }}</span></dd>
<dt class="col-4">Hostname</dt><dd class="col-8"><code>{{ session.hostname_target or '-' }}</code></dd> <dt class="col-4 text-light">MAC</dt><dd class="col-8"><span class="mono">{{ session.mac or '-' }}</span></dd>
<dt class="col-4">MAC</dt><dd class="col-8"><code>{{ session.mac or '-' }}</code></dd> <dt class="col-4 text-light">PC type</dt><dd class="col-8">{{ session.pctype or '-' }}</dd>
<dt class="col-4">PC type</dt><dd class="col-8">{{ session.pctype or '-' }}</dd> <dt class="col-4 text-light">Machine #</dt><dd class="col-8">{{ session.machinenumber or '-' }}</dd>
<dt class="col-4">Machine #</dt><dd class="col-8">{{ session.machinenumber or '-' }}</dd> <dt class="col-4 text-light">Intune ID</dt><dd class="col-8">
<dt class="col-4">Intune ID</dt><dd class="col-8"> {% if session.intune_device_id %}
{% if session.intune_device_id %} <span class="mono" style="word-break: break-all;">{{ session.intune_device_id }}</span>
<code style="word-break: break-all;">{{ session.intune_device_id }}</code> {% else %}-{% endif %}
{% else %}-{% endif %} </dd>
</dd> <dt class="col-4 text-light">Started</dt><dd class="col-8"><span class="mono">{{ session.started_at or '-' }}</span></dd>
<dt class="col-4">Started</dt><dd class="col-8"><code>{{ session.started_at or '-' }}</code></dd> <dt class="col-4 text-light">Last update</dt><dd class="col-8"><span class="mono">{{ session.last_updated or '-' }}</span></dd>
<dt class="col-4">Last update</dt><dd class="col-8"><code>{{ session.last_updated or '-' }}</code></dd> <dt class="col-4 text-light">Stage</dt><dd class="col-8">
<dt class="col-4">Stage</dt><dd class="col-8"> {{ session.stage_index or 0 }}/{{ session.stage_total or '?' }}
{{ session.stage_index or 0 }}/{{ session.stage_total or '?' }} {% if session.current_stage %} - <span class="mono">{{ session.current_stage }}</span>{% endif %}
{% if session.current_stage %} - <code class="small">{{ session.current_stage }}</code>{% endif %} </dd>
</dd> {% if session.previous_run_at %}
{% if session.previous_run_at %} <dt class="col-4 text-light">Prior run</dt><dd class="col-8"><span class="mono">{{ session.previous_run_at }}</span></dd>
<dt class="col-4">Prior run</dt><dd class="col-8"><code>{{ session.previous_run_at }}</code></dd> {% endif %}
{% endif %} {% if session.error %}
{% if session.error %} <dt class="col-4 text-danger">Error</dt>
<dt class="col-4 text-danger">Error</dt> <dd class="col-8 text-danger">{{ session.error }}</dd>
<dd class="col-8 text-danger">{{ session.error }}</dd> {% endif %}
{% endif %} </dl>
</dl>
</div>
</div> </div>
<div class="card mb-3"> <div class="section-card">
<div class="card-header">Stage timeline</div> <div class="section-title">Stage timeline</div>
<div class="card-body p-0"> {% if session.stage_history %}
{% if session.stage_history %} <div class="table-container">
<table class="table table-sm mb-0"> <table class="data-table mb-0">
<thead class="table-light"> <thead>
<tr><th>Time</th><th>Stage</th><th>Status</th><th>Detail</th></tr> <tr><th>Time</th><th>Stage</th><th>Status</th><th>Detail</th></tr>
</thead> </thead>
<tbody> <tbody>
{% for h in session.stage_history %} {% for h in session.stage_history %}
<tr> <tr>
<td class="small"><code>{{ h.ts }}</code></td> <td><span class="mono">{{ h.ts }}</span></td>
<td><span class="badge bg-secondary">{{ h.stage_index }}</span></td> <td><span class="badge badge-secondary">{{ h.stage_index }}</span></td>
<td> <td>
<span class="badge bg-{{ 'success' if h.status == 'succeeded' else ('danger' if h.status == 'failed' else 'primary') }}"> <span class="badge badge-{{ 'success' if h.status == 'succeeded' else ('danger' if h.status == 'failed' else 'primary') }}">
{{ h.status }} {{ h.status }}
</span> </span>
</td> </td>
<td class="small"><code>{{ h.current_stage or '' }}</code></td> <td><span class="mono">{{ h.current_stage or '' }}</span></td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
{% else %}
<div class="p-3 text-muted small">
No stage transitions recorded yet. The client only logs a row when
stage_index advances or status flips to succeeded/failed.
</div>
{% endif %}
</div> </div>
{% else %}
<div class="status-indicator">
<i class="bi bi-info-circle"></i>
<span>No stage transitions recorded yet. The client only logs a row when
stage_index advances or status flips to succeeded/failed.</span>
</div>
{% endif %}
</div> </div>
</div> </div>
<div class="col-lg-7"> <div class="col-lg-7">
<div class="card mb-3"> <div class="section-card">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="section-title" style="display:flex; justify-content:space-between; align-items:center;">
<span>Full log <span>Full log
{% if full_log_truncated %} {% if full_log_truncated %}
<span class="badge bg-warning text-dark ms-2">truncated (last 1 MB)</span> <span class="badge badge-warning" style="margin-left:0.5rem;">truncated (last 1 MB)</span>
{% endif %} {% endif %}
</span> </span>
<button type="button" class="btn btn-sm btn-outline-secondary copy-log-btn">Copy</button> <button type="button" class="btn btn-outline-secondary btn-sm copy-log-btn"><i class="bi bi-clipboard"></i> Copy</button>
</div> </div>
<div class="card-body p-0"> {% if full_log %}
{% if full_log %} <pre id="full-log" class="mono mb-0" style="max-height: 36rem; overflow: auto; white-space: pre-wrap; background: var(--bg); padding: 0.75rem; border-radius: 0.25rem;">{{ full_log }}</pre>
<pre id="full-log" class="bg-light p-2 mb-0 small" style="max-height: 36rem; overflow-y: auto; white-space: pre-wrap;">{{ full_log }}</pre> {% else %}
{% else %} <div class="status-indicator">
<div class="p-3 text-muted small"> <i class="bi bi-info-circle"></i>
No log content. The client has not pushed any <code>log_lines</code> <span>No log content. The client has not pushed any <code>log_lines</code>
entries yet, or the sidecar file was cleared. entries yet, or the sidecar file was cleared.</span>
</div>
{% endif %}
</div> </div>
{% endif %}
</div> </div>
{% if session.log_tail %} {% if session.log_tail %}
<div class="card mb-3"> <div class="section-card">
<div class="card-header">Recent tail ({{ session.log_tail | length }} line{{ 's' if session.log_tail | length != 1 }})</div> <div class="section-title">Recent tail ({{ session.log_tail | length }} line{{ 's' if session.log_tail | length != 1 }})</div>
<div class="card-body p-0"> <pre class="mono mb-0" style="max-height: 12rem; overflow: auto; background: var(--bg); padding: 0.75rem; border-radius: 0.25rem;">{% for line in session.log_tail %}{{ line }}
<pre class="bg-light p-2 mb-0 small" style="max-height: 12rem; overflow-y: auto;">{% for line in session.log_tail %}{{ line }}
{% endfor %}</pre> {% endfor %}</pre>
</div>
</div> </div>
{% endif %} {% endif %}
@@ -153,15 +149,17 @@ function copyText(text) {
} }
document.addEventListener('click', function(e) { document.addEventListener('click', function(e) {
if (e.target.classList.contains('copy-log-btn')) { var logBtn = e.target.closest('.copy-log-btn');
var summaryBtn = e.target.closest('.copy-summary-btn');
if (logBtn) {
var pre = document.getElementById('full-log'); var pre = document.getElementById('full-log');
if (pre) { if (pre) {
copyText(pre.textContent).then(function() { copyText(pre.textContent).then(function() {
e.target.textContent = 'copied!'; logBtn.innerHTML = '<i class="bi bi-check2"></i> copied!';
setTimeout(function() { e.target.textContent = 'Copy'; }, 1200); setTimeout(function() { logBtn.innerHTML = '<i class="bi bi-clipboard"></i> Copy'; }, 1200);
}); });
} }
} else if (e.target.classList.contains('copy-summary-btn')) { } else if (summaryBtn) {
var lines = []; var lines = [];
document.querySelectorAll('dl dt').forEach(function(dt) { document.querySelectorAll('dl dt').forEach(function(dt) {
var dd = dt.nextElementSibling; var dd = dt.nextElementSibling;
@@ -182,8 +180,8 @@ document.addEventListener('click', function(e) {
}); });
} }
copyText(lines.join('\n')).then(function() { copyText(lines.join('\n')).then(function() {
e.target.textContent = 'copied!'; summaryBtn.innerHTML = '<i class="bi bi-check2"></i> copied!';
setTimeout(function() { e.target.textContent = 'Copy support summary'; }, 1200); setTimeout(function() { summaryBtn.innerHTML = '<i class="bi bi-clipboard"></i> Copy support summary'; }, 1200);
}); });
} }
}); });

View File

@@ -1,192 +1,180 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Image Import - PXE Server Manager{% endblock %} {% block title %}Image Import - PXE Server Manager{% endblock %}
{% block content %} {% block content %}
<h2 class="mb-4">Image Import</h2> <div class="page-header">
<h1><i class="bi bi-box-arrow-in-down"></i> Image Import</h1>
<div class="row"> </div>
<div class="col-lg-8">
<div class="row g-4">
<!-- Network Upload Import --> <div class="col-lg-8">
<div class="card mb-3">
<div class="card-header"> <!-- Network Upload Import -->
Import from Network Upload <div class="section-card">
</div> <div class="section-title"><i class="bi bi-hdd-network"></i> Import from Network Upload</div>
<div class="card-body"> {% if upload_sources %}
{% if upload_sources %} <form method="POST" id="uploadImportForm">
<form method="POST" id="uploadImportForm"> <input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}"> <div class="form-field">
<div class="mb-3"> <label for="uploadSource" class="form-label">Source</label>
<label for="uploadSource" class="form-label fw-semibold">Source</label> <select class="form-select" name="source" id="uploadSource" required>
<select class="form-select" name="source" id="uploadSource" required> <option value="">-- Select upload source --</option>
<option value="">-- Select upload source --</option> {% for src in upload_sources %}
{% for src in upload_sources %} <option value="{{ src }}">{{ src }}</option>
<option value="{{ src }}">{{ src }}</option> {% endfor %}
{% endfor %} </select>
</select> <div class="form-text">
<div class="form-text"> Files uploaded via SMB to <code class="mono">\\172.16.9.1\image-upload</code>
Files uploaded via SMB to <code>\\172.16.9.1\image-upload</code> </div>
</div> </div>
</div>
<div class="form-field">
<div class="mb-3"> <label for="uploadTarget" class="form-label">Target Image Type</label>
<label for="uploadTarget" class="form-label fw-semibold">Target Image Type</label> <select class="form-select" name="target" id="uploadTarget" required>
<select class="form-select" name="target" id="uploadTarget" required> <option value="">-- Select target image --</option>
<option value="">-- Select target image --</option> {% for it in image_types %}
{% for it in image_types %} <option value="{{ it }}">{{ friendly_names[it] }} ({{ it }})</option>
<option value="{{ it }}">{{ friendly_names[it] }} ({{ it }})</option> {% endfor %}
{% endfor %} </select>
</select> <div class="form-text">
<div class="form-text"> Content will be copied into the Deploy directory. Shared resources
Content will be copied into the Deploy directory. Shared resources (Out-of-box Drivers) are stored once and linked across all image types.
(Out-of-box Drivers) are stored once and linked across all image types. </div>
</div> </div>
</div>
<div class="alert alert-info" role="alert">
<div class="alert alert-info d-flex align-items-start" role="alert"> <strong>Shared Drivers:</strong> Out-of-box Drivers are automatically pooled
<div> into a shared directory and symlinked for each image type to save disk space.
<strong>Shared Drivers:</strong> Out-of-box Drivers are automatically pooled </div>
into a shared directory and symlinked for each image type to save disk space.
</div> <div class="alert alert-warning" role="alert">
</div> <strong>Warning:</strong> Existing files in the target Deploy directory with the
same names will be overwritten. This operation may take several minutes for large
<div class="alert alert-warning d-flex align-items-start" role="alert"> images.
<div> </div>
<strong>Warning:</strong> Existing files in the target Deploy directory with the
same names will be overwritten. This operation may take several minutes for large <button type="submit" class="pxe-btn pxe-btn-primary" id="uploadImportBtn">
images. <i class="bi bi-play-fill"></i> Start Import
</div> </button>
</div> </form>
{% else %}
<button type="submit" class="btn btn-primary" id="uploadImportBtn"> <div class="text-center py-4">
Start Import <i class="bi bi-cloud-slash" style="font-size:2rem;color:var(--text-light);"></i>
</button> <h5 class="mt-3" style="color:var(--text-light);">No Upload Content Found</h5>
</form> <p class="mb-0" style="color:var(--text-light);">
{% else %} Map <code class="mono">\\172.16.9.1\image-upload</code> on your Windows PC and copy
<div class="text-center py-4"> the Deploy directory contents there.
<h5 class="mt-3 text-muted">No Upload Content Found</h5> </p>
<p class="text-muted mb-0"> <button class="pxe-btn pxe-btn-ghost pxe-btn-sm mt-3" onclick="location.reload()">
Map <code>\\172.16.9.1\image-upload</code> on your Windows PC and copy <i class="bi bi-arrow-clockwise"></i> Refresh
the Deploy directory contents there. </button>
</p> </div>
<button class="btn btn-outline-secondary btn-sm mt-3" onclick="location.reload()"> {% endif %}
Refresh </div>
</button>
</div> <!-- USB Import -->
{% endif %} <div class="section-card">
</div> <div class="section-title"><i class="bi bi-usb-drive"></i> Import from USB Drive</div>
</div> {% if usb_mounts %}
<form method="POST" id="importForm">
<!-- USB Import --> <input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="card"> <div class="form-field">
<div class="card-header"> <label for="source" class="form-label">Source (USB Mount Point)</label>
Import from USB Drive <select class="form-select" name="source" id="source" required>
</div> <option value="">-- Select a mounted USB drive --</option>
<div class="card-body"> {% for mount in usb_mounts %}
{% if usb_mounts %} <option value="{{ mount }}">{{ mount }}</option>
<form method="POST" id="importForm"> {% endfor %}
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}"> </select>
<div class="mb-3"> <div class="form-text">
<label for="source" class="form-label fw-semibold">Source (USB Mount Point)</label> Select the mounted USB drive containing the WinPE deployment content.
<select class="form-select" name="source" id="source" required> </div>
<option value="">-- Select a mounted USB drive --</option> </div>
{% for mount in usb_mounts %}
<option value="{{ mount }}">{{ mount }}</option> <div class="form-field">
{% endfor %} <label for="target" class="form-label">Target Image Type</label>
</select> <select class="form-select" name="target" id="target" required>
<div class="form-text"> <option value="">-- Select target image --</option>
Select the mounted USB drive containing the WinPE deployment content. {% for it in image_types %}
</div> <option value="{{ it }}">{{ friendly_names[it] }} ({{ it }})</option>
</div> {% endfor %}
</select>
<div class="mb-3"> <div class="form-text">
<label for="target" class="form-label fw-semibold">Target Image Type</label> Content will be copied into the Deploy directory for this image type.
<select class="form-select" name="target" id="target" required> </div>
<option value="">-- Select target image --</option> </div>
{% for it in image_types %}
<option value="{{ it }}">{{ friendly_names[it] }} ({{ it }})</option> <div class="alert alert-warning" role="alert">
{% endfor %} <strong>Warning:</strong> Existing files in the target Deploy directory with the
</select> same names will be overwritten. This operation may take several minutes for large
<div class="form-text"> images.
Content will be copied into the Deploy directory for this image type. </div>
</div>
</div> <button type="submit" class="pxe-btn pxe-btn-primary" id="importBtn">
<i class="bi bi-play-fill"></i> Start Import
<div class="alert alert-warning d-flex align-items-start" role="alert"> </button>
<div> </form>
<strong>Warning:</strong> Existing files in the target Deploy directory with the {% else %}
same names will be overwritten. This operation may take several minutes for large <div class="text-center py-4">
images. <i class="bi bi-usb-drive" style="font-size:2rem;color:var(--text-light);"></i>
</div> <h5 class="mt-3" style="color:var(--text-light);">No USB Drives Detected</h5>
</div> <p class="mb-0" style="color:var(--text-light);">
No mounted USB drives were found under <code class="mono">/mnt/</code> or <code class="mono">/media/</code>.<br>
<button type="submit" class="btn btn-primary" id="importBtn"> Mount a USB drive and refresh this page.
Start Import </p>
</button> <button class="pxe-btn pxe-btn-ghost pxe-btn-sm mt-3" onclick="location.reload()">
</form> <i class="bi bi-arrow-clockwise"></i> Refresh
{% else %} </button>
<div class="text-center py-4"> </div>
<h5 class="mt-3 text-muted">No USB Drives Detected</h5> {% endif %}
<p class="text-muted mb-0"> </div>
No mounted USB drives were found under <code>/mnt/</code> or <code>/media/</code>.<br> </div>
Mount a USB drive and refresh this page.
</p> <div class="col-lg-4">
<button class="btn btn-outline-secondary btn-sm mt-3" onclick="location.reload()"> <div class="section-card">
Refresh <div class="section-title"><i class="bi bi-list-check"></i> Current Image Status</div>
</button> <div class="table-container">
</div> <table class="data-table mb-0">
{% endif %} <thead>
</div> <tr>
</div> <th>Image</th>
</div> <th>Content</th>
</tr>
<div class="col-lg-4"> </thead>
<div class="card"> <tbody>
<div class="card-header"> {% for img in images %}
Current Image Status <tr>
</div> <td>{{ img.friendly_name }}</td>
<div class="card-body p-0"> <td>
<table class="table table-sm mb-0"> {% if img.has_content %}
<thead class="table-light"> <span class="badge badge-success">Present</span>
<tr> {% else %}
<th>Image</th> <span class="badge badge-secondary">Empty</span>
<th>Content</th> {% endif %}
</tr> </td>
</thead> </tr>
<tbody> {% endfor %}
{% for img in images %} </tbody>
<tr> </table>
<td class="small">{{ img.friendly_name }}</td> </div>
<td> </div>
{% if img.has_content %} </div>
<span class="badge bg-success">Present</span> </div>
{% else %} {% endblock %}
<span class="badge bg-secondary">Empty</span>
{% endif %} {% block extra_scripts %}
</td> <script>
</tr> document.addEventListener('DOMContentLoaded', function() {
{% endfor %} ['importForm', 'uploadImportForm'].forEach(function(formId) {
</tbody> var form = document.getElementById(formId);
</table> if (form) {
</div> form.addEventListener('submit', function() {
</div> var btn = form.querySelector('button[type="submit"]');
</div> btn.disabled = true;
</div> btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span> Importing...';
{% endblock %} });
}
{% block extra_scripts %} });
<script> });
document.addEventListener('DOMContentLoaded', function() { </script>
['importForm', 'uploadImportForm'].forEach(function(formId) { {% endblock %}
var form = document.getElementById(formId);
if (form) {
form.addEventListener('submit', function() {
var btn = form.querySelector('button[type="submit"]');
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span> Importing...';
});
}
});
});
</script>
{% endblock %}

View File

@@ -24,86 +24,77 @@
{% block content %} {% block content %}
<div class="d-flex justify-content-between align-items-start mb-3 flex-wrap gap-2"> <div class="mb-2">
<a href="{{ url_for('blancco_reports') }}" class="pxe-btn-link" style="padding:0;">&larr; All reports</a>
</div>
<div class="page-header">
<div> <div>
<div class="text-muted small mb-1"> <h2>Drive Erasure Certificate</h2>
<a href="{{ url_for('blancco_reports') }}" class="text-decoration-none">&larr; All reports</a> <code class="mono" style="color:var(--text-light);">{{ filename }}</code>
</div>
<h2 class="mb-0">Drive Erasure Certificate</h2>
<code class="small text-muted">{{ filename }}</code>
</div> </div>
<div class="text-end"> <div class="header-actions">
<a href="{{ url_for('blancco_download_report', filename=filename) }}" <a href="{{ url_for('blancco_download_report', filename=filename) }}"
class="btn btn-sm btn-outline-primary">Download XML</a> class="pxe-btn pxe-btn-ghost">
<i class="bi bi-download"></i> Download XML
</a>
</div> </div>
</div> </div>
{# Hero summary: state badge + standard + drive count + erasure datetime #} {# Hero summary: state badge + standard + drive count + erasure datetime #}
{% set first = (data.erasures or [{}])[0] %} {% set first = (data.erasures or [{}])[0] %}
{% set overall_state = first.state or 'Unknown' %} {% set overall_state = first.state or 'Unknown' %}
<div class="card border-{{ 'success' if overall_state == 'Successful' else 'danger' if overall_state else 'secondary' }} mb-4"> {% set overall_tone = 'success' if overall_state == 'Successful' else 'danger' if overall_state else 'secondary' %}
<div class="card-body"> {% set overall_stat = 'success' if overall_state == 'Successful' else 'danger' if overall_state else '' %}
<div class="row g-3 align-items-center"> <div class="dashboard-grid mb-4">
<div class="col-md-3"> <div class="stat-card {{ overall_stat }}">
<div class="text-muted small">Overall result</div> <div class="label">Overall result</div>
<div class="fs-3"> <div class="value" style="font-size:1.5rem;">
<span class="badge bg-{{ 'success' if overall_state == 'Successful' else 'danger' if overall_state else 'secondary' }}"> <span class="badge badge-lg bg-{{ overall_tone }}">{{ overall_state }}</span>
{{ overall_state }}
</span>
</div>
</div>
<div class="col-md-3">
<div class="text-muted small">Erasure standard</div>
<div>{{ first.erasure_standard_name or '-' }}</div>
</div>
<div class="col-md-2">
<div class="text-muted small">Drives erased</div>
<div class="fs-5">{{ data.erasures | length }}</div>
</div>
<div class="col-md-2">
<div class="text-muted small">Elapsed time</div>
<div class="fs-5"><code>{{ first.elapsed_time or '-' }}</code></div>
</div>
<div class="col-md-2">
<div class="text-muted small">Erased at</div>
<div><code class="small">{{ first.timestamp or meta.date or '-' }}</code></div>
</div>
</div> </div>
</div> </div>
<div class="stat-card">
<div class="label">Erasure standard</div>
<div class="value" style="font-size:1.05rem;">{{ first.erasure_standard_name or '-' }}</div>
</div>
<div class="stat-card">
<div class="label">Drives erased</div>
<div class="value">{{ data.erasures | length }}</div>
</div>
<div class="stat-card">
<div class="label">Elapsed time</div>
<div class="value mono" style="font-size:1.5rem;">{{ first.elapsed_time or '-' }}</div>
</div>
<div class="stat-card">
<div class="label">Erased at</div>
<div class="value mono" style="font-size:1rem;">{{ first.timestamp or meta.date or '-' }}</div>
</div>
</div> </div>
<div class="row g-3 mb-3"> <div class="content-grid mb-3">
<div class="col-md-6"> <div class="section-card">
<div class="card h-100"> <div class="section-title">Customer</div>
<div class="card-header">Customer</div> <dl class="row mb-0 small">
<div class="card-body"> <dt class="col-sm-5">Business name</dt><dd class="col-sm-7">{{ company.business_name or '-' }}</dd>
<dl class="row mb-0 small"> <dt class="col-sm-5">Location</dt> <dd class="col-sm-7">{{ company.business_location or '-' }}</dd>
<dt class="col-sm-5">Business name</dt><dd class="col-sm-7">{{ company.business_name or '-' }}</dd> <dt class="col-sm-5">License holder</dt><dd class="col-sm-7">{{ company.customer_license or '-' }}</dd>
<dt class="col-sm-5">Location</dt> <dd class="col-sm-7">{{ company.business_location or '-' }}</dd> {% for k, v in company.items() %}
<dt class="col-sm-5">License holder</dt><dd class="col-sm-7">{{ company.customer_license or '-' }}</dd> {% if k not in ['business_name','business_location','customer_license'] %}
{% for k, v in company.items() %} <dt class="col-sm-5">{{ k|replace('_',' ')|title }}</dt><dd class="col-sm-7">{{ v }}</dd>
{% if k not in ['business_name','business_location','customer_license'] %} {% endif %}
<dt class="col-sm-5">{{ k|replace('_',' ')|title }}</dt><dd class="col-sm-7">{{ v }}</dd> {% endfor %}
{% endif %} </dl>
{% endfor %}
</dl>
</div>
</div>
</div> </div>
<div class="col-md-6"> <div class="section-card">
<div class="card h-100"> <div class="section-title">System</div>
<div class="card-header">System</div> <dl class="row mb-0 small">
<div class="card-body"> <dt class="col-sm-5">Manufacturer</dt> <dd class="col-sm-7">{{ sysinfo.manufacturer or '-' }}</dd>
<dl class="row mb-0 small"> <dt class="col-sm-5">Model</dt> <dd class="col-sm-7">{{ sysinfo.model or '-' }}</dd>
<dt class="col-sm-5">Manufacturer</dt> <dd class="col-sm-7">{{ sysinfo.manufacturer or '-' }}</dd> <dt class="col-sm-5">Serial</dt> <dd class="col-sm-7"><code class="mono">{{ sysinfo.serial or '-' }}</code></dd>
<dt class="col-sm-5">Model</dt> <dd class="col-sm-7">{{ sysinfo.model or '-' }}</dd> <dt class="col-sm-5">Chassis</dt> <dd class="col-sm-7">{{ sysinfo.chassis_type or '-' }}</dd>
<dt class="col-sm-5">Serial</dt> <dd class="col-sm-7"><code>{{ sysinfo.serial or '-' }}</code></dd> <dt class="col-sm-5">BIOS mode</dt> <dd class="col-sm-7">{{ sysinfo.bios_mode or '-' }} {% if sysinfo.secure_boot_state %}<span class="badge badge-secondary">SecureBoot {{ sysinfo.secure_boot_state }}</span>{% endif %}</dd>
<dt class="col-sm-5">Chassis</dt> <dd class="col-sm-7">{{ sysinfo.chassis_type or '-' }}</dd> <dt class="col-sm-5">BIOS vendor</dt> <dd class="col-sm-7">{{ bios.vendor or '-' }} {{ bios.version or '' }}</dd>
<dt class="col-sm-5">BIOS mode</dt> <dd class="col-sm-7">{{ sysinfo.bios_mode or '-' }} {% if sysinfo.secure_boot_state %}<span class="badge bg-secondary">SecureBoot {{ sysinfo.secure_boot_state }}</span>{% endif %}</dd> </dl>
<dt class="col-sm-5">BIOS vendor</dt> <dd class="col-sm-7">{{ bios.vendor or '-' }} {{ bios.version or '' }}</dd>
</dl>
</div>
</div>
</div> </div>
</div> </div>
@@ -111,48 +102,48 @@
{% for er in data.erasures %} {% for er in data.erasures %}
{% set t = er.target or {} %} {% set t = er.target or {} %}
{% set state = er.state or 'Unknown' %} {% set state = er.state or 'Unknown' %}
{% set border = 'success' if state == 'Successful' else 'danger' if state else 'secondary' %} {% set tone = 'success' if state == 'Successful' else 'danger' if state else 'secondary' %}
{% set steps = (er.steps or {}).step %} {% set steps = (er.steps or {}).step %}
{% if steps is not iterable or steps is string or steps is mapping %} {% if steps is not iterable or steps is string or steps is mapping %}
{% set steps = [steps] if steps else [] %} {% set steps = [steps] if steps else [] %}
{% endif %} {% endif %}
<div class="card border-{{ border }} mb-3"> <div class="card">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header">
<div> <div>
<strong>Drive #{{ er.erasure_id or loop.index }}:</strong> <strong>Drive #{{ er.erasure_id or loop.index }}:</strong>
{{ t.vendor or '' }} {{ t.model or '(unknown drive)' }} {{ t.vendor or '' }} {{ t.model or '(unknown drive)' }}
<code class="ms-2 small text-muted">{{ t.serial or '-' }}</code> <code class="mono ms-2" style="color:var(--text-light);">{{ t.serial or '-' }}</code>
</div> </div>
<span class="badge bg-{{ border }}">{{ state }}</span> <span class="badge bg-{{ tone }}">{{ state }}</span>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="row g-3 mb-3"> <div class="row g-3 mb-3">
<div class="col-md-3"> <div class="col-md-3">
<div class="text-muted small">Interface</div> <div class="eyebrow">Interface</div>
<div>{{ t.interface_type or '-' }}</div> <div>{{ t.interface_type or '-' }}</div>
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
<div class="text-muted small">Capacity</div> <div class="eyebrow">Capacity</div>
<div>{{ hbytes(t.capacity or 0) }}</div> <div>{{ hbytes(t.capacity or 0) }}</div>
</div> </div>
<div class="col-md-2"> <div class="col-md-2">
<div class="text-muted small">Health</div> <div class="eyebrow">Health</div>
<div>{{ t.health or '-' }}</div> <div>{{ t.health or '-' }}</div>
</div> </div>
<div class="col-md-2"> <div class="col-md-2">
<div class="text-muted small">Errors</div> <div class="eyebrow">Errors</div>
<div>{{ er.total_errors or 0 }}</div> <div>{{ er.total_errors or 0 }}</div>
</div> </div>
<div class="col-md-2"> <div class="col-md-2">
<div class="text-muted small">Sectors</div> <div class="eyebrow">Sectors</div>
<div class="small">{{ er.processed_sectors or '-' }} / {{ t.sectors or '-' }}</div> <div class="small">{{ er.processed_sectors or '-' }} / {{ t.sectors or '-' }}</div>
</div> </div>
</div> </div>
{% if steps %} {% if steps %}
<div class="table-responsive"> <div class="table-container">
<table class="table table-sm table-hover align-middle mb-0"> <table class="table mb-0">
<thead class="table-light"> <thead>
<tr> <tr>
<th style="width:3rem;">#</th> <th style="width:3rem;">#</th>
<th>Step</th> <th>Step</th>
@@ -167,13 +158,13 @@
<tr> <tr>
<td>{{ s.number or loop.index }}</td> <td>{{ s.number or loop.index }}</td>
<td>{{ s.type or '-' }}</td> <td>{{ s.type or '-' }}</td>
<td class="small text-muted">{{ s.pattern or '-' }}</td> <td class="small" style="color:var(--text-light);">{{ s.pattern or '-' }}</td>
<td> <td>
<span class="badge bg-{{ 'success' if s.state == 'completed' else 'danger' if s.state else 'secondary' }}"> <span class="badge bg-{{ 'success' if s.state == 'completed' else 'danger' if s.state else 'secondary' }}">
{{ s.state or '-' }} {{ s.state or '-' }}
</span> </span>
</td> </td>
<td><code class="small">{{ s.elapsed_time or '-' }}</code></td> <td><code class="mono">{{ s.elapsed_time or '-' }}</code></td>
<td>{{ s.errors or 0 }}</td> <td>{{ s.errors or 0 }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
@@ -185,27 +176,27 @@
</div> </div>
{% endfor %} {% endfor %}
<div class="card mb-3"> <div class="card">
<div class="card-header">Document signing &amp; provenance</div> <div class="card-header">Document signing &amp; provenance</div>
<div class="card-body"> <div class="card-body">
<dl class="row mb-0 small"> <dl class="row mb-0 small">
<dt class="col-sm-3">Document ID</dt> <dt class="col-sm-3">Document ID</dt>
<dd class="col-sm-9"><code class="text-break">{{ meta.document_id or '-' }}</code></dd> <dd class="col-sm-9"><code class="mono text-break">{{ meta.document_id or '-' }}</code></dd>
<dt class="col-sm-3">Product</dt> <dt class="col-sm-3">Product</dt>
<dd class="col-sm-9">{{ meta.product_name }} {{ meta.product_version }} (rev {{ meta.product_revision }})</dd> <dd class="col-sm-9">{{ meta.product_name }} {{ meta.product_version }} (rev {{ meta.product_revision }})</dd>
<dt class="col-sm-3">Generated</dt> <dt class="col-sm-3">Generated</dt>
<dd class="col-sm-9"><code>{{ meta.date or '-' }}</code></dd> <dd class="col-sm-9"><code class="mono">{{ meta.date or '-' }}</code></dd>
{% if data.license_consumption_ids %} {% if data.license_consumption_ids %}
<dt class="col-sm-3">License consumption ID{{ 's' if data.license_consumption_ids|length > 1 }}</dt> <dt class="col-sm-3">License consumption ID{{ 's' if data.license_consumption_ids|length > 1 }}</dt>
<dd class="col-sm-9"> <dd class="col-sm-9">
{% for lc in data.license_consumption_ids %}<code class="d-block">{{ lc }}</code>{% endfor %} {% for lc in data.license_consumption_ids %}<code class="mono d-block">{{ lc }}</code>{% endfor %}
</dd> </dd>
{% endif %} {% endif %}
<dt class="col-sm-3">Integrity hash</dt> <dt class="col-sm-3">Integrity hash</dt>
<dd class="col-sm-9"> <dd class="col-sm-9">
<details> <details>
<summary class="text-muted">Show base64 signature ({{ meta.integrity|length if meta.integrity else 0 }} chars)</summary> <summary style="color:var(--text-light);cursor:pointer;">Show base64 signature ({{ meta.integrity|length if meta.integrity else 0 }} chars)</summary>
<code class="text-break small d-block mt-2">{{ meta.integrity }}</code> <code class="mono text-break d-block mt-2">{{ meta.integrity }}</code>
</details> </details>
</dd> </dd>
</dl> </dl>

View File

@@ -1,114 +1,113 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Blancco Reports - PXE Server Manager{% endblock %} {% block title %}Blancco Reports - PXE Server Manager{% endblock %}
{% block content %} {% block content %}
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="page-header">
<h2 class="mb-0">Blancco Erasure Reports</h2> <h2><i class="bi bi-shield-check"></i> Blancco Erasure Reports</h2>
<span class="badge bg-secondary fs-6">{{ reports|length }} report{{ 's' if reports|length != 1 }}</span> <div class="header-actions">
</div> <span class="badge badge-secondary badge-lg">{{ reports|length }} report{{ 's' if reports|length != 1 }}</span>
</div>
<div class="card"> </div>
<div class="card-header d-flex align-items-center">
Drive Erasure Certificates <div class="section-card">
</div> <div class="section-title">Drive Erasure Certificates</div>
<div class="card-body p-0"> {% if reports %}
{% if reports %} <div class="table-container">
<table class="table table-hover mb-0"> <table class="data-table">
<thead class="table-light"> <thead>
<tr> <tr>
<th>Serial</th> <th>Serial</th>
<th>Model</th> <th>Model</th>
<th>Date</th> <th>Date</th>
<th>Result</th> <th>Result</th>
<th class="text-end">Actions</th> <th class="text-end">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for r in reports %} {% for r in reports %}
<tr title="{{ r.filename }}"> <tr title="{{ r.filename }}">
<td>{% if r.serial %}<code>{{ r.serial }}</code>{% else %}<span class="text-muted small">-</span>{% endif %}</td> <td>{% if r.serial %}<span class="mono">{{ r.serial }}</span>{% else %}<span class="text-muted">-</span>{% endif %}</td>
<td class="small">{{ r.model or '-' }}</td> <td>{{ r.model or '-' }}</td>
<td>{{ r.modified | timestamp_fmt }}</td> <td>{{ r.modified | timestamp_fmt }}</td>
<td> <td>
{% if r.state == 'Successful' %}<span class="badge bg-success">Successful</span> {% if r.state == 'Successful' %}<span class="badge badge-success">Successful</span>
{% elif r.state == 'Failed' %}<span class="badge bg-danger">Failed</span> {% elif r.state == 'Failed' %}<span class="badge badge-danger">Failed</span>
{% elif r.state %}<span class="badge bg-secondary">{{ r.state }}</span> {% elif r.state %}<span class="badge badge-secondary">{{ r.state }}</span>
{% else %}<span class="text-muted small">-</span>{% endif %} {% else %}<span class="text-muted">-</span>{% endif %}
</td> </td>
<td class="text-end text-nowrap"> <td class="actions text-end text-nowrap">
{% if r.filename.lower().endswith('.xml') %} {% if r.filename.lower().endswith('.xml') %}
<a href="{{ url_for('blancco_view_report', filename=r.filename) }}" <a href="{{ url_for('blancco_view_report', filename=r.filename) }}"
class="btn btn-sm btn-outline-success" title="View formatted report"> class="pxe-btn pxe-btn-success btn-row-action" title="View formatted report">
View <i class="bi bi-eye"></i> View
</a> </a>
{% endif %} {% endif %}
<a href="{{ url_for('blancco_download_report', filename=r.filename) }}" <a href="{{ url_for('blancco_download_report', filename=r.filename) }}"
class="btn btn-sm btn-outline-primary" title="Download"> class="pxe-btn pxe-btn-secondary btn-row-action" title="Download">
Download <i class="bi bi-download"></i> Download
</a> </a>
<button type="button" class="btn btn-sm btn-outline-danger" <button type="button" class="pxe-btn pxe-btn-danger btn-row-action"
data-bs-toggle="modal" data-bs-target="#deleteModal" data-bs-toggle="modal" data-bs-target="#deleteModal"
data-filename="{{ r.filename }}" title="Delete"> data-filename="{{ r.filename }}" title="Delete">
Delete <i class="bi bi-trash"></i> Delete
</button> </button>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
{% else %} </div>
<div class="text-center text-muted py-5"> {% else %}
<p class="mt-2">No erasure reports yet.</p> <div class="text-center text-muted py-5">
<p class="small">Reports will appear here after Blancco Drive Eraser completes a wipe.</p> <p class="mt-2"><i class="bi bi-inbox" style="font-size:1.75rem;"></i></p>
</div> <p class="mb-1">No erasure reports yet.</p>
{% endif %} <p class="small">Reports will appear here after Blancco Drive Eraser completes a wipe.</p>
</div> </div>
</div> {% endif %}
</div>
<div class="card mt-3">
<div class="card-body"> <div class="pxe-card mt-3">
<h6 class="card-title">Report Storage</h6> <h3 class="pxe-card-title"><i class="bi bi-hdd-network"></i> Report Storage</h3>
<p class="card-text mb-1"> <p class="mb-1">
Blancco Drive Eraser saves erasure certificates to the network share Blancco Drive Eraser saves erasure certificates to the network share
<code>\\172.16.9.1\blancco-reports</code>. <span class="mono">\\172.16.9.1\blancco-reports</span>.
</p> </p>
<p class="card-text mb-0 text-muted"> <p class="mb-0 text-muted">
Reports are generated automatically after each drive wipe and contain proof of erasure for compliance and audit purposes. Reports are generated automatically after each drive wipe and contain proof of erasure for compliance and audit purposes.
</p> </p>
</div> </div>
</div>
<!-- Delete Confirmation Modal -->
<!-- Delete Confirmation Modal --> <div class="modal fade" id="deleteModal" tabindex="-1">
<div class="modal fade" id="deleteModal" tabindex="-1"> <div class="modal-dialog">
<div class="modal-dialog"> <div class="modal-content">
<div class="modal-content"> <form id="deleteForm" method="post">
<form id="deleteForm" method="post"> <input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}"> <div class="modal-header">
<div class="modal-header"> <h5 class="modal-title">Confirm Delete</h5>
<h5 class="modal-title">Confirm Delete</h5> <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button> </div>
</div> <div class="modal-body">
<div class="modal-body"> <p>Are you sure you want to delete <strong id="deleteFilename"></strong>?</p>
<p>Are you sure you want to delete <strong id="deleteFilename"></strong>?</p> <p class="text-muted mb-0">Erasure reports may be needed for compliance audits. This action cannot be undone.</p>
<p class="text-muted mb-0">Erasure reports may be needed for compliance audits. This action cannot be undone.</p> </div>
</div> <div class="modal-footer">
<div class="modal-footer"> <button type="button" class="pxe-btn pxe-btn-ghost" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> <button type="submit" class="pxe-btn pxe-btn-danger">Delete</button>
<button type="submit" class="btn btn-danger">Delete</button> </div>
</div> </form>
</form> </div>
</div> </div>
</div> </div>
</div> {% endblock %}
{% endblock %}
{% block extra_scripts %}
{% block extra_scripts %} <script>
<script> document.getElementById('deleteModal').addEventListener('show.bs.modal', function (event) {
document.getElementById('deleteModal').addEventListener('show.bs.modal', function (event) { var btn = event.relatedTarget;
var btn = event.relatedTarget; var filename = btn.getAttribute('data-filename');
var filename = btn.getAttribute('data-filename'); document.getElementById('deleteFilename').textContent = filename;
document.getElementById('deleteFilename').textContent = filename; document.getElementById('deleteForm').action = '/reports/delete/' + encodeURIComponent(filename);
document.getElementById('deleteForm').action = '/reports/delete/' + encodeURIComponent(filename); });
}); </script>
</script> {% endblock %}
{% endblock %}

View File

@@ -1,129 +1,151 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}startnet.cmd Editor - PXE Server Manager{% endblock %} {% block title %}startnet.cmd Editor - PXE Server Manager{% endblock %}
{% block extra_head %} {% block extra_head %}
<style> <style>
.cmd-editor { .cmd-editor {
font-family: 'Consolas', 'Courier New', monospace; font-family: 'Consolas', 'Courier New', monospace;
font-size: 0.9rem; font-size: 0.9rem;
min-height: 600px; min-height: 600px;
height: 70vh; height: 70vh;
resize: vertical; resize: vertical;
background-color: #1e1e1e; width: 100%;
color: #d4d4d4; display: block;
border: 1px solid #333; background-color: #1e1e1e;
padding: 1rem; color: #d4d4d4;
tab-size: 4; border: 1px solid var(--border);
white-space: pre; border-radius: 0.25rem;
line-height: 1.5; padding: 1rem;
} tab-size: 4;
.cmd-editor:focus { white-space: pre;
outline: none; line-height: 1.5;
border-color: #0d6efd; }
box-shadow: 0 0 0 0.15rem rgba(13,110,253,.25); .cmd-editor:focus {
} outline: none;
.wim-info dt { border-color: var(--primary);
font-weight: 600; box-shadow: 0 0 0 0.15rem rgba(65, 129, 255, 0.25);
color: #6c757d; }
} .wim-info dl { margin: 0; }
.wim-info dd { .wim-info dt {
margin-bottom: 0.3rem; font-weight: 600;
} color: var(--text-light);
</style> font-size: 11px;
{% endblock %} text-transform: uppercase;
letter-spacing: 0.04em;
{% block content %} }
<div class="d-flex justify-content-between align-items-center mb-4"> .wim-info dd {
<h2 class="mb-0">startnet.cmd Editor</h2> margin: 0 0 0.65rem 0;
</div> color: var(--text);
}
{% if not wim_exists %} .cmd-ref-item { margin-bottom: 0.85rem; }
<div class="alert alert-warning"> .cmd-ref-item code {
<strong>boot.wim not found</strong> at <code>{{ wim_path }}</code>. display: block;
Run the PXE server setup playbook and import WinPE boot files first. margin-bottom: 0.2rem;
</div> word-break: break-all;
{% else %} }
.cmd-ref-item small { color: var(--text-light); }
<div class="row"> </style>
<div class="col-lg-9"> {% endblock %}
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center"> {% block content %}
<span>Windows\System32\startnet.cmd</span> <div class="page-header">
<span class="badge bg-secondary">boot.wim</span> <h2>startnet.cmd Editor</h2>
</div> {% if wim_exists %}
<div class="card-body p-0"> <div class="header-actions">
<form action="{{ url_for('startnet_save') }}" method="post" id="startnetForm"> <button type="submit" form="startnetForm" class="pxe-btn pxe-btn-primary">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}"> <i class="bi bi-save"></i> Save to boot.wim
<textarea name="content" class="form-control cmd-editor" id="cmdEditor" </button>
spellcheck="false">{{ content }}</textarea> </div>
</form> {% endif %}
</div> </div>
<div class="card-footer d-flex justify-content-between align-items-center">
<small class="text-muted"> {% if not wim_exists %}
Editing the startnet.cmd inside <code>{{ wim_path }}</code> <div class="alert alert-warning">
</small> <strong>boot.wim not found</strong> at <code>{{ wim_path }}</code>.
<button type="submit" form="startnetForm" class="btn btn-primary"> Run the PXE server setup playbook and import WinPE boot files first.
Save to boot.wim </div>
</button> {% else %}
</div>
</div> <div class="row">
<div class="col-lg-9">
<div class="card mt-3"> <div class="pxe-card">
<div class="card-body"> <div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="card-title">Common startnet.cmd Commands</h6> <span class="mono"><i class="bi bi-terminal"></i> Windows\System32\startnet.cmd</span>
<div class="row"> <span class="badge badge-secondary">boot.wim</span>
<div class="col-md-6"> </div>
<code class="d-block mb-1">wpeinit</code> <form action="{{ url_for('startnet_save') }}" method="post" id="startnetForm">
<small class="text-muted d-block mb-2">Initialize WinPE networking</small> <input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<code class="d-block mb-1">net use Z: \\172.16.9.1\winpeapps</code> <textarea name="content" class="cmd-editor" id="cmdEditor"
<small class="text-muted d-block mb-2">Map Samba share for deployment</small> spellcheck="false">{{ content }}</textarea>
</div> </form>
<div class="col-md-6"> <div class="d-flex justify-content-between align-items-center mt-3">
<code class="d-block mb-1">wpeutil WaitForNetwork</code> <small class="text-muted">
<small class="text-muted d-block mb-2">Wait for network to be ready</small> Editing the startnet.cmd inside <code>{{ wim_path }}</code>
<code class="d-block mb-1">Z:\gea-standard\Deploy\Tools\deploy.cmd</code> </small>
<small class="text-muted d-block mb-2">Launch deployment script</small> <button type="submit" form="startnetForm" class="pxe-btn pxe-btn-primary">
</div> <i class="bi bi-save"></i> Save to boot.wim
</div> </button>
</div> </div>
</div> </div>
</div>
<div class="section-card">
<div class="col-lg-3"> <div class="section-title">Common startnet.cmd Commands</div>
<div class="card"> <div class="row">
<div class="card-header"> <div class="col-md-6">
WIM Info <div class="cmd-ref-item">
</div> <code>wpeinit</code>
<div class="card-body wim-info"> <small>Initialize WinPE networking</small>
<dl class="mb-0"> </div>
{% for key, val in wim_info.items() %} <div class="cmd-ref-item">
{% if key in ['Image Count', 'Compression', 'Total Bytes', 'Image Name', 'Image Description'] %} <code>net use Z: \\172.16.9.1\winpeapps</code>
<dt>{{ key }}</dt> <small>Map Samba share for deployment</small>
<dd>{{ val }}</dd> </div>
{% endif %} </div>
{% endfor %} <div class="col-md-6">
{% if not wim_info %} <div class="cmd-ref-item">
<p class="text-muted mb-0">Could not read WIM info.</p> <code>wpeutil WaitForNetwork</code>
{% endif %} <small>Wait for network to be ready</small>
</dl> </div>
</div> <div class="cmd-ref-item">
</div> <code>Z:\gea-standard\Deploy\Tools\deploy.cmd</code>
</div> <small>Launch deployment script</small>
</div> </div>
{% endif %} </div>
{% endblock %} </div>
</div>
{% block extra_scripts %} </div>
<script>
// Tab key inserts a tab in the editor instead of moving focus <div class="col-lg-3">
document.getElementById('cmdEditor')?.addEventListener('keydown', function(e) { <div class="section-card wim-info">
if (e.key === 'Tab') { <div class="section-title">WIM Info</div>
e.preventDefault(); <dl>
var start = this.selectionStart; {% for key, val in wim_info.items() %}
var end = this.selectionEnd; {% if key in ['Image Count', 'Compression', 'Total Bytes', 'Image Name', 'Image Description'] %}
this.value = this.value.substring(0, start) + '\t' + this.value.substring(end); <dt>{{ key }}</dt>
this.selectionStart = this.selectionEnd = start + 1; <dd>{{ val }}</dd>
} {% endif %}
}); {% endfor %}
</script> {% if not wim_info %}
{% endblock %} <p class="text-muted mb-0">Could not read WIM info.</p>
{% endif %}
</dl>
</div>
</div>
</div>
{% endif %}
{% endblock %}
{% block extra_scripts %}
<script>
// Tab key inserts a tab in the editor instead of moving focus
document.getElementById('cmdEditor')?.addEventListener('keydown', function(e) {
if (e.key === 'Tab') {
e.preventDefault();
var start = this.selectionStart;
var end = this.selectionEnd;
this.value = this.value.substring(0, start) + '\t' + this.value.substring(end);
this.selectionStart = this.selectionEnd = start + 1;
}
});
</script>
{% endblock %}

File diff suppressed because it is too large Load Diff