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 %}
{% 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-key="{{ card_key }}"
data-mac="{{ s.mac or '' }}"
@@ -72,8 +73,8 @@
style="line-height:0; flex-shrink:0;"
title="Intune Device ID: {{ s.intune_device_id }}"></div>
{% else %}
<div class="d-flex align-items-center justify-content-center bg-light text-muted small"
style="width:96px; height:96px; border-radius:0.25rem; flex-shrink:0; text-align:center; padding:0.25rem;">
<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; background:var(--bg); border:1px solid var(--border);">
{% if is_inferred %}log-only{% else %}no DeviceId{% endif %}
</div>
{% endif %}
@@ -86,27 +87,27 @@
{% elif s.mac %}{{ s.mac }}
{% else %}{{ s.ip or '(unknown bay)' }}{% endif %}
</strong>
{% if s.hostname_target and s.serial %}<code class="text-muted small">{{ s.hostname_target }}</code>{% endif %}
{% if s.ip %}<code class="text-muted small">{{ s.ip }}</code>{% endif %}
{% if s.pctype %}<span class="badge bg-info text-dark">{{ s.pctype }}</span>{% endif %}
{% if s.machinenumber %}<span class="badge bg-secondary">#{{ s.machinenumber }}</span>{% endif %}
{% if is_inferred %}<span class="badge bg-warning text-dark">log-inferred</span>{% endif %}
<span class="badge bg-{{ border }} ms-auto">{{ s.status or 'in_progress' }}</span>
{% 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="mono" style="color:var(--text-light);">{{ s.ip }}</code>{% endif %}
{% if s.pctype %}<span class="badge badge-info">{{ s.pctype }}</span>{% endif %}
{% if s.machinenumber %}<span class="badge badge-secondary">#{{ s.machinenumber }}</span>{% endif %}
{% if is_inferred %}<span class="badge badge-warning">log-inferred</span>{% endif %}
<span class="badge badge-{{ border }} ms-auto">{{ s.status or 'in_progress' }}</span>
</div>
<div class="d-flex justify-content-between align-items-baseline mt-1">
<div>
<div class="d-flex justify-content-between align-items-baseline mt-2">
<div class="d-flex align-items-center gap-2">
<strong>{{ friendly[0] }}</strong>
{% 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 %}
</div>
{% if not is_inferred %}<span class="text-muted small">{{ pct }}%</span>{% endif %}
{% if not is_inferred %}<span class="eyebrow">{{ pct }}%</span>{% endif %}
</div>
{% 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 %}"
role="progressbar" style="width: {{ pct }}%;"
role="progressbar" style="width: {{ pct }}%; background-color:var(--{{ border }});"
aria-valuenow="{{ pct }}" aria-valuemin="0" aria-valuemax="100"></div>
</div>
{% endif %}
@@ -114,46 +115,44 @@
</div>
</summary>
<div class="card-body pt-0 pb-3 border-top">
{% if friendly[1] %}<div class="small text-muted mt-2">{{ friendly[1] }}</div>{% endif %}
<div class="card-body pt-0 pb-3" style="border-top:1px solid var(--border);">
{% 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 %}
<div class="small mt-2" style="font-size:0.75rem;">
<span class="text-muted">Intune:</span> <code>{{ s.intune_device_id }}</code>
<button type="button" class="btn btn-sm btn-outline-secondary py-0 px-1 copy-btn"
style="font-size:0.65rem; line-height:1; transition: all 0.2s;"
<div class="d-flex flex-wrap align-items-center gap-2 mt-3">
<span class="eyebrow">Intune</span>
<code class="mono">{{ s.intune_device_id }}</code>
<button type="button" class="btn btn-sm btn-outline-secondary btn-row-action copy-btn"
data-copy-text="{{ s.intune_device_id }}">copy</button>
<a class="btn btn-sm btn-outline-primary py-0 px-1"
style="font-size:0.65rem; line-height:1;"
<a class="btn btn-sm btn-outline-primary btn-row-action"
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>
<a class="btn btn-sm btn-outline-warning py-0 px-1"
style="font-size:0.65rem; line-height:1;"
<a class="btn btn-sm btn-outline-warning btn-row-action"
target="_blank" rel="noopener"
href="https://arts.dw.geaerospace.net/requests/type">ARTS request</a>
</div>
{% endif %}
<div class="text-muted mt-1" style="font-size:0.7rem;">
<span class="me-3">started <code>{{ s.started_at or '-' }}</code></span>
<span class="me-3">last <code>{{ s.last_updated or '-' }}</code></span>
<span class="me-3">MAC <code>{{ s.mac or '-' }}</code></span>
{% if s.current_stage %}<span style="font-family:monospace;">{{ s.current_stage }}</span>{% endif %}
<div class="mt-2 d-flex flex-wrap gap-3" style="color:var(--text-light); font-size:0.75rem;">
<span>started <code class="mono">{{ s.started_at or '-' }}</code></span>
<span>last <code class="mono">{{ s.last_updated or '-' }}</code></span>
<span>MAC <code class="mono">{{ s.mac or '-' }}</code></span>
{% if s.current_stage %}<span class="mono">{{ s.current_stage }}</span>{% endif %}
</div>
{% 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 }}
</div>
{% endif %}
{% if not is_inferred %}
<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="d-flex align-items-center gap-2">
<div class="eyebrow mb-2">LAPS password QR (paste -> scan on bay - persists until cleared)</div>
<div class="d-flex flex-wrap align-items-center gap-2">
<input type="text"
class="form-control form-control-sm laps-input"
style="font-family: monospace; max-width: 22rem;"
class="form-control form-control-sm laps-input mono"
style="max-width: 22rem;"
placeholder="paste LAPS password from Intune portal here"
autocomplete="off"
value="{{ s.laps_password or '' }}">
@@ -166,8 +165,8 @@
{% if s.log_tail %}
<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>
<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 }}
<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="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>
</details>
{% endif %}

View File

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

View File

@@ -2,22 +2,26 @@
{% block title %}Clonezilla Backups - PXE Server Manager{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="mb-0">Clonezilla Backups</h2>
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#uploadModal">
Upload Backup
<div class="page-header">
<h2>Clonezilla Backups</h2>
<div class="header-actions">
<button class="pxe-btn pxe-btn-primary" data-bs-toggle="modal" data-bs-target="#uploadModal">
<i class="bi bi-upload"></i> Upload Backup
</button>
</div>
</div>
<div class="card">
<div class="card-header d-flex align-items-center">
<i class="bi bi-hdd-stack me-2"></i>
Machine Backups
<span class="badge bg-secondary ms-2">{{ backups|length }}</span>
<span class="badge badge-secondary ms-2">{{ backups|length }}</span>
</div>
<div class="card-body p-0">
{% if backups %}
<table class="table table-hover mb-0">
<thead class="table-light">
<div class="table-container">
<table class="data-table mb-0">
<thead>
<tr>
<th>Machine #</th>
<th>Filename</th>
@@ -30,40 +34,40 @@
{% for b in backups %}
<tr>
<td><strong>{{ b.machine }}</strong></td>
<td><code>{{ b.filename }}</code></td>
<td><span class="mono">{{ b.filename }}</span></td>
<td>{{ "%.1f"|format(b.size / 1073741824) }} GB</td>
<td>{{ b.modified | timestamp_fmt }}</td>
<td class="text-end text-nowrap">
<td class="actions text-end text-nowrap">
<a href="{{ url_for('clonezilla_download', filename=b.filename) }}"
class="btn btn-sm btn-outline-primary" title="Download">
Download
class="pxe-btn pxe-btn-secondary btn-row-action" title="Download">
<i class="bi bi-download"></i> Download
</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-filename="{{ b.filename }}" data-machine="{{ b.machine }}" title="Delete">
Delete
<i class="bi bi-trash"></i> Delete
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center text-muted py-5">
<p class="mt-2">No backups found. Upload a Clonezilla backup .zip to get started.</p>
<i class="bi bi-hdd-stack" style="font-size: 2rem; opacity: 0.4;"></i>
<p class="mt-2 mb-0">No backups found. Upload a Clonezilla backup .zip to get started.</p>
</div>
{% endif %}
</div>
</div>
<div class="card mt-3">
<div class="card-body">
<h6 class="card-title">Backup Naming Convention</h6>
<p class="card-text mb-0">
Name backup files with the machine number (e.g., <code>6501.zip</code>).
The Samba share <code>\\pxe-server\clonezilla</code> is also available on the network for direct Clonezilla save/restore operations.
<div class="section-card mt-3">
<div class="section-title">Backup Naming Convention</div>
<p class="mb-0">
Name backup files with the machine number (e.g., <span class="mono">6501.zip</span>).
The Samba share <span class="mono">\\pxe-server\clonezilla</span> is also available on the network for direct Clonezilla save/restore operations.
</p>
</div>
</div>
<!-- Upload Modal -->
@@ -77,18 +81,20 @@
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<div class="form-field mb-0">
<label for="backupFile" class="form-label">Backup File (.zip)</label>
<input type="file" class="form-control" id="backupFile" name="backup_file"
accept=".zip" required>
<div class="form-text">
Name the file with the machine number (e.g., <code>6501.zip</code>).
Name the file with the machine number (e.g., <span class="mono">6501.zip</span>).
</div>
</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>
<button type="button" class="pxe-btn pxe-btn-ghost" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="pxe-btn pxe-btn-primary">
<i class="bi bi-upload"></i> Upload
</button>
</div>
</form>
</div>
@@ -110,8 +116,10 @@
<p class="text-muted mb-0">This action cannot be undone.</p>
</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>
<button type="button" class="pxe-btn pxe-btn-ghost" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="pxe-btn pxe-btn-danger">
<i class="bi bi-trash"></i> Delete
</button>
</div>
</form>
</div>

View File

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

View File

@@ -2,21 +2,55 @@
{% block title %}Dashboard - PXE Server Manager{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="mb-0">Dashboard</h2>
<button class="btn btn-outline-secondary btn-sm" onclick="location.reload()">
Refresh
<div class="page-header">
<h2>Dashboard</h2>
<div class="header-actions">
<a href="{{ url_for('images_import') }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-box-arrow-in-down"></i> Image Import
</a>
<button type="button" class="btn btn-success btn-sm" data-bs-toggle="modal" data-bs-target="#newImageModal">
<i class="bi bi-plus-lg"></i> New image type
</button>
<button class="btn btn-outline-secondary btn-sm" onclick="location.reload()">
<i class="bi bi-arrow-clockwise"></i> Refresh
</button>
</div>
</div>
<!-- At-a-glance stats -->
<div class="dashboard-grid mb-4">
{% set services_up = services | selectattr('active') | list | length %}
{% set services_total = services | list | length %}
<div class="stat-card {{ 'success' if services_up == services_total else 'danger' }}">
<div class="label"><i class="bi bi-hdd-network"></i> Services running</div>
<div class="value">{{ services_up }} / {{ services_total }}</div>
</div>
{% set images_total = images | list | length %}
<div class="stat-card info">
<div class="label"><i class="bi bi-hdd-stack"></i> Image types</div>
<div class="value">{{ images_total }}</div>
</div>
{% set images_ready = images | selectattr('has_content') | list | length %}
<div class="stat-card success">
<div class="label"><i class="bi bi-check2-circle"></i> Images with content</div>
<div class="value">{{ images_ready }}</div>
</div>
{% set unattend_missing = images | rejectattr('has_unattend') | list | length %}
<div class="stat-card {{ 'warning' if unattend_missing else 'success' }}">
<div class="label"><i class="bi bi-file-earmark-code"></i> Missing unattend.xml</div>
<div class="value">{{ unattend_missing }}</div>
</div>
</div>
<!-- Services -->
<div class="card mb-4">
<div class="card-header d-flex align-items-center">
PXE Services
<div class="card">
<div class="card-header">
<span><i class="bi bi-hdd-network"></i> PXE Services</span>
</div>
<div class="card-body p-0">
<table class="table table-hover mb-0">
<thead class="table-light">
<div class="table-container">
<table class="table mb-0">
<thead>
<tr>
<th>Service</th>
<th>Status</th>
@@ -33,25 +67,27 @@
<span class="status-dot {{ 'active' if svc.active else 'inactive' }}"></span>
{{ "Running" if svc.active else "Stopped" }}
</td>
<td><code>{{ svc.state }}</code></td>
<td><span class="mono">{{ svc.state }}</span></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<!-- Images -->
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<span>Deployment Images</span>
<div class="card-header">
<span><i class="bi bi-hdd-stack"></i> Deployment Images</span>
<button type="button" class="btn btn-sm btn-success" data-bs-toggle="modal" data-bs-target="#newImageModal">
+ New image type
<i class="bi bi-plus-lg"></i> New image type
</button>
</div>
<div class="card-body p-0">
<table class="table table-hover mb-0">
<thead class="table-light">
<div class="table-container">
<table class="table mb-0">
<thead>
<tr>
<th>Image</th>
<th>Deploy Content</th>
@@ -65,7 +101,7 @@
<tr>
<td>
<strong>{{ img.friendly_name }}</strong><br>
<small class="text-muted">{{ img.image_type }}</small>
<small class="text-muted mono">{{ img.image_type }}</small>
</td>
<td>
{% if img.has_content %}
@@ -78,30 +114,30 @@
{% if img.has_unattend %}
<span class="badge bg-success">Exists</span>
{% else %}
<span class="badge bg-warning text-dark">Missing</span>
<span class="badge bg-warning">Missing</span>
{% endif %}
</td>
<td><code class="small">{{ img.deploy_path }}</code></td>
<td class="text-end">
<td><span class="mono">{{ img.deploy_path }}</span></td>
<td class="actions text-end">
<a href="{{ url_for('image_config', image_type=img.image_type) }}"
class="btn btn-sm btn-outline-secondary me-1">
Config
class="btn btn-sm btn-outline-secondary btn-row-action">
<i class="bi bi-gear"></i> Config
</a>
<a href="{{ url_for('unattend_editor', image_type=img.image_type) }}"
class="btn btn-sm btn-outline-primary me-1">
Unattend
class="btn btn-sm btn-outline-primary btn-row-action">
<i class="bi bi-file-earmark-code"></i> Unattend
</a>
<button type="button" class="btn btn-sm btn-outline-secondary me-1"
<button type="button" class="btn btn-sm btn-outline-secondary btn-row-action"
data-bs-toggle="modal" data-bs-target="#cloneImageModal"
data-src-key="{{ img.image_type }}"
data-src-friendly="{{ img.friendly_name }}">
Clone
<i class="bi bi-files"></i> Clone
</button>
<button type="button" class="btn btn-sm btn-outline-danger"
<button type="button" class="btn btn-sm btn-outline-danger btn-row-action"
data-bs-toggle="modal" data-bs-target="#deleteImageModal"
data-src-key="{{ img.image_type }}"
data-src-friendly="{{ img.friendly_name }}">
Delete
<i class="bi bi-trash"></i> Delete
</button>
</td>
</tr>
@@ -109,6 +145,7 @@
</tbody>
</table>
</div>
</div>
</div>
<!-- New image type modal -->
@@ -122,14 +159,14 @@
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<div class="form-field">
<label class="form-label">Key</label>
<input type="text" class="form-control font-monospace" name="key"
<input type="text" class="form-control mono" name="key"
pattern="[a-z][a-z0-9-]{1,63}" required
placeholder="gea-shopfloor-newtype">
<div class="form-text">Lowercase + hyphens. Used as directory name + URL path. 2-64 chars, must start with a letter.</div>
</div>
<div class="mb-3">
<div class="form-field">
<label class="form-label">Friendly name</label>
<input type="text" class="form-control" name="friendly_name" required
placeholder="GE Aerospace Shop Floor (newtype)">
@@ -158,13 +195,13 @@
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>Source: <strong id="cloneSrcFriendly"></strong> <code id="cloneSrcKey"></code></p>
<div class="mb-3">
<p>Source: <strong id="cloneSrcFriendly"></strong> <span class="mono" id="cloneSrcKey"></span></p>
<div class="form-field">
<label class="form-label">New key</label>
<input type="text" class="form-control font-monospace" name="dst_key"
<input type="text" class="form-control mono" name="dst_key"
pattern="[a-z][a-z0-9-]{1,63}" required>
</div>
<div class="mb-3">
<div class="form-field">
<label class="form-label">Friendly name (optional)</label>
<input type="text" class="form-control" name="friendly_name"
placeholder="leave blank for &lsquo;<src> (copy)&rsquo;">
@@ -196,7 +233,7 @@
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>Remove <strong id="deleteSrcFriendly"></strong> <code id="deleteSrcKey"></code> from the registry.</p>
<p>Remove <strong id="deleteSrcFriendly"></strong> <span class="mono" id="deleteSrcKey"></span> from the registry.</p>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" name="delete_content" value="1" id="deleteContentCheck">
<label class="form-check-label" for="deleteContentCheck">

View File

@@ -2,22 +2,25 @@
{% block title %}Enrollment Packages - PXE Server Manager{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="mb-0">Enrollment Packages</h2>
<div class="page-header">
<h2><i class="bi bi-pc-display"></i> Enrollment Packages</h2>
<div class="header-actions">
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#uploadModal">
Upload Package
<i class="bi bi-upload"></i> Upload Package
</button>
</div>
</div>
<div class="card">
<div class="card-header d-flex align-items-center">
GCCH Provisioning Packages
<span class="badge bg-secondary ms-2">{{ packages|length }}</span>
<div class="card-header">
<span><i class="bi bi-box-seam"></i> GCCH Provisioning Packages</span>
<span class="badge badge-secondary">{{ packages|length }}</span>
</div>
<div class="card-body p-0">
{% if packages %}
<div class="table-container">
<table class="table table-hover mb-0">
<thead class="table-light">
<thead>
<tr>
<th>Filename</th>
<th>Size</th>
@@ -28,46 +31,46 @@
<tbody>
{% for p in packages %}
<tr>
<td><code>{{ p.filename }}</code></td>
<td><span class="mono"><i class="bi bi-file-earmark-zip"></i> {{ p.filename }}</span></td>
<td>{{ "%.1f"|format(p.size / 1048576) }} MB</td>
<td>{{ p.modified | timestamp_fmt }}</td>
<td class="text-end text-nowrap">
<td class="actions text-end text-nowrap">
<a href="{{ url_for('enrollment_download', filename=p.filename) }}"
class="btn btn-sm btn-outline-primary" title="Download">
Download
class="btn btn-sm btn-outline-primary btn-row-action" title="Download">
<i class="bi bi-download"></i> Download
</a>
<button type="button" class="btn btn-sm btn-outline-danger"
<button type="button" class="btn btn-sm btn-outline-danger btn-row-action"
data-bs-toggle="modal" data-bs-target="#deleteModal"
data-filename="{{ p.filename }}" title="Delete">
Delete
<i class="bi bi-trash"></i> Delete
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center text-muted py-5">
<p class="mt-2">No enrollment packages found. Upload a <code>.ppkg</code> file to get started.</p>
<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>
{% endif %}
</div>
</div>
<div class="card mt-3">
<div class="card-body">
<h6 class="card-title">About Enrollment Packages</h6>
<p class="card-text">
GCCH enrollment <code>.ppkg</code> provisioning packages are copied to
<code>C:\Enrollment\</code> on the target machine after imaging.
<div class="pxe-card">
<h3 class="pxe-card-title"><i class="bi bi-info-circle"></i> About Enrollment Packages</h3>
<p>
GCCH enrollment <span class="mono">.ppkg</span> provisioning packages are copied to
<span class="mono">C:\Enrollment\</span> on the target machine after imaging.
At OOBE, connect to a network with internet, press <strong>Windows key 5 times</strong>,
then browse to <code>C:\Enrollment\</code> and select the package. No USB stick needed.
then browse to <span class="mono">C:\Enrollment\</span> and select the package. No USB stick needed.
</p>
<p class="card-text mb-0">
<strong>Naming convention:</strong> Use <code>with-office.ppkg</code> and
<code>without-office.ppkg</code> to match the WinPE enrollment menu options.
<p class="mb-0">
<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.
</p>
</div>
</div>
<!-- Upload Modal -->
@@ -81,18 +84,18 @@
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<div class="form-field">
<label for="ppkgFile" class="form-label">Provisioning Package (.ppkg)</label>
<input type="file" class="form-control" id="ppkgFile" name="ppkg_file"
accept=".ppkg" required>
<div class="form-text">
Use <code>with-office.ppkg</code> or <code>without-office.ppkg</code> to match the WinPE boot menu.
Use <span class="mono">with-office.ppkg</span> or <span class="mono">without-office.ppkg</span> to match the WinPE boot menu.
</div>
</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>
<button type="submit" class="btn btn-primary"><i class="bi bi-upload"></i> Upload</button>
</div>
</form>
</div>
@@ -110,12 +113,12 @@
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<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" class="mono"></strong>?</p>
<p class="text-muted mb-0">This action cannot be undone.</p>
</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>
<button type="submit" class="btn btn-danger"><i class="bi bi-trash"></i> Delete</button>
</div>
</form>
</div>

View File

@@ -3,42 +3,58 @@
{% block extra_head %}
<style>
.section-card { margin-bottom: 1.5rem; }
.section-card .card-header { padding: 0.6rem 1rem; font-size: 0.95rem; }
.badge-disk { font-size: 0.75rem; }
.orphan-section { background-color: #fff8e1; }
/* image_config-specific tweaks layered on top of pxe-theme.css tokens */
.config-table td, .config-table th { vertical-align: middle; }
.config-table .form-control-sm { min-width: 120px; }
.text-truncate-cell { max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.badge-disk { font-size: 11px; }
.text-truncate-cell {
max-width: 250px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Unregistered-drivers strip: warning-tinted footer that reads in both themes */
.orphan-section {
background: rgba(255, 149, 0, 0.08);
border-top: 1px solid var(--border);
}
[data-theme="dark"] .orphan-section { background: rgba(255, 149, 0, 0.10); }
.orphan-toggle { color: var(--text); text-decoration: none; }
.orphan-toggle:hover { color: var(--primary); }
.section-count { background: var(--secondary); }
.card-header .header-actions { flex: 0 0 auto; }
</style>
{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<div class="page-header">
<div>
<h2 class="mb-1">{{ friendly_name }}</h2>
<small class="text-muted">
Image Configuration
&mdash; OS Selection: <strong>{{ config.os_selection or 'Not set' }}</strong>
</small>
<h1>{{ friendly_name }}</h1>
<div class="eyebrow" style="margin-top:0.35rem;">
Image Configuration &mdash; OS Selection:
<strong>{{ config.os_selection or 'Not set' }}</strong>
</div>
</div>
<div class="header-actions">
<a href="{{ url_for('unattend_editor', image_type=image_type) }}" class="btn btn-outline-secondary btn-sm">
Edit Unattend
<i class="bi bi-pencil-square"></i> Edit Unattend
</a>
</div>
</div>
{# ==================== SECTION 1: Hardware Models ==================== #}
<div class="card section-card">
<div class="card-header d-flex justify-content-between align-items-center">
<span>Hardware Models
<span class="badge bg-secondary ms-1">{{ config.hardware_models|length }}</span>
<div class="card">
<div class="card-header">
<span class="d-flex align-items-center gap-2">
<i class="bi bi-cpu"></i> Hardware Models
<span class="badge section-count">{{ config.hardware_models|length }}</span>
</span>
<div>
<div class="header-actions">
<button type="button" class="btn btn-sm btn-outline-primary" id="addHwModel">
Add
<i class="bi bi-plus-lg"></i> Add
</button>
<button type="button" class="btn btn-sm btn-success ms-1" id="saveHwModels">
Save
<button type="button" class="btn btn-sm btn-success" id="saveHwModels">
<i class="bi bi-check-lg"></i> Save
</button>
</div>
</div>
@@ -48,8 +64,9 @@
<input type="hidden" name="section" value="hardware_models">
<input type="hidden" name="payload" id="hwModelsData" value="[]">
</form>
<table class="table table-sm table-hover mb-0 config-table" id="hwModelsTable">
<thead class="table-light">
<div class="table-container">
<table class="table table-hover mb-0 config-table" id="hwModelsTable">
<thead>
<tr>
<th style="width:40px">#</th>
<th>Model</th>
@@ -66,20 +83,21 @@
<td><input type="text" class="form-control form-control-sm" data-field="Id" value="{{ hm.Id }}"></td>
<td>
{% if hm._on_disk %}
<span class="badge bg-success badge-disk">Yes</span>
<span class="badge badge-success badge-disk">Yes</span>
{% else %}
<span class="badge bg-danger badge-disk">No</span>
<span class="badge badge-danger badge-disk">No</span>
{% endif %}
</td>
<td>
<td class="actions">
<button type="button" class="btn btn-outline-danger btn-row-action remove-row">
Remove
<i class="bi bi-trash"></i> Remove
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if not config.hardware_models %}
<div class="text-center text-muted py-3 empty-message" id="hwModelsEmpty">
No hardware models configured.
@@ -89,18 +107,19 @@
</div>
{# ==================== SECTION 2: Driver Packs ==================== #}
<div class="card section-card">
<div class="card-header d-flex justify-content-between align-items-center">
<span>Driver Packs
<span class="badge bg-secondary ms-1">{{ config.drivers|length }}</span>
<div class="card">
<div class="card-header">
<span class="d-flex align-items-center gap-2">
<i class="bi bi-hdd-network"></i> Driver Packs
<span class="badge section-count">{{ config.drivers|length }}</span>
</span>
<div>
<div class="header-actions">
<button type="button" class="btn btn-sm btn-outline-primary"
data-bs-toggle="modal" data-bs-target="#driverUploadModal">
Upload .zip
<i class="bi bi-upload"></i> Upload .zip
</button>
<button type="button" class="btn btn-sm btn-success ms-1" id="saveDrivers">
Save
<button type="button" class="btn btn-sm btn-success" id="saveDrivers">
<i class="bi bi-check-lg"></i> Save
</button>
</div>
</div>
@@ -110,9 +129,9 @@
<input type="hidden" name="section" value="drivers">
<input type="hidden" name="payload" id="driversData" value="[]">
</form>
<div class="table-responsive">
<table class="table table-sm table-hover mb-0 config-table" id="driversTable">
<thead class="table-light">
<div class="table-container">
<table class="table table-hover mb-0 config-table" id="driversTable">
<thead>
<tr>
<th style="width:40px">#</th>
<th>Family</th>
@@ -129,20 +148,20 @@
<td class="order-num">{{ loop.index }}</td>
<td class="text-truncate-cell" title="{{ drv.family }}">{{ drv.family }}</td>
<td class="text-truncate-cell" title="{{ drv.models }}">{{ drv.models }}</td>
<td class="text-truncate-cell" title="{{ drv.FileName or drv.get('fileName','') }}">
<small>{{ drv.FileName or drv.get('fileName','') }}</small>
<td class="text-truncate-cell mono" title="{{ drv.FileName or drv.get('fileName','') }}">
{{ drv.FileName or drv.get('fileName','') }}
</td>
<td><small>{{ drv.osId }}</small></td>
<td class="mono">{{ drv.osId }}</td>
<td>
{% if drv._on_disk %}
<span class="badge bg-success badge-disk">Yes</span>
<span class="badge badge-success badge-disk">Yes</span>
{% else %}
<span class="badge bg-danger badge-disk">No</span>
<span class="badge badge-danger badge-disk">No</span>
{% endif %}
</td>
<td>
<td class="actions">
<button type="button" class="btn btn-outline-danger btn-row-action remove-row">
Remove
<i class="bi bi-trash"></i> Remove
</button>
</td>
</tr>
@@ -162,19 +181,21 @@
{# inputs -> adds an entry to HardwareDriver.json). Bulk remove deletes #}
{# the selected .zip files from Out-of-box Drivers/. #}
{% if config.orphan_drivers %}
<div class="card-footer orphan-section p-0">
<div class="orphan-section">
<div class="px-3 py-2 d-flex justify-content-between align-items-center">
<a class="text-decoration-none" data-bs-toggle="collapse" href="#orphanDrivers" role="button">
<a class="orphan-toggle d-flex align-items-center gap-2" data-bs-toggle="collapse" href="#orphanDrivers" role="button">
<i class="bi bi-exclamation-triangle"></i>
<strong>Unregistered Drivers ({{ config.orphan_drivers|length }})</strong>
<small class="text-muted ms-1">.zip files on disk, no JSON entry</small>
<span class="text-muted" style="font-size:0.82rem;">.zip files on disk, no JSON entry</span>
</a>
</div>
<div class="collapse show" id="orphanDrivers">
<form method="POST" action="{{ url_for('images_drivers_orphans_delete', image_type=image_type) }}"
onsubmit="return confirm('Delete the selected unregistered driver .zip(s) from disk? Cannot be undone.');">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<table class="table table-sm mb-0 align-middle">
<thead class="table-light">
<div class="table-container">
<table class="table mb-0 align-middle">
<thead>
<tr>
<th style="width:30px"><input type="checkbox" id="orphanSelectAll"></th>
<th>File Name</th>
@@ -186,30 +207,39 @@
{% for orph in config.orphan_drivers %}
<tr>
<td><input type="checkbox" class="orphan-select" name="filename" value="{{ orph.fileName }}"></td>
<td><small>{{ orph.fileName }}</small></td>
<td><small class="text-muted">{{ orph.relPath }}</small></td>
<td class="mono">{{ orph.fileName }}</td>
<td class="mono text-muted">{{ orph.relPath }}</td>
<td>
<form method="POST" action="{{ url_for('images_drivers_adopt', image_type=image_type) }}"
class="d-flex gap-1">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="filename" value="{{ orph.fileName }}">
{# adopt inputs target standalone adoptForm below - form-in-form is dropped by parser and hijacked the delete form #}
<div class="d-flex gap-1">
<input type="text" class="form-control form-control-sm" name="family"
form="adoptForm{{ loop.index }}"
placeholder="family id (e.g. Optiplex_7060)" required style="width:11rem;">
<input type="text" class="form-control form-control-sm" name="destination_dir"
form="adoptForm{{ loop.index }}"
placeholder="destinationDir" required style="width:11rem;">
<button type="submit" class="btn btn-sm btn-success">Adopt</button>
</form>
<button type="submit" class="btn btn-sm btn-success" form="adoptForm{{ loop.index }}">Adopt</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="px-3 py-2 text-end">
<button type="submit" class="btn btn-sm btn-outline-danger">
Delete selected
<i class="bi bi-trash"></i> Delete selected
</button>
</div>
</form>
{# one adopt form per orphan row, kept OUTSIDE the delete form #}
{% for orph in config.orphan_drivers %}
<form method="POST" id="adoptForm{{ loop.index }}"
action="{{ url_for('images_drivers_adopt', image_type=image_type) }}">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="filename" value="{{ orph.fileName }}">
</form>
{% endfor %}
</div>
</div>
{% endif %}
@@ -222,24 +252,24 @@
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Upload driver .zip</h5>
<h3 class="modal-title">Upload driver .zip</h3>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<div class="form-field">
<label class="form-label">Driver .zip file</label>
<input type="file" class="form-control" name="driver_file" accept=".zip" required>
<div class="form-text">Lands in <code>{{ image_type }}/Deploy/Out-of-box Drivers/</code>.</div>
</div>
<div class="mb-3">
<div class="form-field">
<label class="form-label">Family ID (optional)</label>
<input type="text" class="form-control font-monospace" name="family"
<input type="text" class="form-control mono" name="family"
placeholder="Optiplex_7060">
<div class="form-text">Matches a HardwareModelSelection.Id. Leave blank to land as orphan + adopt later.</div>
</div>
<div class="mb-3">
<div class="form-field">
<label class="form-label">Destination directory (optional)</label>
<input type="text" class="form-control font-monospace" name="destination_dir"
<input type="text" class="form-control mono" name="destination_dir"
placeholder="*destinationdir*\Drivers\Optiplex">
<div class="form-text">Where the .zip extracts at deploy time. Required if Family ID is set.</div>
</div>
@@ -258,14 +288,15 @@
</div>
{# ==================== SECTION 3: Operating Systems ==================== #}
<div class="card section-card">
<div class="card-header d-flex justify-content-between align-items-center">
<span>Operating Systems
<span class="badge bg-secondary ms-1">{{ config.operating_systems|length }}</span>
<div class="card">
<div class="card-header">
<span class="d-flex align-items-center gap-2">
<i class="bi bi-windows"></i> Operating Systems
<span class="badge section-count">{{ config.operating_systems|length }}</span>
</span>
<div>
<div class="header-actions">
<button type="button" class="btn btn-sm btn-success" id="saveOs">
Save
<i class="bi bi-check-lg"></i> Save
</button>
</div>
</div>
@@ -275,8 +306,9 @@
<input type="hidden" name="section" value="operating_systems">
<input type="hidden" name="payload" id="osData" value="[]">
</form>
<table class="table table-sm table-hover mb-0 config-table" id="osTable">
<thead class="table-light">
<div class="table-container">
<table class="table table-hover mb-0 config-table" id="osTable">
<thead>
<tr>
<th style="width:40px">#</th>
<th>Product Name</th>
@@ -296,30 +328,31 @@
<td>{{ osv.productName }}</td>
<td>{{ osv.versionNumber }}</td>
<td>{{ osv.buildNumber }}</td>
<td>{{ osv.id }}</td>
<td class="mono">{{ osv.id }}</td>
<td>
{% if osv.isActive %}
<span class="badge bg-success badge-disk">Active</span>
<span class="badge badge-success badge-disk">Active</span>
{% else %}
<span class="badge bg-secondary badge-disk">Inactive</span>
<span class="badge badge-secondary badge-disk">Inactive</span>
{% endif %}
</td>
<td>
{% if entry._on_disk %}
<span class="badge bg-success badge-disk">Yes</span>
<span class="badge badge-success badge-disk">Yes</span>
{% else %}
<span class="badge bg-danger badge-disk">No</span>
<span class="badge badge-danger badge-disk">No</span>
{% endif %}
</td>
<td>
<td class="actions">
<button type="button" class="btn btn-outline-danger btn-row-action remove-row">
Remove
<i class="bi bi-trash"></i> Remove
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if not config.operating_systems %}
<div class="text-center text-muted py-3 empty-message" id="osEmpty">
No operating systems configured.
@@ -329,18 +362,19 @@
</div>
{# ==================== SECTION 4: Packages ==================== #}
<div class="card section-card">
<div class="card-header d-flex justify-content-between align-items-center">
<span>Packages
<span class="badge bg-secondary ms-1">{{ config.packages|length }}</span>
<div class="card">
<div class="card-header">
<span class="d-flex align-items-center gap-2">
<i class="bi bi-box-seam"></i> Packages
<span class="badge section-count">{{ config.packages|length }}</span>
</span>
<div>
<div class="header-actions">
<button type="button" class="btn btn-sm btn-outline-primary"
data-bs-toggle="modal" data-bs-target="#packageUploadModal">
Upload file
<i class="bi bi-upload"></i> Upload file
</button>
<button type="button" class="btn btn-sm btn-success ms-1" id="savePackages">
Save
<button type="button" class="btn btn-sm btn-success" id="savePackages">
<i class="bi bi-check-lg"></i> Save
</button>
</div>
</div>
@@ -350,9 +384,9 @@
<input type="hidden" name="section" value="packages">
<input type="hidden" name="payload" id="packagesData" value="[]">
</form>
<div class="table-responsive">
<table class="table table-sm table-hover mb-0 config-table" id="packagesTable">
<thead class="table-light">
<div class="table-container">
<table class="table table-hover mb-0 config-table" id="packagesTable">
<thead>
<tr>
<th style="width:40px">#</th>
<th>Name</th>
@@ -368,29 +402,29 @@
{% for pkg in config.packages %}
<tr data-json='{{ pkg | tojson }}'>
<td class="order-num">{{ loop.index }}</td>
<td class="text-truncate-cell" title="{{ pkg.name }}"><small>{{ pkg.name }}</small></td>
<td class="text-truncate-cell" title="{{ pkg.comment }}"><small>{{ pkg.comment }}</small></td>
<td class="text-truncate-cell" title="{{ pkg.fileName or pkg.get('FileName','') }}">
<small>{{ pkg.fileName or pkg.get('FileName','') }}</small>
<td class="text-truncate-cell" title="{{ pkg.name }}">{{ pkg.name }}</td>
<td class="text-truncate-cell" title="{{ pkg.comment }}">{{ pkg.comment }}</td>
<td class="text-truncate-cell mono" title="{{ pkg.fileName or pkg.get('FileName','') }}">
{{ pkg.fileName or pkg.get('FileName','') }}
</td>
<td><small>{{ pkg.osId }}</small></td>
<td class="mono">{{ pkg.osId }}</td>
<td>
{% if pkg.enabled %}
<span class="badge bg-success badge-disk">Yes</span>
<span class="badge badge-success badge-disk">Yes</span>
{% else %}
<span class="badge bg-secondary badge-disk">No</span>
<span class="badge badge-secondary badge-disk">No</span>
{% endif %}
</td>
<td>
{% if pkg._on_disk %}
<span class="badge bg-success badge-disk">Yes</span>
<span class="badge badge-success badge-disk">Yes</span>
{% else %}
<span class="badge bg-danger badge-disk">No</span>
<span class="badge badge-danger badge-disk">No</span>
{% endif %}
</td>
<td>
<td class="actions">
<button type="button" class="btn btn-outline-danger btn-row-action remove-row">
Remove
<i class="bi bi-trash"></i> Remove
</button>
</td>
</tr>
@@ -413,18 +447,18 @@
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Upload package</h5>
<h3 class="modal-title">Upload package</h3>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<div class="form-field">
<label class="form-label">Package file</label>
<input type="file" class="form-control" name="package_file" required>
<div class="form-text">Lands in <code>{{ image_type }}/Deploy/Packages/</code>.</div>
</div>
<div class="mb-3">
<div class="form-field">
<label class="form-label">Destination directory (optional)</label>
<input type="text" class="form-control font-monospace" name="destination_dir"
<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>

View File

@@ -18,38 +18,41 @@ function cancelImagingReload() {
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-2">
<div class="page-header">
<div>
<h2 class="mb-0">Imaging Progress</h2>
<small class="text-muted">Live via SSE (15s polling fallback). Client pushes -> <code>/imaging/status</code>; log-inferred bays in yellow.</small>
<h1>Imaging Progress</h1>
<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 class="d-flex align-items-center gap-2">
<span id="imaging-live-dot" class="status-dot" title="live stream" style="background-color:#6c757d;"></span>
<span class="badge bg-secondary fs-6"><span id="visible-count">{{ sessions|length }}</span>/<span id="total-count">{{ sessions|length }}</span></span>
<div class="header-actions">
<span class="status-indicator">
<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 %}
<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.');"
style="display:inline;">
<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>
{% endif %}
</div>
</div>
<div class="mb-3">
<div class="form-field mb-3">
<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)"
autocomplete="off">
</div>
{% if not sessions %}
<div id="imaging-empty" class="card">
<div class="card-body text-center text-muted py-5">
<div id="imaging-empty" class="pxe-card text-center text-light" style="padding: 3rem 1.25rem;">
<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="small mb-0">A PC being imaged will post status here, or appear
automatically once it touches DHCP / TFTP / boot.wim.</p>
</div>
</div>
{% endif %}
@@ -58,10 +61,10 @@ function cancelImagingReload() {
</div>
<div class="card mt-3">
<div class="card-body small text-muted">
<strong>How to push status from an imaging client:</strong>
<pre class="mb-0 mt-2">POST http://172.16.9.1:9009/imaging/status
<div class="section-card mt-3">
<div class="section-title">How to push status from an imaging client</div>
<div class="pxe-card-body">
<pre class="mono mb-0" style="white-space: pre-wrap;">POST http://172.16.9.1:9009/imaging/status
Content-Type: application/json
{
@@ -140,19 +143,19 @@ Content-Type: application/json
function startSSE() {
if (!window.EventSource) {
setDot('#ffc107', 'EventSource unsupported - polling only');
setDot('var(--warning)', 'EventSource unsupported - polling only');
startPolling();
return;
}
try {
sse = new EventSource(STREAM_URL);
} catch (e) {
setDot('#dc3545', 'SSE failed - polling');
setDot('var(--danger)', 'SSE failed - polling');
startPolling();
return;
}
sse.onopen = function() {
setDot('#198754', 'live stream connected');
setDot('var(--success)', 'live stream connected');
stopPolling();
};
sse.onmessage = function(ev) {
@@ -163,7 +166,7 @@ Content-Type: application/json
refreshTiles(false);
};
sse.onerror = function() {
setDot('#dc3545', 'live stream lost - polling fallback');
setDot('var(--danger)', 'live stream lost - polling fallback');
try { sse.close(); } catch (e) {}
sse = null;
startPolling();

View File

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

View File

@@ -2,22 +2,21 @@
{% block title %}Image Import - PXE Server Manager{% endblock %}
{% 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>
<div class="row">
<div class="row g-4">
<div class="col-lg-8">
<!-- Network Upload Import -->
<div class="card mb-3">
<div class="card-header">
Import from Network Upload
</div>
<div class="card-body">
<div class="section-card">
<div class="section-title"><i class="bi bi-hdd-network"></i> Import from Network Upload</div>
{% if upload_sources %}
<form method="POST" id="uploadImportForm">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label for="uploadSource" class="form-label fw-semibold">Source</label>
<div class="form-field">
<label for="uploadSource" class="form-label">Source</label>
<select class="form-select" name="source" id="uploadSource" required>
<option value="">-- Select upload source --</option>
{% for src in upload_sources %}
@@ -25,12 +24,12 @@
{% endfor %}
</select>
<div class="form-text">
Files uploaded via SMB to <code>\\172.16.9.1\image-upload</code>
Files uploaded via SMB to <code class="mono">\\172.16.9.1\image-upload</code>
</div>
</div>
<div class="mb-3">
<label for="uploadTarget" class="form-label fw-semibold">Target Image Type</label>
<div class="form-field">
<label for="uploadTarget" class="form-label">Target Image Type</label>
<select class="form-select" name="target" id="uploadTarget" required>
<option value="">-- Select target image --</option>
{% for it in image_types %}
@@ -43,51 +42,44 @@
</div>
</div>
<div class="alert alert-info d-flex align-items-start" role="alert">
<div>
<div class="alert alert-info" role="alert">
<strong>Shared Drivers:</strong> Out-of-box Drivers are automatically pooled
into a shared directory and symlinked for each image type to save disk space.
</div>
</div>
<div class="alert alert-warning d-flex align-items-start" role="alert">
<div>
<div class="alert alert-warning" role="alert">
<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
images.
</div>
</div>
<button type="submit" class="btn btn-primary" id="uploadImportBtn">
Start Import
<button type="submit" class="pxe-btn pxe-btn-primary" id="uploadImportBtn">
<i class="bi bi-play-fill"></i> Start Import
</button>
</form>
{% else %}
<div class="text-center py-4">
<h5 class="mt-3 text-muted">No Upload Content Found</h5>
<p class="text-muted mb-0">
Map <code>\\172.16.9.1\image-upload</code> on your Windows PC and copy
<i class="bi bi-cloud-slash" style="font-size:2rem;color:var(--text-light);"></i>
<h5 class="mt-3" style="color:var(--text-light);">No Upload Content Found</h5>
<p class="mb-0" style="color:var(--text-light);">
Map <code class="mono">\\172.16.9.1\image-upload</code> on your Windows PC and copy
the Deploy directory contents there.
</p>
<button class="btn btn-outline-secondary btn-sm mt-3" onclick="location.reload()">
Refresh
<button class="pxe-btn pxe-btn-ghost pxe-btn-sm mt-3" onclick="location.reload()">
<i class="bi bi-arrow-clockwise"></i> Refresh
</button>
</div>
{% endif %}
</div>
</div>
<!-- USB Import -->
<div class="card">
<div class="card-header">
Import from USB Drive
</div>
<div class="card-body">
<div class="section-card">
<div class="section-title"><i class="bi bi-usb-drive"></i> Import from USB Drive</div>
{% if usb_mounts %}
<form method="POST" id="importForm">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label for="source" class="form-label fw-semibold">Source (USB Mount Point)</label>
<div class="form-field">
<label for="source" class="form-label">Source (USB Mount Point)</label>
<select class="form-select" name="source" id="source" required>
<option value="">-- Select a mounted USB drive --</option>
{% for mount in usb_mounts %}
@@ -99,8 +91,8 @@
</div>
</div>
<div class="mb-3">
<label for="target" class="form-label fw-semibold">Target Image Type</label>
<div class="form-field">
<label for="target" class="form-label">Target Image Type</label>
<select class="form-select" name="target" id="target" required>
<option value="">-- Select target image --</option>
{% for it in image_types %}
@@ -112,42 +104,38 @@
</div>
</div>
<div class="alert alert-warning d-flex align-items-start" role="alert">
<div>
<div class="alert alert-warning" role="alert">
<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
images.
</div>
</div>
<button type="submit" class="btn btn-primary" id="importBtn">
Start Import
<button type="submit" class="pxe-btn pxe-btn-primary" id="importBtn">
<i class="bi bi-play-fill"></i> Start Import
</button>
</form>
{% else %}
<div class="text-center py-4">
<h5 class="mt-3 text-muted">No USB Drives Detected</h5>
<p class="text-muted mb-0">
No mounted USB drives were found under <code>/mnt/</code> or <code>/media/</code>.<br>
<i class="bi bi-usb-drive" style="font-size:2rem;color:var(--text-light);"></i>
<h5 class="mt-3" style="color:var(--text-light);">No USB Drives Detected</h5>
<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>
Mount a USB drive and refresh this page.
</p>
<button class="btn btn-outline-secondary btn-sm mt-3" onclick="location.reload()">
Refresh
<button class="pxe-btn pxe-btn-ghost pxe-btn-sm mt-3" onclick="location.reload()">
<i class="bi bi-arrow-clockwise"></i> Refresh
</button>
</div>
{% endif %}
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card">
<div class="card-header">
Current Image Status
</div>
<div class="card-body p-0">
<table class="table table-sm mb-0">
<thead class="table-light">
<div class="section-card">
<div class="section-title"><i class="bi bi-list-check"></i> Current Image Status</div>
<div class="table-container">
<table class="data-table mb-0">
<thead>
<tr>
<th>Image</th>
<th>Content</th>
@@ -156,12 +144,12 @@
<tbody>
{% for img in images %}
<tr>
<td class="small">{{ img.friendly_name }}</td>
<td>{{ img.friendly_name }}</td>
<td>
{% if img.has_content %}
<span class="badge bg-success">Present</span>
<span class="badge badge-success">Present</span>
{% else %}
<span class="badge bg-secondary">Empty</span>
<span class="badge badge-secondary">Empty</span>
{% endif %}
</td>
</tr>

View File

@@ -24,59 +24,56 @@
{% 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 class="text-muted small mb-1">
<a href="{{ url_for('blancco_reports') }}" class="text-decoration-none">&larr; All reports</a>
<h2>Drive Erasure Certificate</h2>
<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 class="text-end">
<div class="header-actions">
<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>
{# Hero summary: state badge + standard + drive count + erasure datetime #}
{% set first = (data.erasures or [{}])[0] %}
{% 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">
<div class="card-body">
<div class="row g-3 align-items-center">
<div class="col-md-3">
<div class="text-muted small">Overall result</div>
<div class="fs-3">
<span class="badge bg-{{ 'success' if overall_state == 'Successful' else 'danger' if overall_state else 'secondary' }}">
{{ overall_state }}
</span>
{% set overall_tone = 'success' if overall_state == 'Successful' else 'danger' if overall_state else 'secondary' %}
{% set overall_stat = 'success' if overall_state == 'Successful' else 'danger' if overall_state else '' %}
<div class="dashboard-grid mb-4">
<div class="stat-card {{ overall_stat }}">
<div class="label">Overall result</div>
<div class="value" style="font-size:1.5rem;">
<span class="badge badge-lg bg-{{ overall_tone }}">{{ 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 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="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 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 class="row g-3 mb-3">
<div class="col-md-6">
<div class="card h-100">
<div class="card-header">Customer</div>
<div class="card-body">
<div class="content-grid mb-3">
<div class="section-card">
<div class="section-title">Customer</div>
<dl class="row mb-0 small">
<dt class="col-sm-5">Business name</dt><dd class="col-sm-7">{{ company.business_name or '-' }}</dd>
<dt class="col-sm-5">Location</dt> <dd class="col-sm-7">{{ company.business_location or '-' }}</dd>
@@ -88,71 +85,65 @@
{% endfor %}
</dl>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card h-100">
<div class="card-header">System</div>
<div class="card-body">
<div class="section-card">
<div class="section-title">System</div>
<dl class="row mb-0 small">
<dt class="col-sm-5">Manufacturer</dt> <dd class="col-sm-7">{{ sysinfo.manufacturer or '-' }}</dd>
<dt class="col-sm-5">Model</dt> <dd class="col-sm-7">{{ sysinfo.model 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">Serial</dt> <dd class="col-sm-7"><code class="mono">{{ sysinfo.serial or '-' }}</code></dd>
<dt class="col-sm-5">Chassis</dt> <dd class="col-sm-7">{{ sysinfo.chassis_type 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>
<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">BIOS vendor</dt> <dd class="col-sm-7">{{ bios.vendor or '-' }} {{ bios.version or '' }}</dd>
</dl>
</div>
</div>
</div>
</div>
{# Per-drive cards: target details + step-by-step timeline #}
{% for er in data.erasures %}
{% set t = er.target or {} %}
{% 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 %}
{% if steps is not iterable or steps is string or steps is mapping %}
{% set steps = [steps] if steps else [] %}
{% endif %}
<div class="card border-{{ border }} mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<div class="card">
<div class="card-header">
<div>
<strong>Drive #{{ er.erasure_id or loop.index }}:</strong>
{{ 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>
<span class="badge bg-{{ border }}">{{ state }}</span>
<span class="badge bg-{{ tone }}">{{ state }}</span>
</div>
<div class="card-body">
<div class="row g-3 mb-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>
<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>
<div class="col-md-2">
<div class="text-muted small">Health</div>
<div class="eyebrow">Health</div>
<div>{{ t.health or '-' }}</div>
</div>
<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>
<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>
</div>
{% if steps %}
<div class="table-responsive">
<table class="table table-sm table-hover align-middle mb-0">
<thead class="table-light">
<div class="table-container">
<table class="table mb-0">
<thead>
<tr>
<th style="width:3rem;">#</th>
<th>Step</th>
@@ -167,13 +158,13 @@
<tr>
<td>{{ s.number or loop.index }}</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>
<span class="badge bg-{{ 'success' if s.state == 'completed' else 'danger' if s.state else 'secondary' }}">
{{ s.state or '-' }}
</span>
</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>
</tr>
{% endfor %}
@@ -185,27 +176,27 @@
</div>
{% endfor %}
<div class="card mb-3">
<div class="card">
<div class="card-header">Document signing &amp; provenance</div>
<div class="card-body">
<dl class="row mb-0 small">
<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>
<dd class="col-sm-9">{{ meta.product_name }} {{ meta.product_version }} (rev {{ meta.product_revision }})</dd>
<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 %}
<dt class="col-sm-3">License consumption ID{{ 's' if data.license_consumption_ids|length > 1 }}</dt>
<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>
{% endif %}
<dt class="col-sm-3">Integrity hash</dt>
<dd class="col-sm-9">
<details>
<summary class="text-muted">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>
<summary style="color:var(--text-light);cursor:pointer;">Show base64 signature ({{ meta.integrity|length if meta.integrity else 0 }} chars)</summary>
<code class="mono text-break d-block mt-2">{{ meta.integrity }}</code>
</details>
</dd>
</dl>

View File

@@ -2,19 +2,19 @@
{% block title %}Blancco Reports - PXE Server Manager{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="mb-0">Blancco Erasure Reports</h2>
<span class="badge bg-secondary fs-6">{{ reports|length }} report{{ 's' if reports|length != 1 }}</span>
<div class="page-header">
<h2><i class="bi bi-shield-check"></i> Blancco Erasure Reports</h2>
<div class="header-actions">
<span class="badge badge-secondary badge-lg">{{ reports|length }} report{{ 's' if reports|length != 1 }}</span>
</div>
</div>
<div class="card">
<div class="card-header d-flex align-items-center">
Drive Erasure Certificates
</div>
<div class="card-body p-0">
<div class="section-card">
<div class="section-title">Drive Erasure Certificates</div>
{% if reports %}
<table class="table table-hover mb-0">
<thead class="table-light">
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>Serial</th>
<th>Model</th>
@@ -26,56 +26,55 @@
<tbody>
{% for r in reports %}
<tr title="{{ r.filename }}">
<td>{% if r.serial %}<code>{{ r.serial }}</code>{% else %}<span class="text-muted small">-</span>{% endif %}</td>
<td class="small">{{ r.model or '-' }}</td>
<td>{% if r.serial %}<span class="mono">{{ r.serial }}</span>{% else %}<span class="text-muted">-</span>{% endif %}</td>
<td>{{ r.model or '-' }}</td>
<td>{{ r.modified | timestamp_fmt }}</td>
<td>
{% if r.state == 'Successful' %}<span class="badge bg-success">Successful</span>
{% elif r.state == 'Failed' %}<span class="badge bg-danger">Failed</span>
{% elif r.state %}<span class="badge bg-secondary">{{ r.state }}</span>
{% else %}<span class="text-muted small">-</span>{% endif %}
{% if r.state == 'Successful' %}<span class="badge badge-success">Successful</span>
{% elif r.state == 'Failed' %}<span class="badge badge-danger">Failed</span>
{% elif r.state %}<span class="badge badge-secondary">{{ r.state }}</span>
{% else %}<span class="text-muted">-</span>{% endif %}
</td>
<td class="text-end text-nowrap">
<td class="actions text-end text-nowrap">
{% if r.filename.lower().endswith('.xml') %}
<a href="{{ url_for('blancco_view_report', filename=r.filename) }}"
class="btn btn-sm btn-outline-success" title="View formatted report">
View
class="pxe-btn pxe-btn-success btn-row-action" title="View formatted report">
<i class="bi bi-eye"></i> View
</a>
{% endif %}
<a href="{{ url_for('blancco_download_report', filename=r.filename) }}"
class="btn btn-sm btn-outline-primary" title="Download">
Download
class="pxe-btn pxe-btn-secondary btn-row-action" title="Download">
<i class="bi bi-download"></i> Download
</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-filename="{{ r.filename }}" title="Delete">
Delete
<i class="bi bi-trash"></i> Delete
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center text-muted py-5">
<p class="mt-2">No erasure reports yet.</p>
<p class="mt-2"><i class="bi bi-inbox" style="font-size:1.75rem;"></i></p>
<p class="mb-1">No erasure reports yet.</p>
<p class="small">Reports will appear here after Blancco Drive Eraser completes a wipe.</p>
</div>
{% endif %}
</div>
</div>
<div class="card mt-3">
<div class="card-body">
<h6 class="card-title">Report Storage</h6>
<p class="card-text mb-1">
<div class="pxe-card mt-3">
<h3 class="pxe-card-title"><i class="bi bi-hdd-network"></i> Report Storage</h3>
<p class="mb-1">
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 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.
</p>
</div>
</div>
<!-- Delete Confirmation Modal -->
@@ -93,8 +92,8 @@
<p class="text-muted mb-0">Erasure reports may be needed for compliance audits. This action cannot be undone.</p>
</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>
<button type="button" class="pxe-btn pxe-btn-ghost" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="pxe-btn pxe-btn-danger">Delete</button>
</div>
</form>
</div>

View File

@@ -9,9 +9,12 @@
min-height: 600px;
height: 70vh;
resize: vertical;
width: 100%;
display: block;
background-color: #1e1e1e;
color: #d4d4d4;
border: 1px solid #333;
border: 1px solid var(--border);
border-radius: 0.25rem;
padding: 1rem;
tab-size: 4;
white-space: pre;
@@ -19,22 +22,41 @@
}
.cmd-editor:focus {
outline: none;
border-color: #0d6efd;
box-shadow: 0 0 0 0.15rem rgba(13,110,253,.25);
border-color: var(--primary);
box-shadow: 0 0 0 0.15rem rgba(65, 129, 255, 0.25);
}
.wim-info dl { margin: 0; }
.wim-info dt {
font-weight: 600;
color: #6c757d;
color: var(--text-light);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.wim-info dd {
margin-bottom: 0.3rem;
margin: 0 0 0.65rem 0;
color: var(--text);
}
.cmd-ref-item { margin-bottom: 0.85rem; }
.cmd-ref-item code {
display: block;
margin-bottom: 0.2rem;
word-break: break-all;
}
.cmd-ref-item small { color: var(--text-light); }
</style>
{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="mb-0">startnet.cmd Editor</h2>
<div class="page-header">
<h2>startnet.cmd Editor</h2>
{% if wim_exists %}
<div class="header-actions">
<button type="submit" form="startnetForm" class="pxe-btn pxe-btn-primary">
<i class="bi bi-save"></i> Save to boot.wim
</button>
</div>
{% endif %}
</div>
{% if not wim_exists %}
@@ -46,43 +68,47 @@
<div class="row">
<div class="col-lg-9">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<span>Windows\System32\startnet.cmd</span>
<span class="badge bg-secondary">boot.wim</span>
<div class="pxe-card">
<div class="d-flex justify-content-between align-items-center mb-3">
<span class="mono"><i class="bi bi-terminal"></i> Windows\System32\startnet.cmd</span>
<span class="badge badge-secondary">boot.wim</span>
</div>
<div class="card-body p-0">
<form action="{{ url_for('startnet_save') }}" method="post" id="startnetForm">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<textarea name="content" class="form-control cmd-editor" id="cmdEditor"
<textarea name="content" class="cmd-editor" id="cmdEditor"
spellcheck="false">{{ content }}</textarea>
</form>
</div>
<div class="card-footer d-flex justify-content-between align-items-center">
<div class="d-flex justify-content-between align-items-center mt-3">
<small class="text-muted">
Editing the startnet.cmd inside <code>{{ wim_path }}</code>
</small>
<button type="submit" form="startnetForm" class="btn btn-primary">
Save to boot.wim
<button type="submit" form="startnetForm" class="pxe-btn pxe-btn-primary">
<i class="bi bi-save"></i> Save to boot.wim
</button>
</div>
</div>
<div class="card mt-3">
<div class="card-body">
<h6 class="card-title">Common startnet.cmd Commands</h6>
<div class="section-card">
<div class="section-title">Common startnet.cmd Commands</div>
<div class="row">
<div class="col-md-6">
<code class="d-block mb-1">wpeinit</code>
<small class="text-muted d-block mb-2">Initialize WinPE networking</small>
<code class="d-block mb-1">net use Z: \\172.16.9.1\winpeapps</code>
<small class="text-muted d-block mb-2">Map Samba share for deployment</small>
<div class="cmd-ref-item">
<code>wpeinit</code>
<small>Initialize WinPE networking</small>
</div>
<div class="cmd-ref-item">
<code>net use Z: \\172.16.9.1\winpeapps</code>
<small>Map Samba share for deployment</small>
</div>
</div>
<div class="col-md-6">
<code class="d-block mb-1">wpeutil WaitForNetwork</code>
<small class="text-muted d-block mb-2">Wait for network to be ready</small>
<code class="d-block mb-1">Z:\gea-standard\Deploy\Tools\deploy.cmd</code>
<small class="text-muted d-block mb-2">Launch deployment script</small>
<div class="cmd-ref-item">
<code>wpeutil WaitForNetwork</code>
<small>Wait for network to be ready</small>
</div>
<div class="cmd-ref-item">
<code>Z:\gea-standard\Deploy\Tools\deploy.cmd</code>
<small>Launch deployment script</small>
</div>
</div>
</div>
@@ -90,12 +116,9 @@
</div>
<div class="col-lg-3">
<div class="card">
<div class="card-header">
WIM Info
</div>
<div class="card-body wim-info">
<dl class="mb-0">
<div class="section-card wim-info">
<div class="section-title">WIM Info</div>
<dl>
{% for key, val in wim_info.items() %}
{% if key in ['Image Count', 'Compression', 'Total Bytes', 'Image Name', 'Image Description'] %}
<dt>{{ key }}</dt>
@@ -108,7 +131,6 @@
</dl>
</div>
</div>
</div>
</div>
{% endif %}
{% endblock %}

View File

@@ -3,18 +3,32 @@
{% block extra_head %}
<style>
.editor-tabs .nav-link {
font-weight: 500;
}
.command-table tbody tr {
transition: background-color 0.15s;
/* Group dividers that organize the sections in configuration-pass order */
.group-heading {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 1.5px;
font-weight: 600;
color: var(--text-light);
margin: 1.9rem 0 0.9rem;
padding-bottom: 0.4rem;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 0.5rem;
}
.group-heading:first-of-type { margin-top: 0.5rem; }
.group-heading .bi { color: var(--primary); }
/* Command-list feedback while dragging to reorder */
.command-table tbody tr { transition: background-color 0.15s; }
.command-table tbody tr.dragging {
opacity: 0.5;
background-color: #e9ecef;
background-color: rgba(65, 129, 255, 0.12);
}
.raw-xml-editor {
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-family: 'SF Mono', 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 0.85rem;
min-height: 600px;
height: 70vh;
@@ -22,49 +36,55 @@
white-space: pre;
resize: vertical;
}
.section-card {
margin-bottom: 1.5rem;
}
.section-card .card-header {
padding: 0.6rem 1rem;
font-size: 0.95rem;
}
.order-num {
width: 40px;
text-align: center;
font-weight: 600;
color: #6c757d;
color: var(--text-light);
}
.drag-handle { text-align: center; user-select: none; }
/* Count pill sitting next to a card title */
.count-pill {
font-size: 11px;
font-weight: 600;
background: var(--bg);
color: var(--text-light);
border: 1px solid var(--border);
border-radius: 999px;
padding: 1px 9px;
}
.card-header .title-group { display: flex; align-items: center; gap: 0.5rem; font-weight: 600; }
.card-header .title-group .bi { color: var(--primary); }
</style>
{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<div class="page-header">
<div>
<h2 class="mb-1">{{ friendly_name }}</h2>
<small class="text-muted">
<code>{{ image_type }}/Deploy/FlatUnattendW10.xml</code>
</small>
<h1>{{ friendly_name }}</h1>
<small class="mono" style="color: var(--text-light);">{{ image_type }}/Deploy/FlatUnattendW10.xml</small>
</div>
<div>
<div class="header-actions">
<button type="button" class="btn btn-success" id="saveFormBtn">
Save
<i class="bi bi-floppy"></i> Save
</button>
</div>
</div>
<!-- Tabs -->
<!-- Form / Raw XML toggle (ids read by app.js) -->
<ul class="nav nav-tabs editor-tabs mb-3" role="tablist">
<li class="nav-item">
<a class="nav-link active" id="form-tab" data-bs-toggle="tab"
href="#formView" role="tab">
Form Editor
<i class="bi bi-ui-checks-grid"></i> Form Editor
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="raw-tab" data-bs-toggle="tab"
href="#rawView" role="tab">
Raw XML
<i class="bi bi-code-slash"></i> Raw XML
</a>
</li>
</ul>
@@ -76,17 +96,100 @@
<!-- ==================== FORM VIEW ==================== -->
<div class="tab-pane fade show active" id="formView" role="tabpanel">
<!-- 1. Driver Paths -->
<div class="card section-card">
<div class="card-header d-flex justify-content-between align-items-center">
<span>Driver Paths</span>
<div class="alert alert-info">
<i class="bi bi-info-circle"></i>
Edit this image's answer file below. Fields are grouped by Windows setup stage.
The <strong>Form Editor</strong> is the source of truth; switch to <strong>Raw XML</strong>
only for changes the form does not cover.
</div>
<!-- ============= GROUP 1: SYSTEM & REGIONAL ============= -->
<div class="group-heading"><i class="bi bi-pc-display-horizontal"></i> System &amp; Regional</div>
<!-- Machine Settings -->
<div class="card">
<div class="card-header">
<span class="title-group"><i class="bi bi-hdd-network"></i> Machine Settings</span>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Computer Name</label>
<input type="text" class="form-control" name="computer_name"
value="{{ data.computer_name }}" placeholder="* (auto-generate)">
<div class="form-text">Use <code>*</code> to let Windows auto-generate a name.</div>
</div>
<div class="col-md-6">
<label class="form-label">Time Zone</label>
<input type="text" class="form-control" name="time_zone"
value="{{ data.time_zone }}" placeholder="Eastern Standard Time">
<div class="form-text">Applied during the specialize pass.</div>
</div>
<div class="col-md-6">
<label class="form-label">Registered Organization</label>
<input type="text" class="form-control" name="registered_organization"
value="{{ data.registered_organization }}" placeholder="GE Aerospace">
</div>
<div class="col-md-6">
<label class="form-label">Registered Owner</label>
<input type="text" class="form-control" name="registered_owner"
value="{{ data.registered_owner }}" placeholder="GE Aerospace">
</div>
</div>
</div>
</div>
<!-- International Settings (collapsible) -->
<div class="card">
<div class="card-header" data-bs-toggle="collapse" data-bs-target="#intlCollapse"
role="button" style="cursor:pointer">
<span class="title-group"><i class="bi bi-globe2"></i> International Settings</span>
<small style="color: var(--text-light);"><i class="bi bi-chevron-expand"></i> click to expand</small>
</div>
<div class="collapse {% if data.intl.input_locale or data.intl.system_locale or data.intl.ui_language or data.intl.user_locale %}show{% endif %}"
id="intlCollapse">
<div class="card-body">
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">Input Locale</label>
<input type="text" class="form-control" name="intl_input_locale"
value="{{ data.intl.input_locale }}" placeholder="e.g. en-US">
</div>
<div class="col-md-3">
<label class="form-label">System Locale</label>
<input type="text" class="form-control" name="intl_system_locale"
value="{{ data.intl.system_locale }}" placeholder="e.g. en-US">
</div>
<div class="col-md-3">
<label class="form-label">UI Language</label>
<input type="text" class="form-control" name="intl_ui_language"
value="{{ data.intl.ui_language }}" placeholder="e.g. en-US">
</div>
<div class="col-md-3">
<label class="form-label">User Locale</label>
<input type="text" class="form-control" name="intl_user_locale"
value="{{ data.intl.user_locale }}" placeholder="e.g. en-US">
</div>
</div>
</div>
</div>
</div>
<!-- Driver Paths -->
<div class="card">
<div class="card-header">
<span class="title-group">
<i class="bi bi-usb-drive"></i> Driver Paths
<span class="count-pill">{{ data.driver_paths|length }}</span>
</span>
<button type="button" class="btn btn-sm btn-outline-primary" id="addDriverPath">
Add
<i class="bi bi-plus-lg"></i> Add
</button>
</div>
<div class="card-body p-0">
<table class="table table-sm mb-0" id="driverPathsTable">
<thead class="table-light">
<div class="table-container">
<table class="table table-sm mb-0 align-middle" id="driverPathsTable">
<thead>
<tr>
<th style="width:40px">#</th>
<th>Path</th>
@@ -98,178 +201,45 @@
<tr>
<td class="order-num">{{ loop.index }}</td>
<td>
<input type="text" class="form-control form-control-sm"
<input type="text" class="form-control form-control-sm mono"
name="driver_path[]" value="{{ dp }}">
</td>
<td>
<button type="button" class="btn btn-outline-danger btn-row-action remove-row">
Remove
<button type="button" class="btn btn-outline-danger btn-row-action remove-row" title="Remove">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if not data.driver_paths %}
<div class="text-center text-muted py-3 empty-message" id="driverPathsEmpty">
<div class="text-center py-3 empty-message" id="driverPathsEmpty" style="color: var(--text-light);">
No driver paths configured. Click <strong>Add</strong> to add one.
</div>
{% endif %}
</div>
</div>
<!-- 2. Machine Settings -->
<div class="card section-card">
<!-- ============= GROUP 2: ACCOUNTS & SIGN-IN ============= -->
<div class="group-heading"><i class="bi bi-people"></i> Accounts &amp; Sign-in</div>
<!-- User Accounts -->
<div class="card">
<div class="card-header">
Machine Settings
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label fw-semibold">Computer Name</label>
<input type="text" class="form-control" name="computer_name"
value="{{ data.computer_name }}" placeholder="* (auto-generate)">
<div class="form-text">Use * to let Windows auto-generate a name.</div>
</div>
<div class="col-md-6">
<label class="form-label fw-semibold">Time Zone</label>
<input type="text" class="form-control" name="time_zone"
value="{{ data.time_zone }}" placeholder="Eastern Standard Time">
</div>
<div class="col-md-6">
<label class="form-label fw-semibold">Registered Organization</label>
<input type="text" class="form-control" name="registered_organization"
value="{{ data.registered_organization }}" placeholder="GE Aerospace">
</div>
<div class="col-md-6">
<label class="form-label fw-semibold">Registered Owner</label>
<input type="text" class="form-control" name="registered_owner"
value="{{ data.registered_owner }}" placeholder="GE Aerospace">
</div>
</div>
</div>
</div>
<!-- 3. Specialize Commands (RunSynchronous) -->
<div class="card section-card">
<div class="card-header d-flex justify-content-between align-items-center">
<span>Specialize Commands (RunSynchronous)</span>
<button type="button" class="btn btn-sm btn-outline-primary" id="addSpecCmd">
Add
</button>
</div>
<div class="card-body p-0">
<table class="table table-sm mb-0 command-table" id="specCmdTable">
<thead class="table-light">
<tr>
<th style="width:30px"></th>
<th style="width:50px">Order</th>
<th>Path / Command</th>
<th>Description</th>
<th style="width:90px"></th>
</tr>
</thead>
<tbody>
{% for cmd in data.specialize_commands %}
<tr draggable="true">
<td class="drag-handle">::</td>
<td class="order-num">{{ loop.index }}</td>
<td>
<input type="text" class="form-control form-control-sm"
name="spec_cmd_path[]" value="{{ cmd.path }}">
</td>
<td>
<input type="text" class="form-control form-control-sm"
name="spec_cmd_desc[]" value="{{ cmd.description }}">
</td>
<td class="text-nowrap">
<button type="button" class="btn btn-outline-secondary btn-row-action move-up" title="Move up">
Up
</button>
<button type="button" class="btn btn-outline-secondary btn-row-action move-down" title="Move down">
Down
</button>
<button type="button" class="btn btn-outline-danger btn-row-action remove-row">
Remove
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if not data.specialize_commands %}
<div class="text-center text-muted py-3 empty-message" id="specCmdEmpty">
No specialize commands configured. Click <strong>Add</strong> to add one.
</div>
{% endif %}
</div>
</div>
<!-- 4. OOBE Settings -->
<div class="card section-card">
<div class="card-header">
OOBE Settings
</div>
<div class="card-body">
<div class="row g-3">
{% set bool_oobe_fields = [
("HideEULAPage", "Hide EULA Page"),
("HideOEMRegistrationScreen", "Hide OEM Registration Screen"),
("HideOnlineAccountScreens", "Hide Online Account Screens"),
("HideWirelessSetupInOOBE", "Hide Wireless Setup in OOBE"),
("HideLocalAccountScreen", "Hide Local Account Screen"),
("SkipUserOOBE", "Skip User OOBE"),
("SkipMachineOOBE", "Skip Machine OOBE"),
] %}
{% for key, label in bool_oobe_fields %}
<div class="col-md-4">
<div class="form-check form-switch">
<input class="form-check-input oobe-toggle" type="checkbox"
id="oobe_{{ key }}"
data-field="oobe_{{ key }}"
{% if data.oobe[key]|lower == 'true' %}checked{% endif %}>
<label class="form-check-label" for="oobe_{{ key }}">{{ label }}</label>
<input type="hidden" name="oobe_{{ key }}" id="oobe_{{ key }}_val"
value="{{ data.oobe[key] }}">
</div>
</div>
{% endfor %}
<div class="col-md-4">
<label class="form-label fw-semibold">Network Location</label>
<select class="form-select form-select-sm" name="oobe_NetworkLocation">
{% for opt in ["Home", "Work", "Other"] %}
<option value="{{ opt }}" {% if data.oobe.NetworkLocation == opt %}selected{% endif %}>
{{ opt }}
</option>
{% endfor %}
</select>
</div>
<div class="col-md-4">
<label class="form-label fw-semibold">ProtectYourPC</label>
<select class="form-select form-select-sm" name="oobe_ProtectYourPC">
{% for opt in ["1", "2", "3"] %}
<option value="{{ opt }}" {% if data.oobe.ProtectYourPC == opt %}selected{% endif %}>
{{ opt }}{% if opt == "1" %} (Recommended){% elif opt == "3" %} (Skip){% endif %}
</option>
{% endfor %}
</select>
<div class="form-text">1 = Recommended, 2 = Install only updates, 3 = Skip</div>
</div>
</div>
</div>
</div>
<!-- 5. User Accounts -->
<div class="card section-card">
<div class="card-header d-flex justify-content-between align-items-center">
<span>User Accounts (Local)</span>
<span class="title-group">
<i class="bi bi-person-badge"></i> User Accounts (Local)
<span class="count-pill">{{ data.user_accounts|length }}</span>
</span>
<button type="button" class="btn btn-sm btn-outline-primary" id="addUserAccount">
Add
<i class="bi bi-plus-lg"></i> Add
</button>
</div>
<div class="card-body p-0">
<table class="table table-sm mb-0" id="userAccountsTable">
<thead class="table-light">
<div class="table-container">
<table class="table table-sm mb-0 align-middle" id="userAccountsTable">
<thead>
<tr>
<th style="width:40px">#</th>
<th>Name</th>
@@ -301,51 +271,57 @@
</td>
<td>
<input type="hidden" name="account_plaintext_{{ loop.index0 }}" value="{{ acct.plain_text }}">
<button type="button" class="btn btn-outline-danger btn-row-action remove-account-row">
Remove
<button type="button" class="btn btn-outline-danger btn-row-action remove-account-row" title="Remove">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if not data.user_accounts %}
<div class="text-center text-muted py-3 empty-message" id="userAccountsEmpty">
<div class="text-center py-3 empty-message" id="userAccountsEmpty" style="color: var(--text-light);">
No local accounts configured. Click <strong>Add</strong> to add one.
</div>
{% endif %}
</div>
<div class="card-footer">
<small style="color: var(--text-light);">
<i class="bi bi-shield-lock"></i> Passwords are stored in the answer file. Handle this image config as sensitive.
</small>
</div>
</div>
<!-- 6. AutoLogon -->
<div class="card section-card">
<!-- AutoLogon -->
<div class="card">
<div class="card-header">
AutoLogon
<span class="title-group"><i class="bi bi-box-arrow-in-right"></i> AutoLogon</span>
</div>
<div class="card-body">
<div class="row g-3">
<div class="row g-3 align-items-end">
<div class="col-md-3">
<div class="form-check form-switch mt-4">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="autologonEnabledToggle"
{% if data.autologon.enabled|lower == 'true' %}checked{% endif %}>
<label class="form-check-label fw-semibold" for="autologonEnabledToggle">Enabled</label>
<label class="form-check-label" for="autologonEnabledToggle">Enabled</label>
<input type="hidden" name="autologon_enabled" id="autologon_enabled_val"
value="{{ data.autologon.enabled }}">
</div>
</div>
<div class="col-md-3">
<label class="form-label fw-semibold">Username</label>
<label class="form-label">Username</label>
<input type="text" class="form-control" name="autologon_username"
value="{{ data.autologon.username }}" placeholder="e.g. SupportUser">
</div>
<div class="col-md-3">
<label class="form-label fw-semibold">Password</label>
<label class="form-label">Password</label>
<input type="text" class="form-control" name="autologon_password"
value="{{ data.autologon.password }}">
<input type="hidden" name="autologon_plaintext" value="{{ data.autologon.plain_text }}">
</div>
<div class="col-md-3">
<label class="form-label fw-semibold">Logon Count</label>
<label class="form-label">Logon Count</label>
<input type="text" class="form-control" name="autologon_logoncount"
value="{{ data.autologon.logon_count }}" placeholder="e.g. 2 or 999">
<div class="form-text">Number of auto-logon attempts.</div>
@@ -354,51 +330,72 @@
</div>
</div>
<!-- 7. International Settings -->
<div class="card section-card">
<div class="card-header" data-bs-toggle="collapse" data-bs-target="#intlCollapse"
role="button" style="cursor:pointer">
International Settings
<small class="text-muted ms-2">(click to expand)</small>
<!-- ============= GROUP 3: OUT-OF-BOX EXPERIENCE ============= -->
<div class="group-heading"><i class="bi bi-window-desktop"></i> Out-of-Box Experience (OOBE)</div>
<!-- OOBE Settings -->
<div class="card">
<div class="card-header">
<span class="title-group"><i class="bi bi-toggles"></i> OOBE Settings</span>
</div>
<div class="collapse {% if data.intl.input_locale or data.intl.system_locale or data.intl.ui_language or data.intl.user_locale %}show{% endif %}"
id="intlCollapse">
<div class="card-body">
<div class="row g-3">
<div class="col-md-3">
<label class="form-label fw-semibold">Input Locale</label>
<input type="text" class="form-control" name="intl_input_locale"
value="{{ data.intl.input_locale }}" placeholder="e.g. en-US">
{% set bool_oobe_fields = [
("HideEULAPage", "Hide EULA Page"),
("HideOEMRegistrationScreen", "Hide OEM Registration Screen"),
("HideOnlineAccountScreens", "Hide Online Account Screens"),
("HideWirelessSetupInOOBE", "Hide Wireless Setup in OOBE"),
("HideLocalAccountScreen", "Hide Local Account Screen"),
("SkipUserOOBE", "Skip User OOBE"),
("SkipMachineOOBE", "Skip Machine OOBE"),
] %}
{% for key, label in bool_oobe_fields %}
<div class="col-md-4">
<div class="form-check form-switch">
<input class="form-check-input oobe-toggle" type="checkbox"
id="oobe_{{ key }}"
data-field="oobe_{{ key }}"
{% if data.oobe[key]|lower == 'true' %}checked{% endif %}>
<label class="form-check-label" for="oobe_{{ key }}">{{ label }}</label>
<input type="hidden" name="oobe_{{ key }}" id="oobe_{{ key }}_val"
value="{{ data.oobe[key] }}">
</div>
<div class="col-md-3">
<label class="form-label fw-semibold">System Locale</label>
<input type="text" class="form-control" name="intl_system_locale"
value="{{ data.intl.system_locale }}" placeholder="e.g. en-US">
</div>
<div class="col-md-3">
<label class="form-label fw-semibold">UI Language</label>
<input type="text" class="form-control" name="intl_ui_language"
value="{{ data.intl.ui_language }}" placeholder="e.g. en-US">
</div>
<div class="col-md-3">
<label class="form-label fw-semibold">User Locale</label>
<input type="text" class="form-control" name="intl_user_locale"
value="{{ data.intl.user_locale }}" placeholder="e.g. en-US">
{% endfor %}
<div class="col-md-4">
<label class="form-label">Network Location</label>
<select class="form-select form-select-sm" name="oobe_NetworkLocation">
{% for opt in ["Home", "Work", "Other"] %}
<option value="{{ opt }}" {% if data.oobe.NetworkLocation == opt %}selected{% endif %}>
{{ opt }}
</option>
{% endfor %}
</select>
</div>
<div class="col-md-4">
<label class="form-label">ProtectYourPC</label>
<select class="form-select form-select-sm" name="oobe_ProtectYourPC">
{% for opt in ["1", "2", "3"] %}
<option value="{{ opt }}" {% if data.oobe.ProtectYourPC == opt %}selected{% endif %}>
{{ opt }}{% if opt == "1" %} (Recommended){% elif opt == "3" %} (Skip){% endif %}
</option>
{% endfor %}
</select>
<div class="form-text">1 = Recommended, 2 = Install only updates, 3 = Skip</div>
</div>
</div>
</div>
</div>
<!-- 8. OOBE Time Zone -->
<div class="card section-card">
<!-- OOBE Time Zone -->
<div class="card">
<div class="card-header">
OOBE Time Zone
<span class="title-group"><i class="bi bi-clock-history"></i> OOBE Time Zone</span>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label fw-semibold">Time Zone (oobeSystem pass)</label>
<label class="form-label">Time Zone (oobeSystem pass)</label>
<input type="text" class="form-control" name="oobe_timezone"
value="{{ data.oobe_timezone }}" placeholder="e.g. Eastern Standard Time">
<div class="form-text">Separate from the specialize-pass time zone in Machine Settings above.</div>
@@ -407,32 +404,105 @@
</div>
</div>
<!-- 9. First Logon Commands -->
<div class="card section-card">
<div class="card-header d-flex justify-content-between align-items-center">
<span>First Logon Commands</span>
<button type="button" class="btn btn-sm btn-outline-primary" id="addFlCmd">
Add
<!-- ============= GROUP 4: SETUP COMMANDS ============= -->
<div class="group-heading"><i class="bi bi-terminal"></i> Setup Commands (RunSynchronous)</div>
<div class="alert alert-warning">
<i class="bi bi-exclamation-triangle"></i>
These commands run as a validated golden sequence. <strong>Order matters within each list.</strong>
Reorder with the arrows or by dragging the <i class="bi bi-grip-vertical"></i> handle only when you are sure.
</div>
<!-- Specialize Commands -->
<div class="card">
<div class="card-header">
<span class="title-group">
<i class="bi bi-gear-wide-connected"></i> Specialize Commands
<span class="count-pill">{{ data.specialize_commands|length }}</span>
</span>
<button type="button" class="btn btn-sm btn-outline-primary" id="addSpecCmd">
<i class="bi bi-plus-lg"></i> Add
</button>
</div>
<div class="card-body p-0">
<table class="table table-sm mb-0 command-table" id="flCmdTable">
<thead class="table-light">
<div class="table-container">
<table class="table table-sm mb-0 align-middle command-table" id="specCmdTable">
<thead>
<tr>
<th style="width:30px"></th>
<th style="width:50px">Order</th>
<th>Path / Command</th>
<th>Description</th>
<th style="width:120px"></th>
</tr>
</thead>
<tbody>
{% for cmd in data.specialize_commands %}
<tr draggable="true">
<td class="drag-handle"><i class="bi bi-grip-vertical"></i></td>
<td class="order-num">{{ loop.index }}</td>
<td>
<input type="text" class="form-control form-control-sm mono"
name="spec_cmd_path[]" value="{{ cmd.path }}">
</td>
<td>
<input type="text" class="form-control form-control-sm"
name="spec_cmd_desc[]" value="{{ cmd.description }}">
</td>
<td class="text-nowrap">
<button type="button" class="btn btn-outline-secondary btn-row-action move-up" title="Move up">
<i class="bi bi-arrow-up"></i>
</button>
<button type="button" class="btn btn-outline-secondary btn-row-action move-down" title="Move down">
<i class="bi bi-arrow-down"></i>
</button>
<button type="button" class="btn btn-outline-danger btn-row-action remove-row" title="Remove">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if not data.specialize_commands %}
<div class="text-center py-3 empty-message" id="specCmdEmpty" style="color: var(--text-light);">
No specialize commands configured. Click <strong>Add</strong> to add one.
</div>
{% endif %}
</div>
</div>
<!-- First Logon Commands -->
<div class="card">
<div class="card-header">
<span class="title-group">
<i class="bi bi-play-circle"></i> First Logon Commands
<span class="count-pill">{{ data.firstlogon_commands|length }}</span>
</span>
<button type="button" class="btn btn-sm btn-outline-primary" id="addFlCmd">
<i class="bi bi-plus-lg"></i> Add
</button>
</div>
<div class="card-body p-0">
<div class="table-container">
<table class="table table-sm mb-0 align-middle command-table" id="flCmdTable">
<thead>
<tr>
<th style="width:30px"></th>
<th style="width:50px">Order</th>
<th>Command Line</th>
<th>Description</th>
<th style="width:90px"></th>
<th style="width:120px"></th>
</tr>
</thead>
<tbody>
{% for cmd in data.firstlogon_commands %}
<tr draggable="true">
<td class="drag-handle">::</td>
<td class="drag-handle"><i class="bi bi-grip-vertical"></i></td>
<td class="order-num">{{ loop.index }}</td>
<td>
<input type="text" class="form-control form-control-sm"
<input type="text" class="form-control form-control-sm mono"
name="fl_cmd_commandline[]" value="{{ cmd.commandline }}">
</td>
<td>
@@ -441,21 +511,22 @@
</td>
<td class="text-nowrap">
<button type="button" class="btn btn-outline-secondary btn-row-action move-up" title="Move up">
Up
<i class="bi bi-arrow-up"></i>
</button>
<button type="button" class="btn btn-outline-secondary btn-row-action move-down" title="Move down">
Down
<i class="bi bi-arrow-down"></i>
</button>
<button type="button" class="btn btn-outline-danger btn-row-action remove-row">
Remove
<button type="button" class="btn btn-outline-danger btn-row-action remove-row" title="Remove">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if not data.firstlogon_commands %}
<div class="text-center text-muted py-3 empty-message" id="flCmdEmpty">
<div class="text-center py-3 empty-message" id="flCmdEmpty" style="color: var(--text-light);">
No first logon commands configured. Click <strong>Add</strong> to add one.
</div>
{% endif %}
@@ -467,13 +538,17 @@
<!-- ==================== RAW XML VIEW ==================== -->
<div class="tab-pane fade" id="rawView" role="tabpanel">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<span>Raw XML</span>
<div class="card-header">
<span class="title-group"><i class="bi bi-filetype-xml"></i> Raw XML</span>
<button type="button" class="btn btn-sm btn-success" id="saveRawBtn">
Save Raw XML
<i class="bi bi-floppy"></i> Save Raw XML
</button>
</div>
<div class="card-body">
<div class="alert alert-warning">
<i class="bi bi-exclamation-triangle"></i>
Editing raw XML bypasses the form. Save here to write your changes directly.
</div>
<textarea class="form-control raw-xml-editor" name="raw_xml"
id="rawXmlEditor">{{ data.raw_xml }}</textarea>
</div>