webapp: reskin to shopdb-flask design system

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,18 +3,32 @@
{% block extra_head %} {% block extra_head %}
<style> <style>
.editor-tabs .nav-link { /* Group dividers that organize the sections in configuration-pass order */
font-weight: 500; .group-heading {
} font-size: 12px;
.command-table tbody tr { text-transform: uppercase;
transition: background-color 0.15s; 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 { .command-table tbody tr.dragging {
opacity: 0.5; opacity: 0.5;
background-color: #e9ecef; background-color: rgba(65, 129, 255, 0.12);
} }
.raw-xml-editor { .raw-xml-editor {
font-family: 'Consolas', 'Monaco', 'Courier New', monospace; font-family: 'SF Mono', 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 0.85rem; font-size: 0.85rem;
min-height: 600px; min-height: 600px;
height: 70vh; height: 70vh;
@@ -22,49 +36,55 @@
white-space: pre; white-space: pre;
resize: vertical; resize: vertical;
} }
.section-card {
margin-bottom: 1.5rem;
}
.section-card .card-header {
padding: 0.6rem 1rem;
font-size: 0.95rem;
}
.order-num { .order-num {
width: 40px; width: 40px;
text-align: center; text-align: center;
font-weight: 600; 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> </style>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="page-header">
<div> <div>
<h2 class="mb-1">{{ friendly_name }}</h2> <h1>{{ friendly_name }}</h1>
<small class="text-muted"> <small class="mono" style="color: var(--text-light);">{{ image_type }}/Deploy/FlatUnattendW10.xml</small>
<code>{{ image_type }}/Deploy/FlatUnattendW10.xml</code>
</small>
</div> </div>
<div> <div class="header-actions">
<button type="button" class="btn btn-success" id="saveFormBtn"> <button type="button" class="btn btn-success" id="saveFormBtn">
Save <i class="bi bi-floppy"></i> Save
</button> </button>
</div> </div>
</div> </div>
<!-- Tabs --> <!-- Form / Raw XML toggle (ids read by app.js) -->
<ul class="nav nav-tabs editor-tabs mb-3" role="tablist"> <ul class="nav nav-tabs editor-tabs mb-3" role="tablist">
<li class="nav-item"> <li class="nav-item">
<a class="nav-link active" id="form-tab" data-bs-toggle="tab" <a class="nav-link active" id="form-tab" data-bs-toggle="tab"
href="#formView" role="tab"> href="#formView" role="tab">
Form Editor <i class="bi bi-ui-checks-grid"></i> Form Editor
</a> </a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" id="raw-tab" data-bs-toggle="tab" <a class="nav-link" id="raw-tab" data-bs-toggle="tab"
href="#rawView" role="tab"> href="#rawView" role="tab">
Raw XML <i class="bi bi-code-slash"></i> Raw XML
</a> </a>
</li> </li>
</ul> </ul>
@@ -76,17 +96,100 @@
<!-- ==================== FORM VIEW ==================== --> <!-- ==================== FORM VIEW ==================== -->
<div class="tab-pane fade show active" id="formView" role="tabpanel"> <div class="tab-pane fade show active" id="formView" role="tabpanel">
<!-- 1. Driver Paths --> <div class="alert alert-info">
<div class="card section-card"> <i class="bi bi-info-circle"></i>
<div class="card-header d-flex justify-content-between align-items-center"> Edit this image's answer file below. Fields are grouped by Windows setup stage.
<span>Driver Paths</span> 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"> <button type="button" class="btn btn-sm btn-outline-primary" id="addDriverPath">
Add <i class="bi bi-plus-lg"></i> Add
</button> </button>
</div> </div>
<div class="card-body p-0"> <div class="card-body p-0">
<table class="table table-sm mb-0" id="driverPathsTable"> <div class="table-container">
<thead class="table-light"> <table class="table table-sm mb-0 align-middle" id="driverPathsTable">
<thead>
<tr> <tr>
<th style="width:40px">#</th> <th style="width:40px">#</th>
<th>Path</th> <th>Path</th>
@@ -98,178 +201,45 @@
<tr> <tr>
<td class="order-num">{{ loop.index }}</td> <td class="order-num">{{ loop.index }}</td>
<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 }}"> name="driver_path[]" value="{{ dp }}">
</td> </td>
<td> <td>
<button type="button" class="btn btn-outline-danger btn-row-action remove-row"> <button type="button" class="btn btn-outline-danger btn-row-action remove-row" title="Remove">
Remove <i class="bi bi-trash"></i>
</button> </button>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div>
{% if not data.driver_paths %} {% 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. No driver paths configured. Click <strong>Add</strong> to add one.
</div> </div>
{% endif %} {% endif %}
</div> </div>
</div> </div>
<!-- 2. Machine Settings --> <!-- ============= GROUP 2: ACCOUNTS & SIGN-IN ============= -->
<div class="card section-card"> <div class="group-heading"><i class="bi bi-people"></i> Accounts &amp; Sign-in</div>
<!-- User Accounts -->
<div class="card">
<div class="card-header"> <div class="card-header">
Machine Settings <span class="title-group">
</div> <i class="bi bi-person-badge"></i> User Accounts (Local)
<div class="card-body"> <span class="count-pill">{{ data.user_accounts|length }}</span>
<div class="row g-3"> </span>
<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>
<button type="button" class="btn btn-sm btn-outline-primary" id="addUserAccount"> <button type="button" class="btn btn-sm btn-outline-primary" id="addUserAccount">
Add <i class="bi bi-plus-lg"></i> Add
</button> </button>
</div> </div>
<div class="card-body p-0"> <div class="card-body p-0">
<table class="table table-sm mb-0" id="userAccountsTable"> <div class="table-container">
<thead class="table-light"> <table class="table table-sm mb-0 align-middle" id="userAccountsTable">
<thead>
<tr> <tr>
<th style="width:40px">#</th> <th style="width:40px">#</th>
<th>Name</th> <th>Name</th>
@@ -301,51 +271,57 @@
</td> </td>
<td> <td>
<input type="hidden" name="account_plaintext_{{ loop.index0 }}" value="{{ acct.plain_text }}"> <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"> <button type="button" class="btn btn-outline-danger btn-row-action remove-account-row" title="Remove">
Remove <i class="bi bi-trash"></i>
</button> </button>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div>
{% if not data.user_accounts %} {% 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. No local accounts configured. Click <strong>Add</strong> to add one.
</div> </div>
{% endif %} {% endif %}
</div> </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> </div>
<!-- 6. AutoLogon --> <!-- AutoLogon -->
<div class="card section-card"> <div class="card">
<div class="card-header"> <div class="card-header">
AutoLogon <span class="title-group"><i class="bi bi-box-arrow-in-right"></i> AutoLogon</span>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="row g-3"> <div class="row g-3 align-items-end">
<div class="col-md-3"> <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" <input class="form-check-input" type="checkbox" id="autologonEnabledToggle"
{% if data.autologon.enabled|lower == 'true' %}checked{% endif %}> {% 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" <input type="hidden" name="autologon_enabled" id="autologon_enabled_val"
value="{{ data.autologon.enabled }}"> value="{{ data.autologon.enabled }}">
</div> </div>
</div> </div>
<div class="col-md-3"> <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" <input type="text" class="form-control" name="autologon_username"
value="{{ data.autologon.username }}" placeholder="e.g. SupportUser"> value="{{ data.autologon.username }}" placeholder="e.g. SupportUser">
</div> </div>
<div class="col-md-3"> <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" <input type="text" class="form-control" name="autologon_password"
value="{{ data.autologon.password }}"> value="{{ data.autologon.password }}">
<input type="hidden" name="autologon_plaintext" value="{{ data.autologon.plain_text }}"> <input type="hidden" name="autologon_plaintext" value="{{ data.autologon.plain_text }}">
</div> </div>
<div class="col-md-3"> <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" <input type="text" class="form-control" name="autologon_logoncount"
value="{{ data.autologon.logon_count }}" placeholder="e.g. 2 or 999"> value="{{ data.autologon.logon_count }}" placeholder="e.g. 2 or 999">
<div class="form-text">Number of auto-logon attempts.</div> <div class="form-text">Number of auto-logon attempts.</div>
@@ -354,51 +330,72 @@
</div> </div>
</div> </div>
<!-- 7. International Settings --> <!-- ============= GROUP 3: OUT-OF-BOX EXPERIENCE ============= -->
<div class="card section-card"> <div class="group-heading"><i class="bi bi-window-desktop"></i> Out-of-Box Experience (OOBE)</div>
<div class="card-header" data-bs-toggle="collapse" data-bs-target="#intlCollapse"
role="button" style="cursor:pointer"> <!-- OOBE Settings -->
International Settings <div class="card">
<small class="text-muted ms-2">(click to expand)</small> <div class="card-header">
<span class="title-group"><i class="bi bi-toggles"></i> OOBE Settings</span>
</div> </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="card-body">
<div class="row g-3"> <div class="row g-3">
<div class="col-md-3"> {% set bool_oobe_fields = [
<label class="form-label fw-semibold">Input Locale</label> ("HideEULAPage", "Hide EULA Page"),
<input type="text" class="form-control" name="intl_input_locale" ("HideOEMRegistrationScreen", "Hide OEM Registration Screen"),
value="{{ data.intl.input_locale }}" placeholder="e.g. en-US"> ("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>
<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>
<div class="col-md-3"> {% endfor %}
<label class="form-label fw-semibold">UI Language</label> <div class="col-md-4">
<input type="text" class="form-control" name="intl_ui_language" <label class="form-label">Network Location</label>
value="{{ data.intl.ui_language }}" placeholder="e.g. en-US"> <select class="form-select form-select-sm" name="oobe_NetworkLocation">
</div> {% for opt in ["Home", "Work", "Other"] %}
<div class="col-md-3"> <option value="{{ opt }}" {% if data.oobe.NetworkLocation == opt %}selected{% endif %}>
<label class="form-label fw-semibold">User Locale</label> {{ opt }}
<input type="text" class="form-control" name="intl_user_locale" </option>
value="{{ data.intl.user_locale }}" placeholder="e.g. en-US"> {% endfor %}
</select>
</div> </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>
</div> </div>
</div> </div>
<!-- 8. OOBE Time Zone --> <!-- OOBE Time Zone -->
<div class="card section-card"> <div class="card">
<div class="card-header"> <div class="card-header">
OOBE Time Zone <span class="title-group"><i class="bi bi-clock-history"></i> OOBE Time Zone</span>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="row g-3"> <div class="row g-3">
<div class="col-md-6"> <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" <input type="text" class="form-control" name="oobe_timezone"
value="{{ data.oobe_timezone }}" placeholder="e.g. Eastern Standard Time"> 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> <div class="form-text">Separate from the specialize-pass time zone in Machine Settings above.</div>
@@ -407,32 +404,105 @@
</div> </div>
</div> </div>
<!-- 9. First Logon Commands --> <!-- ============= GROUP 4: SETUP COMMANDS ============= -->
<div class="card section-card"> <div class="group-heading"><i class="bi bi-terminal"></i> Setup Commands (RunSynchronous)</div>
<div class="card-header d-flex justify-content-between align-items-center">
<span>First Logon Commands</span> <div class="alert alert-warning">
<button type="button" class="btn btn-sm btn-outline-primary" id="addFlCmd"> <i class="bi bi-exclamation-triangle"></i>
Add 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> </button>
</div> </div>
<div class="card-body p-0"> <div class="card-body p-0">
<table class="table table-sm mb-0 command-table" id="flCmdTable"> <div class="table-container">
<thead class="table-light"> <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> <tr>
<th style="width:30px"></th> <th style="width:30px"></th>
<th style="width:50px">Order</th> <th style="width:50px">Order</th>
<th>Command Line</th> <th>Command Line</th>
<th>Description</th> <th>Description</th>
<th style="width:90px"></th> <th style="width:120px"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for cmd in data.firstlogon_commands %} {% for cmd in data.firstlogon_commands %}
<tr draggable="true"> <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 class="order-num">{{ loop.index }}</td>
<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 }}"> name="fl_cmd_commandline[]" value="{{ cmd.commandline }}">
</td> </td>
<td> <td>
@@ -441,21 +511,22 @@
</td> </td>
<td class="text-nowrap"> <td class="text-nowrap">
<button type="button" class="btn btn-outline-secondary btn-row-action move-up" title="Move up"> <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>
<button type="button" class="btn btn-outline-secondary btn-row-action move-down" title="Move down"> <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>
<button type="button" class="btn btn-outline-danger btn-row-action remove-row"> <button type="button" class="btn btn-outline-danger btn-row-action remove-row" title="Remove">
Remove <i class="bi bi-trash"></i>
</button> </button>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div>
{% if not data.firstlogon_commands %} {% 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. No first logon commands configured. Click <strong>Add</strong> to add one.
</div> </div>
{% endif %} {% endif %}
@@ -467,13 +538,17 @@
<!-- ==================== RAW XML VIEW ==================== --> <!-- ==================== RAW XML VIEW ==================== -->
<div class="tab-pane fade" id="rawView" role="tabpanel"> <div class="tab-pane fade" id="rawView" role="tabpanel">
<div class="card"> <div class="card">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header">
<span>Raw XML</span> <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"> <button type="button" class="btn btn-sm btn-success" id="saveRawBtn">
Save Raw XML <i class="bi bi-floppy"></i> Save Raw XML
</button> </button>
</div> </div>
<div class="card-body"> <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" <textarea class="form-control raw-xml-editor" name="raw_xml"
id="rawXmlEditor">{{ data.raw_xml }}</textarea> id="rawXmlEditor">{{ data.raw_xml }}</textarea>
</div> </div>