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:
928
webapp/static/pxe-theme.css
Normal file
928
webapp/static/pxe-theme.css
Normal 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; }
|
||||
}
|
||||
@@ -58,7 +58,8 @@
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% set card_key = s.serial or ('mac-' ~ (s.mac or 'unknown') ~ '-' ~ (s.ip or '')) %}
|
||||
<details class="card border-{{ border }} mb-2 shadow-sm imaging-card{% if is_inferred %} imaging-inferred{% endif %}"
|
||||
<details class="card mb-2 imaging-card{% if is_inferred %} imaging-inferred{% endif %}"
|
||||
style="border-left:4px solid var(--{{ border }});"
|
||||
data-serial="{{ s.serial or '' }}"
|
||||
data-key="{{ card_key }}"
|
||||
data-mac="{{ s.mac or '' }}"
|
||||
@@ -72,8 +73,8 @@
|
||||
style="line-height:0; flex-shrink:0;"
|
||||
title="Intune Device ID: {{ s.intune_device_id }}"></div>
|
||||
{% else %}
|
||||
<div class="d-flex align-items-center justify-content-center bg-light text-muted small"
|
||||
style="width:96px; height:96px; border-radius:0.25rem; flex-shrink:0; text-align:center; padding:0.25rem;">
|
||||
<div class="d-flex align-items-center justify-content-center eyebrow"
|
||||
style="width:96px; height:96px; border-radius:0.25rem; flex-shrink:0; text-align:center; padding:0.25rem; background:var(--bg); border:1px solid var(--border);">
|
||||
{% if is_inferred %}log-only{% else %}no DeviceId{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -86,27 +87,27 @@
|
||||
{% elif s.mac %}{{ s.mac }}
|
||||
{% else %}{{ s.ip or '(unknown bay)' }}{% endif %}
|
||||
</strong>
|
||||
{% if s.hostname_target and s.serial %}<code class="text-muted small">{{ s.hostname_target }}</code>{% endif %}
|
||||
{% if s.ip %}<code class="text-muted small">{{ s.ip }}</code>{% endif %}
|
||||
{% if s.pctype %}<span class="badge bg-info text-dark">{{ s.pctype }}</span>{% endif %}
|
||||
{% if s.machinenumber %}<span class="badge bg-secondary">#{{ s.machinenumber }}</span>{% endif %}
|
||||
{% if is_inferred %}<span class="badge bg-warning text-dark">log-inferred</span>{% endif %}
|
||||
<span class="badge bg-{{ border }} ms-auto">{{ s.status or 'in_progress' }}</span>
|
||||
{% if s.hostname_target and s.serial %}<code class="mono" style="color:var(--text-light);">{{ s.hostname_target }}</code>{% endif %}
|
||||
{% if s.ip %}<code class="mono" style="color:var(--text-light);">{{ s.ip }}</code>{% endif %}
|
||||
{% if s.pctype %}<span class="badge badge-info">{{ s.pctype }}</span>{% endif %}
|
||||
{% if s.machinenumber %}<span class="badge badge-secondary">#{{ s.machinenumber }}</span>{% endif %}
|
||||
{% if is_inferred %}<span class="badge badge-warning">log-inferred</span>{% endif %}
|
||||
<span class="badge badge-{{ border }} ms-auto">{{ s.status or 'in_progress' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-baseline mt-1">
|
||||
<div>
|
||||
<div class="d-flex justify-content-between align-items-baseline mt-2">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<strong>{{ friendly[0] }}</strong>
|
||||
{% if not is_inferred %}
|
||||
<span class="badge bg-secondary ms-1">{{ stage_idx }}/{{ stage_total or '?' }}</span>
|
||||
<span class="badge badge-secondary">{{ stage_idx }}/{{ stage_total or '?' }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if not is_inferred %}<span class="text-muted small">{{ pct }}%</span>{% endif %}
|
||||
{% if not is_inferred %}<span class="eyebrow">{{ pct }}%</span>{% endif %}
|
||||
</div>
|
||||
{% if not is_inferred %}
|
||||
<div class="progress mt-1" style="height:0.7rem;">
|
||||
<div class="progress mt-1" style="height:0.7rem; background:var(--bg); border-radius:0.25rem;">
|
||||
<div class="progress-bar bg-{{ border }} {% if not is_done and not is_failed %}progress-bar-striped progress-bar-animated{% endif %}"
|
||||
role="progressbar" style="width: {{ pct }}%;"
|
||||
role="progressbar" style="width: {{ pct }}%; background-color:var(--{{ border }});"
|
||||
aria-valuenow="{{ pct }}" aria-valuemin="0" aria-valuemax="100"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -114,46 +115,44 @@
|
||||
</div>
|
||||
</summary>
|
||||
|
||||
<div class="card-body pt-0 pb-3 border-top">
|
||||
{% if friendly[1] %}<div class="small text-muted mt-2">{{ friendly[1] }}</div>{% endif %}
|
||||
<div class="card-body pt-0 pb-3" style="border-top:1px solid var(--border);">
|
||||
{% if friendly[1] %}<div class="mt-2" style="color:var(--text-light); font-size:0.85rem;">{{ friendly[1] }}</div>{% endif %}
|
||||
|
||||
{% if s.intune_device_id %}
|
||||
<div class="small mt-2" style="font-size:0.75rem;">
|
||||
<span class="text-muted">Intune:</span> <code>{{ s.intune_device_id }}</code>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary py-0 px-1 copy-btn"
|
||||
style="font-size:0.65rem; line-height:1; transition: all 0.2s;"
|
||||
<div class="d-flex flex-wrap align-items-center gap-2 mt-3">
|
||||
<span class="eyebrow">Intune</span>
|
||||
<code class="mono">{{ s.intune_device_id }}</code>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary btn-row-action copy-btn"
|
||||
data-copy-text="{{ s.intune_device_id }}">copy</button>
|
||||
<a class="btn btn-sm btn-outline-primary py-0 px-1"
|
||||
style="font-size:0.65rem; line-height:1;"
|
||||
<a class="btn btn-sm btn-outline-primary btn-row-action"
|
||||
target="_blank" rel="noopener"
|
||||
href="https://portal.azure.us/?feature.msaljs=false#view/Microsoft_Intune_Devices/DeviceSettingsMenuBlade/~/properties/aadDeviceId/{{ s.intune_device_id }}">set category</a>
|
||||
<a class="btn btn-sm btn-outline-warning py-0 px-1"
|
||||
style="font-size:0.65rem; line-height:1;"
|
||||
<a class="btn btn-sm btn-outline-warning btn-row-action"
|
||||
target="_blank" rel="noopener"
|
||||
href="https://arts.dw.geaerospace.net/requests/type">ARTS request</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="text-muted mt-1" style="font-size:0.7rem;">
|
||||
<span class="me-3">started <code>{{ s.started_at or '-' }}</code></span>
|
||||
<span class="me-3">last <code>{{ s.last_updated or '-' }}</code></span>
|
||||
<span class="me-3">MAC <code>{{ s.mac or '-' }}</code></span>
|
||||
{% if s.current_stage %}<span style="font-family:monospace;">{{ s.current_stage }}</span>{% endif %}
|
||||
<div class="mt-2 d-flex flex-wrap gap-3" style="color:var(--text-light); font-size:0.75rem;">
|
||||
<span>started <code class="mono">{{ s.started_at or '-' }}</code></span>
|
||||
<span>last <code class="mono">{{ s.last_updated or '-' }}</code></span>
|
||||
<span>MAC <code class="mono">{{ s.mac or '-' }}</code></span>
|
||||
{% if s.current_stage %}<span class="mono">{{ s.current_stage }}</span>{% endif %}
|
||||
</div>
|
||||
|
||||
{% if s.error %}
|
||||
<div class="alert alert-danger small py-2 mb-2 mt-3">
|
||||
<div class="alert alert-danger py-2 mb-2 mt-3" style="font-size:0.85rem;">
|
||||
<strong>Error:</strong> {{ s.error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not is_inferred %}
|
||||
<div class="mt-3 laps-card" data-serial="{{ s.serial }}">
|
||||
<div class="text-muted small mb-1">LAPS password QR (paste -> scan on bay - persists until cleared)</div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="eyebrow mb-2">LAPS password QR (paste -> scan on bay - persists until cleared)</div>
|
||||
<div class="d-flex flex-wrap align-items-center gap-2">
|
||||
<input type="text"
|
||||
class="form-control form-control-sm laps-input"
|
||||
style="font-family: monospace; max-width: 22rem;"
|
||||
class="form-control form-control-sm laps-input mono"
|
||||
style="max-width: 22rem;"
|
||||
placeholder="paste LAPS password from Intune portal here"
|
||||
autocomplete="off"
|
||||
value="{{ s.laps_password or '' }}">
|
||||
@@ -166,8 +165,8 @@
|
||||
|
||||
{% if s.log_tail %}
|
||||
<details class="mt-3">
|
||||
<summary class="text-muted small">{% if is_inferred %}Evidence{% else %}Log tail{% endif %} ({{ s.log_tail | length }} line{{ 's' if s.log_tail | length != 1 }})</summary>
|
||||
<pre class="bg-light p-2 mt-2 small mb-0" style="max-height: 12rem; overflow-y: auto;">{% for line in s.log_tail %}{{ line }}
|
||||
<summary class="eyebrow" style="cursor:pointer;">{% if is_inferred %}Evidence{% else %}Log tail{% endif %} ({{ s.log_tail | length }} line{{ 's' if s.log_tail | length != 1 }})</summary>
|
||||
<pre class="mono p-2 mt-2 mb-0" style="max-height: 12rem; overflow-y: auto; background:var(--bg); border:1px solid var(--border); border-radius:0.25rem; font-size:0.78rem;">{% for line in s.log_tail %}{{ line }}
|
||||
{% endfor %}</pre>
|
||||
</details>
|
||||
{% endif %}
|
||||
|
||||
@@ -1,61 +1,60 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Audit Log - PXE Server Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2 class="mb-0">Audit Log</h2>
|
||||
<span class="badge bg-secondary fs-6">{{ entries|length }} entries</span>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header d-flex align-items-center">
|
||||
Activity History
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% if entries %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-sm mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width: 180px;">Timestamp</th>
|
||||
<th style="width: 130px;">Source</th>
|
||||
<th style="width: 180px;">Action</th>
|
||||
<th>Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in entries %}
|
||||
<tr>
|
||||
{% set parts = entry.split(' ', 1) %}
|
||||
{% if parts|length == 2 %}
|
||||
{% set meta = parts[1].split('] ', 1) %}
|
||||
<td><small class="text-muted">{{ parts[0] }}</small></td>
|
||||
{% if meta|length == 2 %}
|
||||
<td><code>{{ meta[0].lstrip('[') }}</code></td>
|
||||
{% set action_detail = meta[1].split(': ', 1) %}
|
||||
{% if action_detail|length == 2 %}
|
||||
<td><span class="badge bg-primary">{{ action_detail[0] }}</span></td>
|
||||
<td>{{ action_detail[1] }}</td>
|
||||
{% else %}
|
||||
<td colspan="2">{{ meta[1] }}</td>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<td colspan="3">{{ parts[1] }}</td>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<td colspan="4">{{ entry }}</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-5">
|
||||
<p class="mt-2">No audit log entries yet.</p>
|
||||
<p class="small">Actions like image imports, unattend edits, and backup operations will be logged here.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Audit Log - PXE Server Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h2>Audit Log</h2>
|
||||
<div class="header-actions">
|
||||
<span class="badge badge-secondary badge-lg">{{ entries|length }} entries</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<div class="section-title">Activity History</div>
|
||||
{% if entries %}
|
||||
<div class="table-container">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 180px;">Timestamp</th>
|
||||
<th style="width: 130px;">Source</th>
|
||||
<th style="width: 180px;">Action</th>
|
||||
<th>Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in entries %}
|
||||
<tr>
|
||||
{% set parts = entry.split(' ', 1) %}
|
||||
{% if parts|length == 2 %}
|
||||
{% set meta = parts[1].split('] ', 1) %}
|
||||
<td class="mono">{{ parts[0] }}</td>
|
||||
{% if meta|length == 2 %}
|
||||
<td class="mono">{{ meta[0].lstrip('[') }}</td>
|
||||
{% set action_detail = meta[1].split(': ', 1) %}
|
||||
{% if action_detail|length == 2 %}
|
||||
<td><span class="badge badge-primary">{{ action_detail[0] }}</span></td>
|
||||
<td>{{ action_detail[1] }}</td>
|
||||
{% else %}
|
||||
<td colspan="2">{{ meta[1] }}</td>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<td colspan="3">{{ parts[1] }}</td>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<td colspan="4">{{ entry }}</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-5">
|
||||
<i class="bi bi-list-check" style="font-size: 2rem; opacity: 0.4;"></i>
|
||||
<p class="mt-2 mb-1">No audit log entries yet.</p>
|
||||
<p class="small mb-0">Actions like image imports, unattend edits, and backup operations will be logged here.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,132 +1,140 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Clonezilla Backups - PXE Server Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2 class="mb-0">Clonezilla Backups</h2>
|
||||
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#uploadModal">
|
||||
Upload Backup
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header d-flex align-items-center">
|
||||
Machine Backups
|
||||
<span class="badge bg-secondary ms-2">{{ backups|length }}</span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% if backups %}
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Machine #</th>
|
||||
<th>Filename</th>
|
||||
<th>Size</th>
|
||||
<th>Last Modified</th>
|
||||
<th class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for b in backups %}
|
||||
<tr>
|
||||
<td><strong>{{ b.machine }}</strong></td>
|
||||
<td><code>{{ b.filename }}</code></td>
|
||||
<td>{{ "%.1f"|format(b.size / 1073741824) }} GB</td>
|
||||
<td>{{ b.modified | timestamp_fmt }}</td>
|
||||
<td class="text-end text-nowrap">
|
||||
<a href="{{ url_for('clonezilla_download', filename=b.filename) }}"
|
||||
class="btn btn-sm btn-outline-primary" title="Download">
|
||||
Download
|
||||
</a>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger"
|
||||
data-bs-toggle="modal" data-bs-target="#deleteModal"
|
||||
data-filename="{{ b.filename }}" data-machine="{{ b.machine }}" title="Delete">
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-5">
|
||||
<p class="mt-2">No backups found. Upload a Clonezilla backup .zip to get started.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-3">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">Backup Naming Convention</h6>
|
||||
<p class="card-text mb-0">
|
||||
Name backup files with the machine number (e.g., <code>6501.zip</code>).
|
||||
The Samba share <code>\\pxe-server\clonezilla</code> is also available on the network for direct Clonezilla save/restore operations.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upload Modal -->
|
||||
<div class="modal fade" id="uploadModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form action="{{ url_for('clonezilla_upload') }}" method="post" enctype="multipart/form-data">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Upload Backup</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label for="backupFile" class="form-label">Backup File (.zip)</label>
|
||||
<input type="file" class="form-control" id="backupFile" name="backup_file"
|
||||
accept=".zip" required>
|
||||
<div class="form-text">
|
||||
Name the file with the machine number (e.g., <code>6501.zip</code>).
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Upload</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div class="modal fade" id="deleteModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form id="deleteForm" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Confirm Delete</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to delete the backup for machine <strong id="deleteMachine"></strong>?</p>
|
||||
<p class="text-muted mb-0">This action cannot be undone.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-danger">Delete</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
document.getElementById('deleteModal').addEventListener('show.bs.modal', function (event) {
|
||||
var btn = event.relatedTarget;
|
||||
var filename = btn.getAttribute('data-filename');
|
||||
var machine = btn.getAttribute('data-machine');
|
||||
document.getElementById('deleteMachine').textContent = machine;
|
||||
document.getElementById('deleteForm').action = '/backups/delete/' + encodeURIComponent(filename);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Clonezilla Backups - PXE Server Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h2>Clonezilla Backups</h2>
|
||||
<div class="header-actions">
|
||||
<button class="pxe-btn pxe-btn-primary" data-bs-toggle="modal" data-bs-target="#uploadModal">
|
||||
<i class="bi bi-upload"></i> Upload Backup
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header d-flex align-items-center">
|
||||
<i class="bi bi-hdd-stack me-2"></i>
|
||||
Machine Backups
|
||||
<span class="badge badge-secondary ms-2">{{ backups|length }}</span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% if backups %}
|
||||
<div class="table-container">
|
||||
<table class="data-table mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Machine #</th>
|
||||
<th>Filename</th>
|
||||
<th>Size</th>
|
||||
<th>Last Modified</th>
|
||||
<th class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for b in backups %}
|
||||
<tr>
|
||||
<td><strong>{{ b.machine }}</strong></td>
|
||||
<td><span class="mono">{{ b.filename }}</span></td>
|
||||
<td>{{ "%.1f"|format(b.size / 1073741824) }} GB</td>
|
||||
<td>{{ b.modified | timestamp_fmt }}</td>
|
||||
<td class="actions text-end text-nowrap">
|
||||
<a href="{{ url_for('clonezilla_download', filename=b.filename) }}"
|
||||
class="pxe-btn pxe-btn-secondary btn-row-action" title="Download">
|
||||
<i class="bi bi-download"></i> Download
|
||||
</a>
|
||||
<button type="button" class="pxe-btn pxe-btn-danger btn-row-action"
|
||||
data-bs-toggle="modal" data-bs-target="#deleteModal"
|
||||
data-filename="{{ b.filename }}" data-machine="{{ b.machine }}" title="Delete">
|
||||
<i class="bi bi-trash"></i> Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-5">
|
||||
<i class="bi bi-hdd-stack" style="font-size: 2rem; opacity: 0.4;"></i>
|
||||
<p class="mt-2 mb-0">No backups found. Upload a Clonezilla backup .zip to get started.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-card mt-3">
|
||||
<div class="section-title">Backup Naming Convention</div>
|
||||
<p class="mb-0">
|
||||
Name backup files with the machine number (e.g., <span class="mono">6501.zip</span>).
|
||||
The Samba share <span class="mono">\\pxe-server\clonezilla</span> is also available on the network for direct Clonezilla save/restore operations.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Upload Modal -->
|
||||
<div class="modal fade" id="uploadModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form action="{{ url_for('clonezilla_upload') }}" method="post" enctype="multipart/form-data">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Upload Backup</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-field mb-0">
|
||||
<label for="backupFile" class="form-label">Backup File (.zip)</label>
|
||||
<input type="file" class="form-control" id="backupFile" name="backup_file"
|
||||
accept=".zip" required>
|
||||
<div class="form-text">
|
||||
Name the file with the machine number (e.g., <span class="mono">6501.zip</span>).
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="pxe-btn pxe-btn-ghost" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="pxe-btn pxe-btn-primary">
|
||||
<i class="bi bi-upload"></i> Upload
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div class="modal fade" id="deleteModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form id="deleteForm" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Confirm Delete</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to delete the backup for machine <strong id="deleteMachine"></strong>?</p>
|
||||
<p class="text-muted mb-0">This action cannot be undone.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="pxe-btn pxe-btn-ghost" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="pxe-btn pxe-btn-danger">
|
||||
<i class="bi bi-trash"></i> Delete
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
document.getElementById('deleteModal').addEventListener('show.bs.modal', function (event) {
|
||||
var btn = event.relatedTarget;
|
||||
var filename = btn.getAttribute('data-filename');
|
||||
var machine = btn.getAttribute('data-machine');
|
||||
document.getElementById('deleteMachine').textContent = machine;
|
||||
document.getElementById('deleteForm').action = '/backups/delete/' + encodeURIComponent(filename);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,228 +1,178 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-bs-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<title>{% block title %}PXE Server Manager{% endblock %}</title>
|
||||
<link rel="icon" href="{{ url_for('static', filename='favicon.ico') }}" type="image/x-icon">
|
||||
<link href="{{ url_for('static', filename='bootstrap.min.css') }}" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--sidebar-width: 280px;
|
||||
}
|
||||
body {
|
||||
min-height: 100vh;
|
||||
}
|
||||
.sidebar {
|
||||
width: var(--sidebar-width);
|
||||
min-height: 100vh;
|
||||
background-color: #1a1d21;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 100;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.sidebar .nav-link {
|
||||
color: #adb5bd;
|
||||
padding: 0.6rem 1.25rem;
|
||||
font-size: 0.9rem;
|
||||
border-radius: 0;
|
||||
}
|
||||
.sidebar .nav-link:hover,
|
||||
.sidebar .nav-link.active {
|
||||
color: #fff;
|
||||
background-color: rgba(255,255,255,0.08);
|
||||
}
|
||||
.sidebar-heading {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: #6c757d;
|
||||
padding: 1rem 1.25rem 0.4rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.sidebar .brand {
|
||||
padding: 1.2rem 1.25rem;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.08);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.main-content {
|
||||
margin-left: var(--sidebar-width);
|
||||
padding: 2rem;
|
||||
}
|
||||
.status-dot {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.status-dot.active {
|
||||
background-color: #198754;
|
||||
}
|
||||
.status-dot.inactive {
|
||||
background-color: #dc3545;
|
||||
}
|
||||
.status-dot.unknown {
|
||||
background-color: #ffc107;
|
||||
}
|
||||
.card {
|
||||
border: 1px solid #dee2e6;
|
||||
}
|
||||
.card-header {
|
||||
font-weight: 600;
|
||||
}
|
||||
.table th {
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn-row-action {
|
||||
padding: 0.2rem 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.drag-handle {
|
||||
cursor: grab;
|
||||
color: #6c757d;
|
||||
}
|
||||
.drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.nav-section-divider {
|
||||
border-top: 1px solid rgba(255,255,255,0.06);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
{% block extra_head %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<nav class="sidebar d-flex flex-column">
|
||||
<div class="brand">
|
||||
<img src="{{ url_for('static', filename='ge-aerospace-logo.svg') }}" alt="GE Aerospace" style="height: 28px; filter: brightness(0) invert(1);">
|
||||
PXE Manager
|
||||
</div>
|
||||
<ul class="nav flex-column mt-2">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'dashboard' %}active{% endif %}"
|
||||
href="{{ url_for('dashboard') }}">
|
||||
Dashboard
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'images_import' %}active{% endif %}"
|
||||
href="{{ url_for('images_import') }}">
|
||||
Image Import
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'imaging_dashboard' %}active{% endif %}"
|
||||
href="{{ url_for('imaging_dashboard') }}">
|
||||
Imaging Progress
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="nav-section-divider"></div>
|
||||
<div class="sidebar-heading">Tools</div>
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'startnet_editor' %}active{% endif %}"
|
||||
href="{{ url_for('startnet_editor') }}">
|
||||
startnet.cmd
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'clonezilla_backups' %}active{% endif %}"
|
||||
href="{{ url_for('clonezilla_backups') }}">
|
||||
Clonezilla Backups
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'blancco_reports' %}active{% endif %}"
|
||||
href="{{ url_for('blancco_reports') }}">
|
||||
Blancco Reports
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'enrollment' %}active{% endif %}"
|
||||
href="{{ url_for('enrollment') }}">
|
||||
Enrollment
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'audit_log' %}active{% endif %}"
|
||||
href="{{ url_for('audit_log') }}">
|
||||
Audit Log
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="nav-section-divider"></div>
|
||||
<div class="sidebar-heading">GE Aerospace Images</div>
|
||||
<ul class="nav flex-column">
|
||||
{% for it in all_image_types if it.startswith('gea-') %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'unattend_editor' and image_type is defined and image_type == it %}active{% endif %}"
|
||||
href="{{ url_for('unattend_editor', image_type=it) }}">
|
||||
{{ all_friendly_names[it] }}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'image_config' and image_type is defined and image_type == it %}active{% endif %}"
|
||||
href="{{ url_for('image_config', image_type=it) }}" style="padding-left: 2.5rem; font-size: 0.82rem;">
|
||||
Configuration
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
<div class="nav-section-divider"></div>
|
||||
<div class="sidebar-heading">GE Legacy Images</div>
|
||||
<ul class="nav flex-column">
|
||||
{% for it in all_image_types if it.startswith('ge-') and not it.startswith('gea-') %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'unattend_editor' and image_type is defined and image_type == it %}active{% endif %}"
|
||||
href="{{ url_for('unattend_editor', image_type=it) }}">
|
||||
{{ all_friendly_names[it] }}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'image_config' and image_type is defined and image_type == it %}active{% endif %}"
|
||||
href="{{ url_for('image_config', image_type=it) }}" style="padding-left: 2.5rem; font-size: 0.82rem;">
|
||||
Configuration
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<!-- Main content -->
|
||||
<div class="main-content">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
|
||||
<script src="{{ url_for('static', filename='bootstrap.bundle.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='app.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='qrcode.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='qr-render.js') }}"></script>
|
||||
{% block extra_scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-bs-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<title>{% block title %}PXE Server Manager{% endblock %}</title>
|
||||
<link rel="icon" href="{{ url_for('static', filename='favicon.ico') }}" type="image/x-icon">
|
||||
<link href="{{ url_for('static', filename='bootstrap.min.css') }}" rel="stylesheet">
|
||||
<link href="{{ url_for('static', filename='bootstrap-icons.min.css') }}" rel="stylesheet">
|
||||
<!-- pxe-theme.css loads AFTER bootstrap so its token+component rules win -->
|
||||
<link href="{{ url_for('static', filename='pxe-theme.css') }}" rel="stylesheet">
|
||||
<script>
|
||||
// Set the theme before first paint to avoid an unstyled flash.
|
||||
(function () {
|
||||
var KEY = 'pxe-theme';
|
||||
var stored = null;
|
||||
try { stored = localStorage.getItem(KEY); } catch (e) {}
|
||||
var theme = (stored === 'light' || stored === 'dark')
|
||||
? stored
|
||||
: (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
document.documentElement.setAttribute('data-bs-theme', theme);
|
||||
})();
|
||||
</script>
|
||||
{% block extra_head %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<nav class="sidebar d-flex flex-column">
|
||||
<div class="sidebar-header">
|
||||
<img class="sidebar-logo" src="{{ url_for('static', filename='ge-aerospace-logo.svg') }}" alt="GE Aerospace">
|
||||
<h1>PXE Manager</h1>
|
||||
</div>
|
||||
|
||||
<ul class="sidebar-nav nav flex-column mt-2">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'dashboard' %}active{% endif %}"
|
||||
href="{{ url_for('dashboard') }}">
|
||||
<i class="bi bi-speedometer2"></i> Dashboard
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'images_import' %}active{% endif %}"
|
||||
href="{{ url_for('images_import') }}">
|
||||
<i class="bi bi-box-arrow-in-down"></i> Image Import
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'imaging_dashboard' %}active{% endif %}"
|
||||
href="{{ url_for('imaging_dashboard') }}">
|
||||
<i class="bi bi-activity"></i> Imaging Progress
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="nav-section">Tools</div>
|
||||
<ul class="sidebar-nav nav flex-column">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'startnet_editor' %}active{% endif %}"
|
||||
href="{{ url_for('startnet_editor') }}">
|
||||
<i class="bi bi-terminal"></i> startnet.cmd
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'clonezilla_backups' %}active{% endif %}"
|
||||
href="{{ url_for('clonezilla_backups') }}">
|
||||
<i class="bi bi-hdd-stack"></i> Clonezilla Backups
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'blancco_reports' %}active{% endif %}"
|
||||
href="{{ url_for('blancco_reports') }}">
|
||||
<i class="bi bi-shield-check"></i> Blancco Reports
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'enrollment' %}active{% endif %}"
|
||||
href="{{ url_for('enrollment') }}">
|
||||
<i class="bi bi-pc-display"></i> Enrollment
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'audit_log' %}active{% endif %}"
|
||||
href="{{ url_for('audit_log') }}">
|
||||
<i class="bi bi-list-check"></i> Audit Log
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="nav-section">GE Aerospace Images</div>
|
||||
<ul class="sidebar-nav nav flex-column">
|
||||
{% for it in all_image_types if it.startswith('gea-') %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint == 'unattend_editor' and image_type is defined and image_type == it %}active{% endif %}"
|
||||
href="{{ url_for('unattend_editor', image_type=it) }}">
|
||||
<i class="bi bi-windows"></i> {{ all_friendly_names[it] }}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link nav-sublink {% if request.endpoint == 'image_config' and image_type is defined and image_type == it %}active{% endif %}"
|
||||
href="{{ url_for('image_config', image_type=it) }}">
|
||||
<i class="bi bi-gear"></i> Configuration
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
<!-- Footer pinned to bottom -->
|
||||
<div class="sidebar-footer mt-auto">
|
||||
<button type="button" id="themeToggle" class="theme-toggle">
|
||||
<i class="bi bi-moon-stars" id="themeToggleIcon"></i>
|
||||
<span id="themeToggleLabel">Dark</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Main content -->
|
||||
<div class="main-content">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Theme toggle: flips data-theme (and data-bs-theme) and persists to localStorage.
|
||||
(function () {
|
||||
var KEY = 'pxe-theme';
|
||||
var btn = document.getElementById('themeToggle');
|
||||
var icon = document.getElementById('themeToggleIcon');
|
||||
var label = document.getElementById('themeToggleLabel');
|
||||
|
||||
function paintToggle(theme) {
|
||||
// Label/icon advertise the theme you would switch TO.
|
||||
if (theme === 'dark') {
|
||||
if (icon) icon.className = 'bi bi-sun';
|
||||
if (label) label.textContent = 'Light';
|
||||
} else {
|
||||
if (icon) icon.className = 'bi bi-moon-stars';
|
||||
if (label) label.textContent = 'Dark';
|
||||
}
|
||||
}
|
||||
|
||||
function apply(theme) {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
document.documentElement.setAttribute('data-bs-theme', theme);
|
||||
try { localStorage.setItem(KEY, theme); } catch (e) {}
|
||||
paintToggle(theme);
|
||||
}
|
||||
|
||||
paintToggle(document.documentElement.getAttribute('data-theme') || 'light');
|
||||
|
||||
if (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
var current = document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
|
||||
apply(current === 'dark' ? 'light' : 'dark');
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script src="{{ url_for('static', filename='bootstrap.bundle.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='app.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='qrcode.min.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='qr-render.js') }}"></script>
|
||||
{% block extra_scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,241 +1,278 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Dashboard - PXE Server Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2 class="mb-0">Dashboard</h2>
|
||||
<button class="btn btn-outline-secondary btn-sm" onclick="location.reload()">
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Services -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex align-items-center">
|
||||
PXE Services
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Service</th>
|
||||
<th>Status</th>
|
||||
<th>State</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for svc in services %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{{ svc.name }}</strong>
|
||||
</td>
|
||||
<td>
|
||||
<span class="status-dot {{ 'active' if svc.active else 'inactive' }}"></span>
|
||||
{{ "Running" if svc.active else "Stopped" }}
|
||||
</td>
|
||||
<td><code>{{ svc.state }}</code></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Images -->
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span>Deployment Images</span>
|
||||
<button type="button" class="btn btn-sm btn-success" data-bs-toggle="modal" data-bs-target="#newImageModal">
|
||||
+ New image type
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Image</th>
|
||||
<th>Deploy Content</th>
|
||||
<th>unattend.xml</th>
|
||||
<th>Path</th>
|
||||
<th class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for img in images %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{{ img.friendly_name }}</strong><br>
|
||||
<small class="text-muted">{{ img.image_type }}</small>
|
||||
</td>
|
||||
<td>
|
||||
{% if img.has_content %}
|
||||
<span class="badge bg-success">Present</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Empty</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if img.has_unattend %}
|
||||
<span class="badge bg-success">Exists</span>
|
||||
{% else %}
|
||||
<span class="badge bg-warning text-dark">Missing</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td><code class="small">{{ img.deploy_path }}</code></td>
|
||||
<td class="text-end">
|
||||
<a href="{{ url_for('image_config', image_type=img.image_type) }}"
|
||||
class="btn btn-sm btn-outline-secondary me-1">
|
||||
Config
|
||||
</a>
|
||||
<a href="{{ url_for('unattend_editor', image_type=img.image_type) }}"
|
||||
class="btn btn-sm btn-outline-primary me-1">
|
||||
Unattend
|
||||
</a>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary me-1"
|
||||
data-bs-toggle="modal" data-bs-target="#cloneImageModal"
|
||||
data-src-key="{{ img.image_type }}"
|
||||
data-src-friendly="{{ img.friendly_name }}">
|
||||
Clone
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger"
|
||||
data-bs-toggle="modal" data-bs-target="#deleteImageModal"
|
||||
data-src-key="{{ img.image_type }}"
|
||||
data-src-friendly="{{ img.friendly_name }}">
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New image type modal -->
|
||||
<div class="modal fade" id="newImageModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<form method="post" action="{{ url_for('images_new') }}">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Create image type</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Key</label>
|
||||
<input type="text" class="form-control font-monospace" name="key"
|
||||
pattern="[a-z][a-z0-9-]{1,63}" required
|
||||
placeholder="gea-shopfloor-newtype">
|
||||
<div class="form-text">Lowercase + hyphens. Used as directory name + URL path. 2-64 chars, must start with a letter.</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Friendly name</label>
|
||||
<input type="text" class="form-control" name="friendly_name" required
|
||||
placeholder="GE Aerospace Shop Floor (newtype)">
|
||||
</div>
|
||||
<div class="alert alert-info small">
|
||||
Empty image type. Populate via Image Import or Clone afterwards.
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-success">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Clone image modal -->
|
||||
<div class="modal fade" id="cloneImageModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<form method="post" id="cloneForm">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Clone image type</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Source: <strong id="cloneSrcFriendly"></strong> <code id="cloneSrcKey"></code></p>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">New key</label>
|
||||
<input type="text" class="form-control font-monospace" name="dst_key"
|
||||
pattern="[a-z][a-z0-9-]{1,63}" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Friendly name (optional)</label>
|
||||
<input type="text" class="form-control" name="friendly_name"
|
||||
placeholder="leave blank for ‘<src> (copy)’">
|
||||
</div>
|
||||
<div class="alert alert-info small">
|
||||
Copies Deploy/ tree (Control + Tools + unattend) and preserves
|
||||
symlinks to shared dirs (Out-of-box Drivers, Operating Systems,
|
||||
Packages). Disk usage stays low because shared content is not
|
||||
duplicated.
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-success">Clone</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete image modal -->
|
||||
<div class="modal fade" id="deleteImageModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<form method="post" id="deleteForm">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Delete image type</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Remove <strong id="deleteSrcFriendly"></strong> <code id="deleteSrcKey"></code> from the registry.</p>
|
||||
<div class="form-check mb-3">
|
||||
<input class="form-check-input" type="checkbox" name="delete_content" value="1" id="deleteContentCheck">
|
||||
<label class="form-check-label" for="deleteContentCheck">
|
||||
Also wipe on-disk Deploy/Tools/etc (symlinked shared dirs are unlinked, not followed)
|
||||
</label>
|
||||
</div>
|
||||
<div class="alert alert-warning small">
|
||||
Removing from registry hides the image from the UI and Ansible
|
||||
playbook list. Existing PXE-imaged clients are unaffected. Wiping
|
||||
content is irreversible.
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-danger">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
document.addEventListener('show.bs.modal', function(e) {
|
||||
var btn = e.relatedTarget;
|
||||
if (!btn) return;
|
||||
var srcKey = btn.getAttribute('data-src-key') || '';
|
||||
var srcFriendly = btn.getAttribute('data-src-friendly') || '';
|
||||
if (e.target.id === 'cloneImageModal') {
|
||||
e.target.querySelector('#cloneSrcKey').textContent = srcKey;
|
||||
e.target.querySelector('#cloneSrcFriendly').textContent = srcFriendly;
|
||||
document.getElementById('cloneForm').action = '/images/' + encodeURIComponent(srcKey) + '/clone';
|
||||
} else if (e.target.id === 'deleteImageModal') {
|
||||
e.target.querySelector('#deleteSrcKey').textContent = srcKey;
|
||||
e.target.querySelector('#deleteSrcFriendly').textContent = srcFriendly;
|
||||
document.getElementById('deleteForm').action = '/images/' + encodeURIComponent(srcKey) + '/delete';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Dashboard - PXE Server Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h2>Dashboard</h2>
|
||||
<div class="header-actions">
|
||||
<a href="{{ url_for('images_import') }}" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-box-arrow-in-down"></i> Image Import
|
||||
</a>
|
||||
<button type="button" class="btn btn-success btn-sm" data-bs-toggle="modal" data-bs-target="#newImageModal">
|
||||
<i class="bi bi-plus-lg"></i> New image type
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" onclick="location.reload()">
|
||||
<i class="bi bi-arrow-clockwise"></i> Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- At-a-glance stats -->
|
||||
<div class="dashboard-grid mb-4">
|
||||
{% set services_up = services | selectattr('active') | list | length %}
|
||||
{% set services_total = services | list | length %}
|
||||
<div class="stat-card {{ 'success' if services_up == services_total else 'danger' }}">
|
||||
<div class="label"><i class="bi bi-hdd-network"></i> Services running</div>
|
||||
<div class="value">{{ services_up }} / {{ services_total }}</div>
|
||||
</div>
|
||||
{% set images_total = images | list | length %}
|
||||
<div class="stat-card info">
|
||||
<div class="label"><i class="bi bi-hdd-stack"></i> Image types</div>
|
||||
<div class="value">{{ images_total }}</div>
|
||||
</div>
|
||||
{% set images_ready = images | selectattr('has_content') | list | length %}
|
||||
<div class="stat-card success">
|
||||
<div class="label"><i class="bi bi-check2-circle"></i> Images with content</div>
|
||||
<div class="value">{{ images_ready }}</div>
|
||||
</div>
|
||||
{% set unattend_missing = images | rejectattr('has_unattend') | list | length %}
|
||||
<div class="stat-card {{ 'warning' if unattend_missing else 'success' }}">
|
||||
<div class="label"><i class="bi bi-file-earmark-code"></i> Missing unattend.xml</div>
|
||||
<div class="value">{{ unattend_missing }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Services -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span><i class="bi bi-hdd-network"></i> PXE Services</span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-container">
|
||||
<table class="table mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Service</th>
|
||||
<th>Status</th>
|
||||
<th>State</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for svc in services %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{{ svc.name }}</strong>
|
||||
</td>
|
||||
<td>
|
||||
<span class="status-dot {{ 'active' if svc.active else 'inactive' }}"></span>
|
||||
{{ "Running" if svc.active else "Stopped" }}
|
||||
</td>
|
||||
<td><span class="mono">{{ svc.state }}</span></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Images -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span><i class="bi bi-hdd-stack"></i> Deployment Images</span>
|
||||
<button type="button" class="btn btn-sm btn-success" data-bs-toggle="modal" data-bs-target="#newImageModal">
|
||||
<i class="bi bi-plus-lg"></i> New image type
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-container">
|
||||
<table class="table mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Image</th>
|
||||
<th>Deploy Content</th>
|
||||
<th>unattend.xml</th>
|
||||
<th>Path</th>
|
||||
<th class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for img in images %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{{ img.friendly_name }}</strong><br>
|
||||
<small class="text-muted mono">{{ img.image_type }}</small>
|
||||
</td>
|
||||
<td>
|
||||
{% if img.has_content %}
|
||||
<span class="badge bg-success">Present</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Empty</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if img.has_unattend %}
|
||||
<span class="badge bg-success">Exists</span>
|
||||
{% else %}
|
||||
<span class="badge bg-warning">Missing</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td><span class="mono">{{ img.deploy_path }}</span></td>
|
||||
<td class="actions text-end">
|
||||
<a href="{{ url_for('image_config', image_type=img.image_type) }}"
|
||||
class="btn btn-sm btn-outline-secondary btn-row-action">
|
||||
<i class="bi bi-gear"></i> Config
|
||||
</a>
|
||||
<a href="{{ url_for('unattend_editor', image_type=img.image_type) }}"
|
||||
class="btn btn-sm btn-outline-primary btn-row-action">
|
||||
<i class="bi bi-file-earmark-code"></i> Unattend
|
||||
</a>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary btn-row-action"
|
||||
data-bs-toggle="modal" data-bs-target="#cloneImageModal"
|
||||
data-src-key="{{ img.image_type }}"
|
||||
data-src-friendly="{{ img.friendly_name }}">
|
||||
<i class="bi bi-files"></i> Clone
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger btn-row-action"
|
||||
data-bs-toggle="modal" data-bs-target="#deleteImageModal"
|
||||
data-src-key="{{ img.image_type }}"
|
||||
data-src-friendly="{{ img.friendly_name }}">
|
||||
<i class="bi bi-trash"></i> Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New image type modal -->
|
||||
<div class="modal fade" id="newImageModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<form method="post" action="{{ url_for('images_new') }}">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Create image type</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-field">
|
||||
<label class="form-label">Key</label>
|
||||
<input type="text" class="form-control mono" name="key"
|
||||
pattern="[a-z][a-z0-9-]{1,63}" required
|
||||
placeholder="gea-shopfloor-newtype">
|
||||
<div class="form-text">Lowercase + hyphens. Used as directory name + URL path. 2-64 chars, must start with a letter.</div>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label">Friendly name</label>
|
||||
<input type="text" class="form-control" name="friendly_name" required
|
||||
placeholder="GE Aerospace Shop Floor (newtype)">
|
||||
</div>
|
||||
<div class="alert alert-info small">
|
||||
Empty image type. Populate via Image Import or Clone afterwards.
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-success">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Clone image modal -->
|
||||
<div class="modal fade" id="cloneImageModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<form method="post" id="cloneForm">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Clone image type</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Source: <strong id="cloneSrcFriendly"></strong> <span class="mono" id="cloneSrcKey"></span></p>
|
||||
<div class="form-field">
|
||||
<label class="form-label">New key</label>
|
||||
<input type="text" class="form-control mono" name="dst_key"
|
||||
pattern="[a-z][a-z0-9-]{1,63}" required>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label">Friendly name (optional)</label>
|
||||
<input type="text" class="form-control" name="friendly_name"
|
||||
placeholder="leave blank for ‘<src> (copy)’">
|
||||
</div>
|
||||
<div class="alert alert-info small">
|
||||
Copies Deploy/ tree (Control + Tools + unattend) and preserves
|
||||
symlinks to shared dirs (Out-of-box Drivers, Operating Systems,
|
||||
Packages). Disk usage stays low because shared content is not
|
||||
duplicated.
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-success">Clone</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete image modal -->
|
||||
<div class="modal fade" id="deleteImageModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<form method="post" id="deleteForm">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Delete image type</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Remove <strong id="deleteSrcFriendly"></strong> <span class="mono" id="deleteSrcKey"></span> from the registry.</p>
|
||||
<div class="form-check mb-3">
|
||||
<input class="form-check-input" type="checkbox" name="delete_content" value="1" id="deleteContentCheck">
|
||||
<label class="form-check-label" for="deleteContentCheck">
|
||||
Also wipe on-disk Deploy/Tools/etc (symlinked shared dirs are unlinked, not followed)
|
||||
</label>
|
||||
</div>
|
||||
<div class="alert alert-warning small">
|
||||
Removing from registry hides the image from the UI and Ansible
|
||||
playbook list. Existing PXE-imaged clients are unaffected. Wiping
|
||||
content is irreversible.
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-danger">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
document.addEventListener('show.bs.modal', function(e) {
|
||||
var btn = e.relatedTarget;
|
||||
if (!btn) return;
|
||||
var srcKey = btn.getAttribute('data-src-key') || '';
|
||||
var srcFriendly = btn.getAttribute('data-src-friendly') || '';
|
||||
if (e.target.id === 'cloneImageModal') {
|
||||
e.target.querySelector('#cloneSrcKey').textContent = srcKey;
|
||||
e.target.querySelector('#cloneSrcFriendly').textContent = srcFriendly;
|
||||
document.getElementById('cloneForm').action = '/images/' + encodeURIComponent(srcKey) + '/clone';
|
||||
} else if (e.target.id === 'deleteImageModal') {
|
||||
e.target.querySelector('#deleteSrcKey').textContent = srcKey;
|
||||
e.target.querySelector('#deleteSrcFriendly').textContent = srcFriendly;
|
||||
document.getElementById('deleteForm').action = '/images/' + encodeURIComponent(srcKey) + '/delete';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,135 +1,138 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Enrollment Packages - PXE Server Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2 class="mb-0">Enrollment Packages</h2>
|
||||
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#uploadModal">
|
||||
Upload Package
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header d-flex align-items-center">
|
||||
GCCH Provisioning Packages
|
||||
<span class="badge bg-secondary ms-2">{{ packages|length }}</span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% if packages %}
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Filename</th>
|
||||
<th>Size</th>
|
||||
<th>Last Modified</th>
|
||||
<th class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for p in packages %}
|
||||
<tr>
|
||||
<td><code>{{ p.filename }}</code></td>
|
||||
<td>{{ "%.1f"|format(p.size / 1048576) }} MB</td>
|
||||
<td>{{ p.modified | timestamp_fmt }}</td>
|
||||
<td class="text-end text-nowrap">
|
||||
<a href="{{ url_for('enrollment_download', filename=p.filename) }}"
|
||||
class="btn btn-sm btn-outline-primary" title="Download">
|
||||
Download
|
||||
</a>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger"
|
||||
data-bs-toggle="modal" data-bs-target="#deleteModal"
|
||||
data-filename="{{ p.filename }}" title="Delete">
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-5">
|
||||
<p class="mt-2">No enrollment packages found. Upload a <code>.ppkg</code> file to get started.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-3">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">About Enrollment Packages</h6>
|
||||
<p class="card-text">
|
||||
GCCH enrollment <code>.ppkg</code> provisioning packages are copied to
|
||||
<code>C:\Enrollment\</code> on the target machine after imaging.
|
||||
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.
|
||||
</p>
|
||||
<p class="card-text mb-0">
|
||||
<strong>Naming convention:</strong> Use <code>with-office.ppkg</code> and
|
||||
<code>without-office.ppkg</code> to match the WinPE enrollment menu options.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upload Modal -->
|
||||
<div class="modal fade" id="uploadModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form action="{{ url_for('enrollment_upload') }}" method="post" enctype="multipart/form-data">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Upload Enrollment Package</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label for="ppkgFile" class="form-label">Provisioning Package (.ppkg)</label>
|
||||
<input type="file" class="form-control" id="ppkgFile" name="ppkg_file"
|
||||
accept=".ppkg" required>
|
||||
<div class="form-text">
|
||||
Use <code>with-office.ppkg</code> or <code>without-office.ppkg</code> to match the WinPE boot menu.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Upload</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div class="modal fade" id="deleteModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form id="deleteForm" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Confirm Delete</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to delete <strong id="deleteFilename"></strong>?</p>
|
||||
<p class="text-muted mb-0">This action cannot be undone.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-danger">Delete</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
document.getElementById('deleteModal').addEventListener('show.bs.modal', function (event) {
|
||||
var btn = event.relatedTarget;
|
||||
var filename = btn.getAttribute('data-filename');
|
||||
document.getElementById('deleteFilename').textContent = filename;
|
||||
document.getElementById('deleteForm').action = '/enrollment/delete/' + encodeURIComponent(filename);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Enrollment Packages - PXE Server Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h2><i class="bi bi-pc-display"></i> Enrollment Packages</h2>
|
||||
<div class="header-actions">
|
||||
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#uploadModal">
|
||||
<i class="bi bi-upload"></i> Upload Package
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span><i class="bi bi-box-seam"></i> GCCH Provisioning Packages</span>
|
||||
<span class="badge badge-secondary">{{ packages|length }}</span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% if packages %}
|
||||
<div class="table-container">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Filename</th>
|
||||
<th>Size</th>
|
||||
<th>Last Modified</th>
|
||||
<th class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for p in packages %}
|
||||
<tr>
|
||||
<td><span class="mono"><i class="bi bi-file-earmark-zip"></i> {{ p.filename }}</span></td>
|
||||
<td>{{ "%.1f"|format(p.size / 1048576) }} MB</td>
|
||||
<td>{{ p.modified | timestamp_fmt }}</td>
|
||||
<td class="actions text-end text-nowrap">
|
||||
<a href="{{ url_for('enrollment_download', filename=p.filename) }}"
|
||||
class="btn btn-sm btn-outline-primary btn-row-action" title="Download">
|
||||
<i class="bi bi-download"></i> Download
|
||||
</a>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger btn-row-action"
|
||||
data-bs-toggle="modal" data-bs-target="#deleteModal"
|
||||
data-filename="{{ p.filename }}" title="Delete">
|
||||
<i class="bi bi-trash"></i> Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-5">
|
||||
<i class="bi bi-inbox" style="font-size: 2.5rem; opacity: 0.5;"></i>
|
||||
<p class="mt-2 mb-0">No enrollment packages found. Upload a <span class="mono">.ppkg</span> file to get started.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pxe-card">
|
||||
<h3 class="pxe-card-title"><i class="bi bi-info-circle"></i> About Enrollment Packages</h3>
|
||||
<p>
|
||||
GCCH enrollment <span class="mono">.ppkg</span> provisioning packages are copied to
|
||||
<span class="mono">C:\Enrollment\</span> on the target machine after imaging.
|
||||
At OOBE, connect to a network with internet, press <strong>Windows key 5 times</strong>,
|
||||
then browse to <span class="mono">C:\Enrollment\</span> and select the package. No USB stick needed.
|
||||
</p>
|
||||
<p class="mb-0">
|
||||
<strong>Naming convention:</strong> Use <span class="mono">with-office.ppkg</span> and
|
||||
<span class="mono">without-office.ppkg</span> to match the WinPE enrollment menu options.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Upload Modal -->
|
||||
<div class="modal fade" id="uploadModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form action="{{ url_for('enrollment_upload') }}" method="post" enctype="multipart/form-data">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Upload Enrollment Package</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-field">
|
||||
<label for="ppkgFile" class="form-label">Provisioning Package (.ppkg)</label>
|
||||
<input type="file" class="form-control" id="ppkgFile" name="ppkg_file"
|
||||
accept=".ppkg" required>
|
||||
<div class="form-text">
|
||||
Use <span class="mono">with-office.ppkg</span> or <span class="mono">without-office.ppkg</span> to match the WinPE boot menu.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary"><i class="bi bi-upload"></i> Upload</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div class="modal fade" id="deleteModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form id="deleteForm" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Confirm Delete</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to delete <strong id="deleteFilename" class="mono"></strong>?</p>
|
||||
<p class="text-muted mb-0">This action cannot be undone.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-danger"><i class="bi bi-trash"></i> Delete</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
document.getElementById('deleteModal').addEventListener('show.bs.modal', function (event) {
|
||||
var btn = event.relatedTarget;
|
||||
var filename = btn.getAttribute('data-filename');
|
||||
document.getElementById('deleteFilename').textContent = filename;
|
||||
document.getElementById('deleteForm').action = '/enrollment/delete/' + encodeURIComponent(filename);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,458 +1,492 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ friendly_name }} - Configuration{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<style>
|
||||
.section-card { margin-bottom: 1.5rem; }
|
||||
.section-card .card-header { padding: 0.6rem 1rem; font-size: 0.95rem; }
|
||||
.badge-disk { font-size: 0.75rem; }
|
||||
.orphan-section { background-color: #fff8e1; }
|
||||
.config-table td, .config-table th { vertical-align: middle; }
|
||||
.config-table .form-control-sm { min-width: 120px; }
|
||||
.text-truncate-cell { max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h2 class="mb-1">{{ friendly_name }}</h2>
|
||||
<small class="text-muted">
|
||||
Image Configuration
|
||||
— OS Selection: <strong>{{ config.os_selection or 'Not set' }}</strong>
|
||||
</small>
|
||||
</div>
|
||||
<a href="{{ url_for('unattend_editor', image_type=image_type) }}" class="btn btn-outline-secondary btn-sm">
|
||||
Edit Unattend
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{# ==================== SECTION 1: Hardware Models ==================== #}
|
||||
<div class="card section-card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span>Hardware Models
|
||||
<span class="badge bg-secondary ms-1">{{ config.hardware_models|length }}</span>
|
||||
</span>
|
||||
<div>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" id="addHwModel">
|
||||
Add
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-success ms-1" id="saveHwModels">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<form method="POST" action="{{ url_for('image_config_save', image_type=image_type) }}" id="hwModelsForm">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="section" value="hardware_models">
|
||||
<input type="hidden" name="payload" id="hwModelsData" value="[]">
|
||||
</form>
|
||||
<table class="table table-sm table-hover mb-0 config-table" id="hwModelsTable">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width:40px">#</th>
|
||||
<th>Model</th>
|
||||
<th>Driver Family ID</th>
|
||||
<th style="width:90px">On Disk</th>
|
||||
<th style="width:60px"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for hm in config.hardware_models %}
|
||||
<tr>
|
||||
<td class="order-num">{{ loop.index }}</td>
|
||||
<td><input type="text" class="form-control form-control-sm" data-field="Model" value="{{ hm.Model }}"></td>
|
||||
<td><input type="text" class="form-control form-control-sm" data-field="Id" value="{{ hm.Id }}"></td>
|
||||
<td>
|
||||
{% if hm._on_disk %}
|
||||
<span class="badge bg-success badge-disk">Yes</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger badge-disk">No</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<button type="button" class="btn btn-outline-danger btn-row-action remove-row">
|
||||
Remove
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% if not config.hardware_models %}
|
||||
<div class="text-center text-muted py-3 empty-message" id="hwModelsEmpty">
|
||||
No hardware models configured.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ==================== SECTION 2: Driver Packs ==================== #}
|
||||
<div class="card section-card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span>Driver Packs
|
||||
<span class="badge bg-secondary ms-1">{{ config.drivers|length }}</span>
|
||||
</span>
|
||||
<div>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary"
|
||||
data-bs-toggle="modal" data-bs-target="#driverUploadModal">
|
||||
Upload .zip
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-success ms-1" id="saveDrivers">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<form method="POST" action="{{ url_for('image_config_save', image_type=image_type) }}" id="driversForm">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="section" value="drivers">
|
||||
<input type="hidden" name="payload" id="driversData" value="[]">
|
||||
</form>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover mb-0 config-table" id="driversTable">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width:40px">#</th>
|
||||
<th>Family</th>
|
||||
<th>Models</th>
|
||||
<th>File Name</th>
|
||||
<th style="width:70px">OS IDs</th>
|
||||
<th style="width:90px">On Disk</th>
|
||||
<th style="width:60px"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for drv in config.drivers %}
|
||||
<tr data-json='{{ drv | tojson }}'>
|
||||
<td class="order-num">{{ loop.index }}</td>
|
||||
<td class="text-truncate-cell" title="{{ drv.family }}">{{ drv.family }}</td>
|
||||
<td class="text-truncate-cell" title="{{ drv.models }}">{{ drv.models }}</td>
|
||||
<td class="text-truncate-cell" title="{{ drv.FileName or drv.get('fileName','') }}">
|
||||
<small>{{ drv.FileName or drv.get('fileName','') }}</small>
|
||||
</td>
|
||||
<td><small>{{ drv.osId }}</small></td>
|
||||
<td>
|
||||
{% if drv._on_disk %}
|
||||
<span class="badge bg-success badge-disk">Yes</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger badge-disk">No</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<button type="button" class="btn btn-outline-danger btn-row-action remove-row">
|
||||
Remove
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% if not config.drivers %}
|
||||
<div class="text-center text-muted py-3 empty-message" id="driversEmpty">
|
||||
No driver packs configured.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Orphan drivers sub-section: zips on disk that aren't referenced by #}
|
||||
{# HardwareDriver.json. Each row has an inline Adopt form (family + dest #}
|
||||
{# inputs -> adds an entry to HardwareDriver.json). Bulk remove deletes #}
|
||||
{# the selected .zip files from Out-of-box Drivers/. #}
|
||||
{% if config.orphan_drivers %}
|
||||
<div class="card-footer orphan-section p-0">
|
||||
<div class="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">
|
||||
<strong>Unregistered Drivers ({{ config.orphan_drivers|length }})</strong>
|
||||
<small class="text-muted ms-1">.zip files on disk, no JSON entry</small>
|
||||
</a>
|
||||
</div>
|
||||
<div class="collapse show" id="orphanDrivers">
|
||||
<form method="POST" action="{{ url_for('images_drivers_orphans_delete', image_type=image_type) }}"
|
||||
onsubmit="return confirm('Delete the selected unregistered driver .zip(s) from disk? Cannot be undone.');">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<table class="table table-sm mb-0 align-middle">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width:30px"><input type="checkbox" id="orphanSelectAll"></th>
|
||||
<th>File Name</th>
|
||||
<th>Relative Path</th>
|
||||
<th style="width:300px">Adopt into HardwareDriver.json</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for orph in config.orphan_drivers %}
|
||||
<tr>
|
||||
<td><input type="checkbox" class="orphan-select" name="filename" value="{{ orph.fileName }}"></td>
|
||||
<td><small>{{ orph.fileName }}</small></td>
|
||||
<td><small class="text-muted">{{ orph.relPath }}</small></td>
|
||||
<td>
|
||||
<form method="POST" action="{{ url_for('images_drivers_adopt', image_type=image_type) }}"
|
||||
class="d-flex gap-1">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="filename" value="{{ orph.fileName }}">
|
||||
<input type="text" class="form-control form-control-sm" name="family"
|
||||
placeholder="family id (e.g. Optiplex_7060)" required style="width:11rem;">
|
||||
<input type="text" class="form-control form-control-sm" name="destination_dir"
|
||||
placeholder="destinationDir" required style="width:11rem;">
|
||||
<button type="submit" class="btn btn-sm btn-success">Adopt</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="px-3 py-2 text-end">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">
|
||||
Delete selected
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Driver upload modal -->
|
||||
<div class="modal fade" id="driverUploadModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<form method="POST" action="{{ url_for('images_drivers_upload', image_type=image_type) }}" enctype="multipart/form-data">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Upload driver .zip</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Driver .zip file</label>
|
||||
<input type="file" class="form-control" name="driver_file" accept=".zip" required>
|
||||
<div class="form-text">Lands in <code>{{ image_type }}/Deploy/Out-of-box Drivers/</code>.</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Family ID (optional)</label>
|
||||
<input type="text" class="form-control font-monospace" name="family"
|
||||
placeholder="Optiplex_7060">
|
||||
<div class="form-text">Matches a HardwareModelSelection.Id. Leave blank to land as orphan + adopt later.</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Destination directory (optional)</label>
|
||||
<input type="text" class="form-control font-monospace" name="destination_dir"
|
||||
placeholder="*destinationdir*\Drivers\Optiplex">
|
||||
<div class="form-text">Where the .zip extracts at deploy time. Required if Family ID is set.</div>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="overwrite" value="1" id="driverOverwriteCheck">
|
||||
<label class="form-check-label" for="driverOverwriteCheck">Overwrite if same filename exists</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Upload</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ==================== SECTION 3: Operating Systems ==================== #}
|
||||
<div class="card section-card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span>Operating Systems
|
||||
<span class="badge bg-secondary ms-1">{{ config.operating_systems|length }}</span>
|
||||
</span>
|
||||
<div>
|
||||
<button type="button" class="btn btn-sm btn-success" id="saveOs">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<form method="POST" action="{{ url_for('image_config_save', image_type=image_type) }}" id="osForm">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="section" value="operating_systems">
|
||||
<input type="hidden" name="payload" id="osData" value="[]">
|
||||
</form>
|
||||
<table class="table table-sm table-hover mb-0 config-table" id="osTable">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width:40px">#</th>
|
||||
<th>Product Name</th>
|
||||
<th>Version</th>
|
||||
<th>Build</th>
|
||||
<th style="width:60px">ID</th>
|
||||
<th style="width:70px">Active</th>
|
||||
<th style="width:90px">WIM On Disk</th>
|
||||
<th style="width:60px"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in config.operating_systems %}
|
||||
{% set osv = entry.operatingSystemVersion %}
|
||||
<tr data-json='{{ entry | tojson }}'>
|
||||
<td class="order-num">{{ loop.index }}</td>
|
||||
<td>{{ osv.productName }}</td>
|
||||
<td>{{ osv.versionNumber }}</td>
|
||||
<td>{{ osv.buildNumber }}</td>
|
||||
<td>{{ osv.id }}</td>
|
||||
<td>
|
||||
{% if osv.isActive %}
|
||||
<span class="badge bg-success badge-disk">Active</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary badge-disk">Inactive</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if entry._on_disk %}
|
||||
<span class="badge bg-success badge-disk">Yes</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger badge-disk">No</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<button type="button" class="btn btn-outline-danger btn-row-action remove-row">
|
||||
Remove
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% if not config.operating_systems %}
|
||||
<div class="text-center text-muted py-3 empty-message" id="osEmpty">
|
||||
No operating systems configured.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ==================== SECTION 4: Packages ==================== #}
|
||||
<div class="card section-card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span>Packages
|
||||
<span class="badge bg-secondary ms-1">{{ config.packages|length }}</span>
|
||||
</span>
|
||||
<div>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary"
|
||||
data-bs-toggle="modal" data-bs-target="#packageUploadModal">
|
||||
Upload file
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-success ms-1" id="savePackages">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<form method="POST" action="{{ url_for('image_config_save', image_type=image_type) }}" id="packagesForm">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="section" value="packages">
|
||||
<input type="hidden" name="payload" id="packagesData" value="[]">
|
||||
</form>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover mb-0 config-table" id="packagesTable">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width:40px">#</th>
|
||||
<th>Name</th>
|
||||
<th>Comment</th>
|
||||
<th>File</th>
|
||||
<th style="width:70px">OS IDs</th>
|
||||
<th style="width:80px">Enabled</th>
|
||||
<th style="width:90px">On Disk</th>
|
||||
<th style="width:60px"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for pkg in config.packages %}
|
||||
<tr data-json='{{ pkg | tojson }}'>
|
||||
<td class="order-num">{{ loop.index }}</td>
|
||||
<td class="text-truncate-cell" title="{{ pkg.name }}"><small>{{ pkg.name }}</small></td>
|
||||
<td class="text-truncate-cell" title="{{ pkg.comment }}"><small>{{ pkg.comment }}</small></td>
|
||||
<td class="text-truncate-cell" title="{{ pkg.fileName or pkg.get('FileName','') }}">
|
||||
<small>{{ pkg.fileName or pkg.get('FileName','') }}</small>
|
||||
</td>
|
||||
<td><small>{{ pkg.osId }}</small></td>
|
||||
<td>
|
||||
{% if pkg.enabled %}
|
||||
<span class="badge bg-success badge-disk">Yes</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary badge-disk">No</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if pkg._on_disk %}
|
||||
<span class="badge bg-success badge-disk">Yes</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger badge-disk">No</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<button type="button" class="btn btn-outline-danger btn-row-action remove-row">
|
||||
Remove
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% if not config.packages %}
|
||||
<div class="text-center text-muted py-3 empty-message" id="packagesEmpty">
|
||||
No packages configured.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Package upload modal -->
|
||||
<div class="modal fade" id="packageUploadModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<form method="POST" action="{{ url_for('images_packages_upload', image_type=image_type) }}" enctype="multipart/form-data">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Upload package</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Package file</label>
|
||||
<input type="file" class="form-control" name="package_file" required>
|
||||
<div class="form-text">Lands in <code>{{ image_type }}/Deploy/Packages/</code>.</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Destination directory (optional)</label>
|
||||
<input type="text" class="form-control font-monospace" name="destination_dir"
|
||||
placeholder="*destinationdir*\Packages">
|
||||
<div class="form-text">Setting this also appends an entry to <code>packages.json</code>.</div>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="overwrite" value="1" id="pkgOverwriteCheck">
|
||||
<label class="form-check-label" for="pkgOverwriteCheck">Overwrite if same filename exists</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Upload</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
// Orphan drivers: select-all checkbox toggles every row's selector.
|
||||
document.addEventListener('click', function(e) {
|
||||
if (e.target && e.target.id === 'orphanSelectAll') {
|
||||
document.querySelectorAll('.orphan-select').forEach(function(cb) {
|
||||
cb.checked = e.target.checked;
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ friendly_name }} - Configuration{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<style>
|
||||
/* image_config-specific tweaks layered on top of pxe-theme.css tokens */
|
||||
.config-table td, .config-table th { vertical-align: middle; }
|
||||
.config-table .form-control-sm { min-width: 120px; }
|
||||
.badge-disk { font-size: 11px; }
|
||||
.text-truncate-cell {
|
||||
max-width: 250px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* Unregistered-drivers strip: warning-tinted footer that reads in both themes */
|
||||
.orphan-section {
|
||||
background: rgba(255, 149, 0, 0.08);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
[data-theme="dark"] .orphan-section { background: rgba(255, 149, 0, 0.10); }
|
||||
.orphan-toggle { color: var(--text); text-decoration: none; }
|
||||
.orphan-toggle:hover { color: var(--primary); }
|
||||
.section-count { background: var(--secondary); }
|
||||
.card-header .header-actions { flex: 0 0 auto; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>{{ friendly_name }}</h1>
|
||||
<div class="eyebrow" style="margin-top:0.35rem;">
|
||||
Image Configuration — OS Selection:
|
||||
<strong>{{ config.os_selection or 'Not set' }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<a href="{{ url_for('unattend_editor', image_type=image_type) }}" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-pencil-square"></i> Edit Unattend
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ==================== SECTION 1: Hardware Models ==================== #}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="d-flex align-items-center gap-2">
|
||||
<i class="bi bi-cpu"></i> Hardware Models
|
||||
<span class="badge section-count">{{ config.hardware_models|length }}</span>
|
||||
</span>
|
||||
<div class="header-actions">
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" id="addHwModel">
|
||||
<i class="bi bi-plus-lg"></i> Add
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-success" id="saveHwModels">
|
||||
<i class="bi bi-check-lg"></i> Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<form method="POST" action="{{ url_for('image_config_save', image_type=image_type) }}" id="hwModelsForm">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="section" value="hardware_models">
|
||||
<input type="hidden" name="payload" id="hwModelsData" value="[]">
|
||||
</form>
|
||||
<div class="table-container">
|
||||
<table class="table table-hover mb-0 config-table" id="hwModelsTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:40px">#</th>
|
||||
<th>Model</th>
|
||||
<th>Driver Family ID</th>
|
||||
<th style="width:90px">On Disk</th>
|
||||
<th style="width:60px"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for hm in config.hardware_models %}
|
||||
<tr>
|
||||
<td class="order-num">{{ loop.index }}</td>
|
||||
<td><input type="text" class="form-control form-control-sm" data-field="Model" value="{{ hm.Model }}"></td>
|
||||
<td><input type="text" class="form-control form-control-sm" data-field="Id" value="{{ hm.Id }}"></td>
|
||||
<td>
|
||||
{% if hm._on_disk %}
|
||||
<span class="badge badge-success badge-disk">Yes</span>
|
||||
{% else %}
|
||||
<span class="badge badge-danger badge-disk">No</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button type="button" class="btn btn-outline-danger btn-row-action remove-row">
|
||||
<i class="bi bi-trash"></i> Remove
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% if not config.hardware_models %}
|
||||
<div class="text-center text-muted py-3 empty-message" id="hwModelsEmpty">
|
||||
No hardware models configured.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ==================== SECTION 2: Driver Packs ==================== #}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="d-flex align-items-center gap-2">
|
||||
<i class="bi bi-hdd-network"></i> Driver Packs
|
||||
<span class="badge section-count">{{ config.drivers|length }}</span>
|
||||
</span>
|
||||
<div class="header-actions">
|
||||
<button type="button" class="btn btn-sm btn-outline-primary"
|
||||
data-bs-toggle="modal" data-bs-target="#driverUploadModal">
|
||||
<i class="bi bi-upload"></i> Upload .zip
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-success" id="saveDrivers">
|
||||
<i class="bi bi-check-lg"></i> Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<form method="POST" action="{{ url_for('image_config_save', image_type=image_type) }}" id="driversForm">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="section" value="drivers">
|
||||
<input type="hidden" name="payload" id="driversData" value="[]">
|
||||
</form>
|
||||
<div class="table-container">
|
||||
<table class="table table-hover mb-0 config-table" id="driversTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:40px">#</th>
|
||||
<th>Family</th>
|
||||
<th>Models</th>
|
||||
<th>File Name</th>
|
||||
<th style="width:70px">OS IDs</th>
|
||||
<th style="width:90px">On Disk</th>
|
||||
<th style="width:60px"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for drv in config.drivers %}
|
||||
<tr data-json='{{ drv | tojson }}'>
|
||||
<td class="order-num">{{ loop.index }}</td>
|
||||
<td class="text-truncate-cell" title="{{ drv.family }}">{{ drv.family }}</td>
|
||||
<td class="text-truncate-cell" title="{{ drv.models }}">{{ drv.models }}</td>
|
||||
<td class="text-truncate-cell mono" title="{{ drv.FileName or drv.get('fileName','') }}">
|
||||
{{ drv.FileName or drv.get('fileName','') }}
|
||||
</td>
|
||||
<td class="mono">{{ drv.osId }}</td>
|
||||
<td>
|
||||
{% if drv._on_disk %}
|
||||
<span class="badge badge-success badge-disk">Yes</span>
|
||||
{% else %}
|
||||
<span class="badge badge-danger badge-disk">No</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button type="button" class="btn btn-outline-danger btn-row-action remove-row">
|
||||
<i class="bi bi-trash"></i> Remove
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% if not config.drivers %}
|
||||
<div class="text-center text-muted py-3 empty-message" id="driversEmpty">
|
||||
No driver packs configured.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Orphan drivers sub-section: zips on disk that aren't referenced by #}
|
||||
{# HardwareDriver.json. Each row has an inline Adopt form (family + dest #}
|
||||
{# inputs -> adds an entry to HardwareDriver.json). Bulk remove deletes #}
|
||||
{# the selected .zip files from Out-of-box Drivers/. #}
|
||||
{% if config.orphan_drivers %}
|
||||
<div class="orphan-section">
|
||||
<div class="px-3 py-2 d-flex justify-content-between align-items-center">
|
||||
<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>
|
||||
<span class="text-muted" style="font-size:0.82rem;">.zip files on disk, no JSON entry</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="collapse show" id="orphanDrivers">
|
||||
<form method="POST" action="{{ url_for('images_drivers_orphans_delete', image_type=image_type) }}"
|
||||
onsubmit="return confirm('Delete the selected unregistered driver .zip(s) from disk? Cannot be undone.');">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="table-container">
|
||||
<table class="table mb-0 align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:30px"><input type="checkbox" id="orphanSelectAll"></th>
|
||||
<th>File Name</th>
|
||||
<th>Relative Path</th>
|
||||
<th style="width:300px">Adopt into HardwareDriver.json</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for orph in config.orphan_drivers %}
|
||||
<tr>
|
||||
<td><input type="checkbox" class="orphan-select" name="filename" value="{{ orph.fileName }}"></td>
|
||||
<td class="mono">{{ orph.fileName }}</td>
|
||||
<td class="mono text-muted">{{ orph.relPath }}</td>
|
||||
<td>
|
||||
{# adopt inputs target standalone adoptForm below - form-in-form is dropped by parser and hijacked the delete form #}
|
||||
<div class="d-flex gap-1">
|
||||
<input type="text" class="form-control form-control-sm" name="family"
|
||||
form="adoptForm{{ loop.index }}"
|
||||
placeholder="family id (e.g. Optiplex_7060)" required style="width:11rem;">
|
||||
<input type="text" class="form-control form-control-sm" name="destination_dir"
|
||||
form="adoptForm{{ loop.index }}"
|
||||
placeholder="destinationDir" required style="width:11rem;">
|
||||
<button type="submit" class="btn btn-sm btn-success" form="adoptForm{{ loop.index }}">Adopt</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="px-3 py-2 text-end">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">
|
||||
<i class="bi bi-trash"></i> Delete selected
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{# one adopt form per orphan row, kept OUTSIDE the delete form #}
|
||||
{% for orph in config.orphan_drivers %}
|
||||
<form method="POST" id="adoptForm{{ loop.index }}"
|
||||
action="{{ url_for('images_drivers_adopt', image_type=image_type) }}">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="filename" value="{{ orph.fileName }}">
|
||||
</form>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Driver upload modal -->
|
||||
<div class="modal fade" id="driverUploadModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<form method="POST" action="{{ url_for('images_drivers_upload', image_type=image_type) }}" enctype="multipart/form-data">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">Upload driver .zip</h3>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-field">
|
||||
<label class="form-label">Driver .zip file</label>
|
||||
<input type="file" class="form-control" name="driver_file" accept=".zip" required>
|
||||
<div class="form-text">Lands in <code>{{ image_type }}/Deploy/Out-of-box Drivers/</code>.</div>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label">Family ID (optional)</label>
|
||||
<input type="text" class="form-control mono" name="family"
|
||||
placeholder="Optiplex_7060">
|
||||
<div class="form-text">Matches a HardwareModelSelection.Id. Leave blank to land as orphan + adopt later.</div>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label">Destination directory (optional)</label>
|
||||
<input type="text" class="form-control mono" name="destination_dir"
|
||||
placeholder="*destinationdir*\Drivers\Optiplex">
|
||||
<div class="form-text">Where the .zip extracts at deploy time. Required if Family ID is set.</div>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="overwrite" value="1" id="driverOverwriteCheck">
|
||||
<label class="form-check-label" for="driverOverwriteCheck">Overwrite if same filename exists</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Upload</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ==================== SECTION 3: Operating Systems ==================== #}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="d-flex align-items-center gap-2">
|
||||
<i class="bi bi-windows"></i> Operating Systems
|
||||
<span class="badge section-count">{{ config.operating_systems|length }}</span>
|
||||
</span>
|
||||
<div class="header-actions">
|
||||
<button type="button" class="btn btn-sm btn-success" id="saveOs">
|
||||
<i class="bi bi-check-lg"></i> Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<form method="POST" action="{{ url_for('image_config_save', image_type=image_type) }}" id="osForm">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="section" value="operating_systems">
|
||||
<input type="hidden" name="payload" id="osData" value="[]">
|
||||
</form>
|
||||
<div class="table-container">
|
||||
<table class="table table-hover mb-0 config-table" id="osTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:40px">#</th>
|
||||
<th>Product Name</th>
|
||||
<th>Version</th>
|
||||
<th>Build</th>
|
||||
<th style="width:60px">ID</th>
|
||||
<th style="width:70px">Active</th>
|
||||
<th style="width:90px">WIM On Disk</th>
|
||||
<th style="width:60px"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in config.operating_systems %}
|
||||
{% set osv = entry.operatingSystemVersion %}
|
||||
<tr data-json='{{ entry | tojson }}'>
|
||||
<td class="order-num">{{ loop.index }}</td>
|
||||
<td>{{ osv.productName }}</td>
|
||||
<td>{{ osv.versionNumber }}</td>
|
||||
<td>{{ osv.buildNumber }}</td>
|
||||
<td class="mono">{{ osv.id }}</td>
|
||||
<td>
|
||||
{% if osv.isActive %}
|
||||
<span class="badge badge-success badge-disk">Active</span>
|
||||
{% else %}
|
||||
<span class="badge badge-secondary badge-disk">Inactive</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if entry._on_disk %}
|
||||
<span class="badge badge-success badge-disk">Yes</span>
|
||||
{% else %}
|
||||
<span class="badge badge-danger badge-disk">No</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button type="button" class="btn btn-outline-danger btn-row-action remove-row">
|
||||
<i class="bi bi-trash"></i> Remove
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% if not config.operating_systems %}
|
||||
<div class="text-center text-muted py-3 empty-message" id="osEmpty">
|
||||
No operating systems configured.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ==================== SECTION 4: Packages ==================== #}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="d-flex align-items-center gap-2">
|
||||
<i class="bi bi-box-seam"></i> Packages
|
||||
<span class="badge section-count">{{ config.packages|length }}</span>
|
||||
</span>
|
||||
<div class="header-actions">
|
||||
<button type="button" class="btn btn-sm btn-outline-primary"
|
||||
data-bs-toggle="modal" data-bs-target="#packageUploadModal">
|
||||
<i class="bi bi-upload"></i> Upload file
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-success" id="savePackages">
|
||||
<i class="bi bi-check-lg"></i> Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<form method="POST" action="{{ url_for('image_config_save', image_type=image_type) }}" id="packagesForm">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="section" value="packages">
|
||||
<input type="hidden" name="payload" id="packagesData" value="[]">
|
||||
</form>
|
||||
<div class="table-container">
|
||||
<table class="table table-hover mb-0 config-table" id="packagesTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:40px">#</th>
|
||||
<th>Name</th>
|
||||
<th>Comment</th>
|
||||
<th>File</th>
|
||||
<th style="width:70px">OS IDs</th>
|
||||
<th style="width:80px">Enabled</th>
|
||||
<th style="width:90px">On Disk</th>
|
||||
<th style="width:60px"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for pkg in config.packages %}
|
||||
<tr data-json='{{ pkg | tojson }}'>
|
||||
<td class="order-num">{{ loop.index }}</td>
|
||||
<td class="text-truncate-cell" title="{{ pkg.name }}">{{ pkg.name }}</td>
|
||||
<td class="text-truncate-cell" title="{{ pkg.comment }}">{{ pkg.comment }}</td>
|
||||
<td class="text-truncate-cell mono" title="{{ pkg.fileName or pkg.get('FileName','') }}">
|
||||
{{ pkg.fileName or pkg.get('FileName','') }}
|
||||
</td>
|
||||
<td class="mono">{{ pkg.osId }}</td>
|
||||
<td>
|
||||
{% if pkg.enabled %}
|
||||
<span class="badge badge-success badge-disk">Yes</span>
|
||||
{% else %}
|
||||
<span class="badge badge-secondary badge-disk">No</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if pkg._on_disk %}
|
||||
<span class="badge badge-success badge-disk">Yes</span>
|
||||
{% else %}
|
||||
<span class="badge badge-danger badge-disk">No</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button type="button" class="btn btn-outline-danger btn-row-action remove-row">
|
||||
<i class="bi bi-trash"></i> Remove
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% if not config.packages %}
|
||||
<div class="text-center text-muted py-3 empty-message" id="packagesEmpty">
|
||||
No packages configured.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Package upload modal -->
|
||||
<div class="modal fade" id="packageUploadModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<form method="POST" action="{{ url_for('images_packages_upload', image_type=image_type) }}" enctype="multipart/form-data">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">Upload package</h3>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-field">
|
||||
<label class="form-label">Package file</label>
|
||||
<input type="file" class="form-control" name="package_file" required>
|
||||
<div class="form-text">Lands in <code>{{ image_type }}/Deploy/Packages/</code>.</div>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label">Destination directory (optional)</label>
|
||||
<input type="text" class="form-control mono" name="destination_dir"
|
||||
placeholder="*destinationdir*\Packages">
|
||||
<div class="form-text">Setting this also appends an entry to <code>packages.json</code>.</div>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="overwrite" value="1" id="pkgOverwriteCheck">
|
||||
<label class="form-check-label" for="pkgOverwriteCheck">Overwrite if same filename exists</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Upload</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
// Orphan drivers: select-all checkbox toggles every row's selector.
|
||||
document.addEventListener('click', function(e) {
|
||||
if (e.target && e.target.id === 'orphanSelectAll') {
|
||||
document.querySelectorAll('.orphan-select').forEach(function(cb) {
|
||||
cb.checked = e.target.checked;
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -18,38 +18,41 @@ function cancelImagingReload() {
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="mb-0">Imaging Progress</h2>
|
||||
<small class="text-muted">Live via SSE (15s polling fallback). Client pushes -> <code>/imaging/status</code>; log-inferred bays in yellow.</small>
|
||||
<h1>Imaging Progress</h1>
|
||||
<small class="text-light">Live via SSE (15s polling fallback). Client pushes → <code class="mono">/imaging/status</code>; log-inferred bays in yellow.</small>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span id="imaging-live-dot" class="status-dot" title="live stream" style="background-color:#6c757d;"></span>
|
||||
<span class="badge bg-secondary fs-6"><span id="visible-count">{{ sessions|length }}</span>/<span id="total-count">{{ sessions|length }}</span></span>
|
||||
<div class="header-actions">
|
||||
<span class="status-indicator">
|
||||
<span id="imaging-live-dot" class="status-dot" title="live stream" style="background-color:var(--secondary);"></span>
|
||||
<span class="badge badge-secondary badge-lg"><span id="visible-count">{{ sessions|length }}</span>/<span id="total-count">{{ sessions|length }}</span></span>
|
||||
</span>
|
||||
{% if sessions %}
|
||||
<form method="post" action="{{ url_for('imaging_delete_all') }}"
|
||||
onsubmit="return confirm('Clear all {{ sessions|length }} imaging session(s)? This wipes every tile from the dashboard. Live re-images will repopulate on next status push.');"
|
||||
style="display:inline;">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">Clear all</button>
|
||||
<button type="submit" class="pxe-btn pxe-btn-danger pxe-btn-sm">
|
||||
<i class="bi bi-trash"></i> Clear all
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="form-field mb-3">
|
||||
<input id="imaging-search" type="search" class="form-control form-control-sm"
|
||||
placeholder="Filter by serial, hostname, pctype, machine#, Intune id, MAC, IP, stage name, stage-N, status, source (client|inferred)"
|
||||
autocomplete="off">
|
||||
</div>
|
||||
|
||||
{% if not sessions %}
|
||||
<div id="imaging-empty" class="card">
|
||||
<div class="card-body text-center text-muted py-5">
|
||||
<p class="mb-1">No imaging sessions yet.</p>
|
||||
<p class="small mb-0">A PC being imaged will post status here, or appear
|
||||
automatically once it touches DHCP / TFTP / boot.wim.</p>
|
||||
</div>
|
||||
<div id="imaging-empty" class="pxe-card text-center text-light" style="padding: 3rem 1.25rem;">
|
||||
<p class="mb-1"><i class="bi bi-hdd-network" style="font-size: 1.75rem; opacity: 0.5;"></i></p>
|
||||
<p class="mb-1">No imaging sessions yet.</p>
|
||||
<p class="small mb-0">A PC being imaged will post status here, or appear
|
||||
automatically once it touches DHCP / TFTP / boot.wim.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -58,10 +61,10 @@ function cancelImagingReload() {
|
||||
</div>
|
||||
|
||||
|
||||
<div class="card mt-3">
|
||||
<div class="card-body small text-muted">
|
||||
<strong>How to push status from an imaging client:</strong>
|
||||
<pre class="mb-0 mt-2">POST http://172.16.9.1:9009/imaging/status
|
||||
<div class="section-card mt-3">
|
||||
<div class="section-title">How to push status from an imaging client</div>
|
||||
<div class="pxe-card-body">
|
||||
<pre class="mono mb-0" style="white-space: pre-wrap;">POST http://172.16.9.1:9009/imaging/status
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
@@ -140,19 +143,19 @@ Content-Type: application/json
|
||||
|
||||
function startSSE() {
|
||||
if (!window.EventSource) {
|
||||
setDot('#ffc107', 'EventSource unsupported - polling only');
|
||||
setDot('var(--warning)', 'EventSource unsupported - polling only');
|
||||
startPolling();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
sse = new EventSource(STREAM_URL);
|
||||
} catch (e) {
|
||||
setDot('#dc3545', 'SSE failed - polling');
|
||||
setDot('var(--danger)', 'SSE failed - polling');
|
||||
startPolling();
|
||||
return;
|
||||
}
|
||||
sse.onopen = function() {
|
||||
setDot('#198754', 'live stream connected');
|
||||
setDot('var(--success)', 'live stream connected');
|
||||
stopPolling();
|
||||
};
|
||||
sse.onmessage = function(ev) {
|
||||
@@ -163,7 +166,7 @@ Content-Type: application/json
|
||||
refreshTiles(false);
|
||||
};
|
||||
sse.onerror = function() {
|
||||
setDot('#dc3545', 'live stream lost - polling fallback');
|
||||
setDot('var(--danger)', 'live stream lost - polling fallback');
|
||||
try { sse.close(); } catch (e) {}
|
||||
sse = null;
|
||||
startPolling();
|
||||
|
||||
@@ -2,23 +2,23 @@
|
||||
{% block title %}{{ session.serial }} - Imaging Session{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="mb-0">{{ session.serial }}
|
||||
{% if session.hostname_target %}<small class="text-muted">{{ session.hostname_target }}</small>{% endif %}
|
||||
<h2>{{ session.serial }}
|
||||
{% if session.hostname_target %}<span class="text-light" style="font-size:14px; font-weight:400;">{{ session.hostname_target }}</span>{% endif %}
|
||||
</h2>
|
||||
<small class="text-muted">
|
||||
{% if session.pctype %}<span class="badge bg-info text-dark">{{ session.pctype }}</span>{% endif %}
|
||||
{% if session.machinenumber %}<span class="badge bg-secondary">#{{ session.machinenumber }}</span>{% endif %}
|
||||
<span class="badge bg-{{ 'success' if session.status == 'succeeded' else ('danger' if session.status == 'failed' else 'primary') }}">{{ session.status or 'in_progress' }}</span>
|
||||
</small>
|
||||
<div style="margin-top:0.5rem; display:flex; gap:0.4rem; flex-wrap:wrap;">
|
||||
{% if session.pctype %}<span class="badge badge-info">{{ session.pctype }}</span>{% endif %}
|
||||
{% if session.machinenumber %}<span class="badge badge-secondary">#{{ session.machinenumber }}</span>{% endif %}
|
||||
<span class="badge badge-{{ 'success' if session.status == 'succeeded' else ('danger' if session.status == 'failed' else 'primary') }}">{{ session.status or 'in_progress' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="header-actions">
|
||||
<a href="{{ url_for('imaging_dashboard') }}" class="btn btn-outline-secondary btn-sm">
|
||||
« Back to dashboard
|
||||
<i class="bi bi-arrow-left"></i> Back to dashboard
|
||||
</a>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm copy-summary-btn">
|
||||
Copy support summary
|
||||
<button type="button" class="btn btn-primary btn-sm copy-summary-btn">
|
||||
<i class="bi bi-clipboard"></i> Copy support summary
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -26,101 +26,97 @@
|
||||
<div class="row">
|
||||
<div class="col-lg-5">
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">Session metadata</div>
|
||||
<div class="card-body">
|
||||
<dl class="row mb-0 small">
|
||||
<dt class="col-4">Serial</dt><dd class="col-8"><code>{{ session.serial }}</code></dd>
|
||||
<dt class="col-4">Hostname</dt><dd class="col-8"><code>{{ session.hostname_target or '-' }}</code></dd>
|
||||
<dt class="col-4">MAC</dt><dd class="col-8"><code>{{ session.mac or '-' }}</code></dd>
|
||||
<dt class="col-4">PC type</dt><dd class="col-8">{{ session.pctype or '-' }}</dd>
|
||||
<dt class="col-4">Machine #</dt><dd class="col-8">{{ session.machinenumber or '-' }}</dd>
|
||||
<dt class="col-4">Intune ID</dt><dd class="col-8">
|
||||
{% if session.intune_device_id %}
|
||||
<code style="word-break: break-all;">{{ session.intune_device_id }}</code>
|
||||
{% else %}-{% endif %}
|
||||
</dd>
|
||||
<dt class="col-4">Started</dt><dd class="col-8"><code>{{ session.started_at or '-' }}</code></dd>
|
||||
<dt class="col-4">Last update</dt><dd class="col-8"><code>{{ session.last_updated or '-' }}</code></dd>
|
||||
<dt class="col-4">Stage</dt><dd class="col-8">
|
||||
{{ session.stage_index or 0 }}/{{ session.stage_total or '?' }}
|
||||
{% if session.current_stage %} - <code class="small">{{ session.current_stage }}</code>{% endif %}
|
||||
</dd>
|
||||
{% if session.previous_run_at %}
|
||||
<dt class="col-4">Prior run</dt><dd class="col-8"><code>{{ session.previous_run_at }}</code></dd>
|
||||
{% endif %}
|
||||
{% if session.error %}
|
||||
<dt class="col-4 text-danger">Error</dt>
|
||||
<dd class="col-8 text-danger">{{ session.error }}</dd>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
<div class="section-card">
|
||||
<div class="section-title">Session metadata</div>
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-4 text-light">Serial</dt><dd class="col-8"><span class="mono">{{ session.serial }}</span></dd>
|
||||
<dt class="col-4 text-light">Hostname</dt><dd class="col-8"><span class="mono">{{ session.hostname_target or '-' }}</span></dd>
|
||||
<dt class="col-4 text-light">MAC</dt><dd class="col-8"><span class="mono">{{ session.mac or '-' }}</span></dd>
|
||||
<dt class="col-4 text-light">PC type</dt><dd class="col-8">{{ session.pctype or '-' }}</dd>
|
||||
<dt class="col-4 text-light">Machine #</dt><dd class="col-8">{{ session.machinenumber or '-' }}</dd>
|
||||
<dt class="col-4 text-light">Intune ID</dt><dd class="col-8">
|
||||
{% if session.intune_device_id %}
|
||||
<span class="mono" style="word-break: break-all;">{{ session.intune_device_id }}</span>
|
||||
{% else %}-{% endif %}
|
||||
</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 text-light">Last update</dt><dd class="col-8"><span class="mono">{{ session.last_updated or '-' }}</span></dd>
|
||||
<dt class="col-4 text-light">Stage</dt><dd class="col-8">
|
||||
{{ session.stage_index or 0 }}/{{ session.stage_total or '?' }}
|
||||
{% if session.current_stage %} - <span class="mono">{{ session.current_stage }}</span>{% endif %}
|
||||
</dd>
|
||||
{% if session.previous_run_at %}
|
||||
<dt class="col-4 text-light">Prior run</dt><dd class="col-8"><span class="mono">{{ session.previous_run_at }}</span></dd>
|
||||
{% endif %}
|
||||
{% if session.error %}
|
||||
<dt class="col-4 text-danger">Error</dt>
|
||||
<dd class="col-8 text-danger">{{ session.error }}</dd>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">Stage timeline</div>
|
||||
<div class="card-body p-0">
|
||||
{% if session.stage_history %}
|
||||
<table class="table table-sm mb-0">
|
||||
<thead class="table-light">
|
||||
<div class="section-card">
|
||||
<div class="section-title">Stage timeline</div>
|
||||
{% if session.stage_history %}
|
||||
<div class="table-container">
|
||||
<table class="data-table mb-0">
|
||||
<thead>
|
||||
<tr><th>Time</th><th>Stage</th><th>Status</th><th>Detail</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for h in session.stage_history %}
|
||||
<tr>
|
||||
<td class="small"><code>{{ h.ts }}</code></td>
|
||||
<td><span class="badge bg-secondary">{{ h.stage_index }}</span></td>
|
||||
<td><span class="mono">{{ h.ts }}</span></td>
|
||||
<td><span class="badge badge-secondary">{{ h.stage_index }}</span></td>
|
||||
<td>
|
||||
<span class="badge bg-{{ 'success' if h.status == 'succeeded' else ('danger' if h.status == 'failed' else 'primary') }}">
|
||||
<span class="badge badge-{{ 'success' if h.status == 'succeeded' else ('danger' if h.status == 'failed' else 'primary') }}">
|
||||
{{ h.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="small"><code>{{ h.current_stage or '' }}</code></td>
|
||||
<td><span class="mono">{{ h.current_stage or '' }}</span></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="p-3 text-muted small">
|
||||
No stage transitions recorded yet. The client only logs a row when
|
||||
stage_index advances or status flips to succeeded/failed.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="status-indicator">
|
||||
<i class="bi bi-info-circle"></i>
|
||||
<span>No stage transitions recorded yet. The client only logs a row when
|
||||
stage_index advances or status flips to succeeded/failed.</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="col-lg-7">
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<div class="section-card">
|
||||
<div class="section-title" style="display:flex; justify-content:space-between; align-items:center;">
|
||||
<span>Full log
|
||||
{% if full_log_truncated %}
|
||||
<span class="badge bg-warning text-dark ms-2">truncated (last 1 MB)</span>
|
||||
<span class="badge badge-warning" style="margin-left:0.5rem;">truncated (last 1 MB)</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary copy-log-btn">Copy</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm copy-log-btn"><i class="bi bi-clipboard"></i> Copy</button>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% if full_log %}
|
||||
<pre id="full-log" class="bg-light p-2 mb-0 small" style="max-height: 36rem; overflow-y: auto; white-space: pre-wrap;">{{ full_log }}</pre>
|
||||
{% else %}
|
||||
<div class="p-3 text-muted small">
|
||||
No log content. The client has not pushed any <code>log_lines</code>
|
||||
entries yet, or the sidecar file was cleared.
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if full_log %}
|
||||
<pre id="full-log" class="mono mb-0" style="max-height: 36rem; overflow: auto; white-space: pre-wrap; background: var(--bg); padding: 0.75rem; border-radius: 0.25rem;">{{ full_log }}</pre>
|
||||
{% else %}
|
||||
<div class="status-indicator">
|
||||
<i class="bi bi-info-circle"></i>
|
||||
<span>No log content. The client has not pushed any <code>log_lines</code>
|
||||
entries yet, or the sidecar file was cleared.</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if session.log_tail %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">Recent tail ({{ session.log_tail | length }} line{{ 's' if session.log_tail | length != 1 }})</div>
|
||||
<div class="card-body p-0">
|
||||
<pre class="bg-light p-2 mb-0 small" style="max-height: 12rem; overflow-y: auto;">{% for line in session.log_tail %}{{ line }}
|
||||
<div class="section-card">
|
||||
<div class="section-title">Recent tail ({{ session.log_tail | length }} line{{ 's' if session.log_tail | length != 1 }})</div>
|
||||
<pre class="mono mb-0" style="max-height: 12rem; overflow: auto; background: var(--bg); padding: 0.75rem; border-radius: 0.25rem;">{% for line in session.log_tail %}{{ line }}
|
||||
{% endfor %}</pre>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -153,15 +149,17 @@ function copyText(text) {
|
||||
}
|
||||
|
||||
document.addEventListener('click', function(e) {
|
||||
if (e.target.classList.contains('copy-log-btn')) {
|
||||
var logBtn = e.target.closest('.copy-log-btn');
|
||||
var summaryBtn = e.target.closest('.copy-summary-btn');
|
||||
if (logBtn) {
|
||||
var pre = document.getElementById('full-log');
|
||||
if (pre) {
|
||||
copyText(pre.textContent).then(function() {
|
||||
e.target.textContent = 'copied!';
|
||||
setTimeout(function() { e.target.textContent = 'Copy'; }, 1200);
|
||||
logBtn.innerHTML = '<i class="bi bi-check2"></i> copied!';
|
||||
setTimeout(function() { logBtn.innerHTML = '<i class="bi bi-clipboard"></i> Copy'; }, 1200);
|
||||
});
|
||||
}
|
||||
} else if (e.target.classList.contains('copy-summary-btn')) {
|
||||
} else if (summaryBtn) {
|
||||
var lines = [];
|
||||
document.querySelectorAll('dl dt').forEach(function(dt) {
|
||||
var dd = dt.nextElementSibling;
|
||||
@@ -182,8 +180,8 @@ document.addEventListener('click', function(e) {
|
||||
});
|
||||
}
|
||||
copyText(lines.join('\n')).then(function() {
|
||||
e.target.textContent = 'copied!';
|
||||
setTimeout(function() { e.target.textContent = 'Copy support summary'; }, 1200);
|
||||
summaryBtn.innerHTML = '<i class="bi bi-check2"></i> copied!';
|
||||
setTimeout(function() { summaryBtn.innerHTML = '<i class="bi bi-clipboard"></i> Copy support summary'; }, 1200);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,192 +1,180 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Image Import - PXE Server Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2 class="mb-4">Image Import</h2>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
|
||||
<!-- Network Upload Import -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
Import from Network Upload
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if upload_sources %}
|
||||
<form method="POST" id="uploadImportForm">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="mb-3">
|
||||
<label for="uploadSource" class="form-label fw-semibold">Source</label>
|
||||
<select class="form-select" name="source" id="uploadSource" required>
|
||||
<option value="">-- Select upload source --</option>
|
||||
{% for src in upload_sources %}
|
||||
<option value="{{ src }}">{{ src }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="form-text">
|
||||
Files uploaded via SMB to <code>\\172.16.9.1\image-upload</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="uploadTarget" class="form-label fw-semibold">Target Image Type</label>
|
||||
<select class="form-select" name="target" id="uploadTarget" required>
|
||||
<option value="">-- Select target image --</option>
|
||||
{% for it in image_types %}
|
||||
<option value="{{ it }}">{{ friendly_names[it] }} ({{ it }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="form-text">
|
||||
Content will be copied into the Deploy directory. Shared resources
|
||||
(Out-of-box Drivers) are stored once and linked across all image types.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info d-flex align-items-start" role="alert">
|
||||
<div>
|
||||
<strong>Shared Drivers:</strong> Out-of-box Drivers are automatically pooled
|
||||
into a shared directory and symlinked for each image type to save disk space.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-warning d-flex align-items-start" role="alert">
|
||||
<div>
|
||||
<strong>Warning:</strong> Existing files in the target Deploy directory with the
|
||||
same names will be overwritten. This operation may take several minutes for large
|
||||
images.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary" id="uploadImportBtn">
|
||||
Start Import
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="text-center py-4">
|
||||
<h5 class="mt-3 text-muted">No Upload Content Found</h5>
|
||||
<p class="text-muted mb-0">
|
||||
Map <code>\\172.16.9.1\image-upload</code> on your Windows PC and copy
|
||||
the Deploy directory contents there.
|
||||
</p>
|
||||
<button class="btn btn-outline-secondary btn-sm mt-3" onclick="location.reload()">
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- USB Import -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
Import from USB Drive
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if usb_mounts %}
|
||||
<form method="POST" id="importForm">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="mb-3">
|
||||
<label for="source" class="form-label fw-semibold">Source (USB Mount Point)</label>
|
||||
<select class="form-select" name="source" id="source" required>
|
||||
<option value="">-- Select a mounted USB drive --</option>
|
||||
{% for mount in usb_mounts %}
|
||||
<option value="{{ mount }}">{{ mount }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="form-text">
|
||||
Select the mounted USB drive containing the WinPE deployment content.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="target" class="form-label fw-semibold">Target Image Type</label>
|
||||
<select class="form-select" name="target" id="target" required>
|
||||
<option value="">-- Select target image --</option>
|
||||
{% for it in image_types %}
|
||||
<option value="{{ it }}">{{ friendly_names[it] }} ({{ it }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="form-text">
|
||||
Content will be copied into the Deploy directory for this image type.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-warning d-flex align-items-start" role="alert">
|
||||
<div>
|
||||
<strong>Warning:</strong> Existing files in the target Deploy directory with the
|
||||
same names will be overwritten. This operation may take several minutes for large
|
||||
images.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary" id="importBtn">
|
||||
Start Import
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="text-center py-4">
|
||||
<h5 class="mt-3 text-muted">No USB Drives Detected</h5>
|
||||
<p class="text-muted mb-0">
|
||||
No mounted USB drives were found under <code>/mnt/</code> or <code>/media/</code>.<br>
|
||||
Mount a USB drive and refresh this page.
|
||||
</p>
|
||||
<button class="btn btn-outline-secondary btn-sm mt-3" onclick="location.reload()">
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
Current Image Status
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Image</th>
|
||||
<th>Content</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for img in images %}
|
||||
<tr>
|
||||
<td class="small">{{ img.friendly_name }}</td>
|
||||
<td>
|
||||
{% if img.has_content %}
|
||||
<span class="badge bg-success">Present</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Empty</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
['importForm', 'uploadImportForm'].forEach(function(formId) {
|
||||
var form = document.getElementById(formId);
|
||||
if (form) {
|
||||
form.addEventListener('submit', function() {
|
||||
var btn = form.querySelector('button[type="submit"]');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span> Importing...';
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Image Import - PXE Server Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1><i class="bi bi-box-arrow-in-down"></i> Image Import</h1>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-lg-8">
|
||||
|
||||
<!-- Network Upload Import -->
|
||||
<div class="section-card">
|
||||
<div class="section-title"><i class="bi bi-hdd-network"></i> Import from Network Upload</div>
|
||||
{% if upload_sources %}
|
||||
<form method="POST" id="uploadImportForm">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="form-field">
|
||||
<label for="uploadSource" class="form-label">Source</label>
|
||||
<select class="form-select" name="source" id="uploadSource" required>
|
||||
<option value="">-- Select upload source --</option>
|
||||
{% for src in upload_sources %}
|
||||
<option value="{{ src }}">{{ src }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="form-text">
|
||||
Files uploaded via SMB to <code class="mono">\\172.16.9.1\image-upload</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label for="uploadTarget" class="form-label">Target Image Type</label>
|
||||
<select class="form-select" name="target" id="uploadTarget" required>
|
||||
<option value="">-- Select target image --</option>
|
||||
{% for it in image_types %}
|
||||
<option value="{{ it }}">{{ friendly_names[it] }} ({{ it }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="form-text">
|
||||
Content will be copied into the Deploy directory. Shared resources
|
||||
(Out-of-box Drivers) are stored once and linked across all image types.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info" role="alert">
|
||||
<strong>Shared Drivers:</strong> Out-of-box Drivers are automatically pooled
|
||||
into a shared directory and symlinked for each image type to save disk space.
|
||||
</div>
|
||||
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<strong>Warning:</strong> Existing files in the target Deploy directory with the
|
||||
same names will be overwritten. This operation may take several minutes for large
|
||||
images.
|
||||
</div>
|
||||
|
||||
<button type="submit" class="pxe-btn pxe-btn-primary" id="uploadImportBtn">
|
||||
<i class="bi bi-play-fill"></i> Start Import
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="text-center py-4">
|
||||
<i class="bi bi-cloud-slash" style="font-size:2rem;color:var(--text-light);"></i>
|
||||
<h5 class="mt-3" style="color:var(--text-light);">No Upload Content Found</h5>
|
||||
<p class="mb-0" style="color:var(--text-light);">
|
||||
Map <code class="mono">\\172.16.9.1\image-upload</code> on your Windows PC and copy
|
||||
the Deploy directory contents there.
|
||||
</p>
|
||||
<button class="pxe-btn pxe-btn-ghost pxe-btn-sm mt-3" onclick="location.reload()">
|
||||
<i class="bi bi-arrow-clockwise"></i> Refresh
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- USB Import -->
|
||||
<div class="section-card">
|
||||
<div class="section-title"><i class="bi bi-usb-drive"></i> Import from USB Drive</div>
|
||||
{% if usb_mounts %}
|
||||
<form method="POST" id="importForm">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="form-field">
|
||||
<label for="source" class="form-label">Source (USB Mount Point)</label>
|
||||
<select class="form-select" name="source" id="source" required>
|
||||
<option value="">-- Select a mounted USB drive --</option>
|
||||
{% for mount in usb_mounts %}
|
||||
<option value="{{ mount }}">{{ mount }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="form-text">
|
||||
Select the mounted USB drive containing the WinPE deployment content.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label for="target" class="form-label">Target Image Type</label>
|
||||
<select class="form-select" name="target" id="target" required>
|
||||
<option value="">-- Select target image --</option>
|
||||
{% for it in image_types %}
|
||||
<option value="{{ it }}">{{ friendly_names[it] }} ({{ it }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="form-text">
|
||||
Content will be copied into the Deploy directory for this image type.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-warning" role="alert">
|
||||
<strong>Warning:</strong> Existing files in the target Deploy directory with the
|
||||
same names will be overwritten. This operation may take several minutes for large
|
||||
images.
|
||||
</div>
|
||||
|
||||
<button type="submit" class="pxe-btn pxe-btn-primary" id="importBtn">
|
||||
<i class="bi bi-play-fill"></i> Start Import
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="text-center py-4">
|
||||
<i class="bi bi-usb-drive" style="font-size:2rem;color:var(--text-light);"></i>
|
||||
<h5 class="mt-3" style="color:var(--text-light);">No USB Drives Detected</h5>
|
||||
<p class="mb-0" style="color:var(--text-light);">
|
||||
No mounted USB drives were found under <code class="mono">/mnt/</code> or <code class="mono">/media/</code>.<br>
|
||||
Mount a USB drive and refresh this page.
|
||||
</p>
|
||||
<button class="pxe-btn pxe-btn-ghost pxe-btn-sm mt-3" onclick="location.reload()">
|
||||
<i class="bi bi-arrow-clockwise"></i> Refresh
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4">
|
||||
<div class="section-card">
|
||||
<div class="section-title"><i class="bi bi-list-check"></i> Current Image Status</div>
|
||||
<div class="table-container">
|
||||
<table class="data-table mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Image</th>
|
||||
<th>Content</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for img in images %}
|
||||
<tr>
|
||||
<td>{{ img.friendly_name }}</td>
|
||||
<td>
|
||||
{% if img.has_content %}
|
||||
<span class="badge badge-success">Present</span>
|
||||
{% else %}
|
||||
<span class="badge badge-secondary">Empty</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
['importForm', 'uploadImportForm'].forEach(function(formId) {
|
||||
var form = document.getElementById(formId);
|
||||
if (form) {
|
||||
form.addEventListener('submit', function() {
|
||||
var btn = form.querySelector('button[type="submit"]');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span> Importing...';
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -24,86 +24,77 @@
|
||||
|
||||
{% 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;">← All reports</a>
|
||||
</div>
|
||||
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<div class="text-muted small mb-1">
|
||||
<a href="{{ url_for('blancco_reports') }}" class="text-decoration-none">← All reports</a>
|
||||
</div>
|
||||
<h2 class="mb-0">Drive Erasure Certificate</h2>
|
||||
<code class="small text-muted">{{ filename }}</code>
|
||||
<h2>Drive Erasure Certificate</h2>
|
||||
<code class="mono" style="color:var(--text-light);">{{ filename }}</code>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<div class="header-actions">
|
||||
<a href="{{ url_for('blancco_download_report', filename=filename) }}"
|
||||
class="btn btn-sm btn-outline-primary">Download XML</a>
|
||||
class="pxe-btn pxe-btn-ghost">
|
||||
<i class="bi bi-download"></i> Download XML
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Hero summary: state badge + standard + drive count + erasure datetime #}
|
||||
{% set first = (data.erasures or [{}])[0] %}
|
||||
{% set overall_state = first.state or 'Unknown' %}
|
||||
<div class="card border-{{ 'success' if overall_state == 'Successful' else 'danger' if overall_state else 'secondary' }} mb-4">
|
||||
<div class="card-body">
|
||||
<div class="row g-3 align-items-center">
|
||||
<div class="col-md-3">
|
||||
<div class="text-muted small">Overall result</div>
|
||||
<div class="fs-3">
|
||||
<span class="badge bg-{{ 'success' if overall_state == 'Successful' else 'danger' if overall_state else 'secondary' }}">
|
||||
{{ overall_state }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="text-muted small">Erasure standard</div>
|
||||
<div>{{ first.erasure_standard_name or '-' }}</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div class="text-muted small">Drives erased</div>
|
||||
<div class="fs-5">{{ data.erasures | length }}</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div class="text-muted small">Elapsed time</div>
|
||||
<div class="fs-5"><code>{{ first.elapsed_time or '-' }}</code></div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div class="text-muted small">Erased at</div>
|
||||
<div><code class="small">{{ first.timestamp or meta.date or '-' }}</code></div>
|
||||
</div>
|
||||
{% set overall_tone = 'success' if overall_state == 'Successful' else 'danger' if overall_state else 'secondary' %}
|
||||
{% set overall_stat = 'success' if overall_state == 'Successful' else 'danger' if overall_state else '' %}
|
||||
<div class="dashboard-grid mb-4">
|
||||
<div class="stat-card {{ overall_stat }}">
|
||||
<div class="label">Overall result</div>
|
||||
<div class="value" style="font-size:1.5rem;">
|
||||
<span class="badge badge-lg bg-{{ overall_tone }}">{{ overall_state }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Erasure standard</div>
|
||||
<div class="value" style="font-size:1.05rem;">{{ first.erasure_standard_name or '-' }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Drives erased</div>
|
||||
<div class="value">{{ data.erasures | length }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Elapsed time</div>
|
||||
<div class="value mono" style="font-size:1.5rem;">{{ first.elapsed_time or '-' }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Erased at</div>
|
||||
<div class="value mono" style="font-size:1rem;">{{ first.timestamp or meta.date or '-' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">Customer</div>
|
||||
<div class="card-body">
|
||||
<dl class="row mb-0 small">
|
||||
<dt class="col-sm-5">Business name</dt><dd class="col-sm-7">{{ company.business_name or '-' }}</dd>
|
||||
<dt class="col-sm-5">Location</dt> <dd class="col-sm-7">{{ company.business_location or '-' }}</dd>
|
||||
<dt class="col-sm-5">License holder</dt><dd class="col-sm-7">{{ company.customer_license or '-' }}</dd>
|
||||
{% for k, v in company.items() %}
|
||||
{% if k not in ['business_name','business_location','customer_license'] %}
|
||||
<dt class="col-sm-5">{{ k|replace('_',' ')|title }}</dt><dd class="col-sm-7">{{ v }}</dd>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
<div class="content-grid mb-3">
|
||||
<div class="section-card">
|
||||
<div class="section-title">Customer</div>
|
||||
<dl class="row mb-0 small">
|
||||
<dt class="col-sm-5">Business name</dt><dd class="col-sm-7">{{ company.business_name or '-' }}</dd>
|
||||
<dt class="col-sm-5">Location</dt> <dd class="col-sm-7">{{ company.business_location or '-' }}</dd>
|
||||
<dt class="col-sm-5">License holder</dt><dd class="col-sm-7">{{ company.customer_license or '-' }}</dd>
|
||||
{% for k, v in company.items() %}
|
||||
{% if k not in ['business_name','business_location','customer_license'] %}
|
||||
<dt class="col-sm-5">{{ k|replace('_',' ')|title }}</dt><dd class="col-sm-7">{{ v }}</dd>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</dl>
|
||||
</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">
|
||||
<dt class="col-sm-5">Manufacturer</dt> <dd class="col-sm-7">{{ sysinfo.manufacturer or '-' }}</dd>
|
||||
<dt class="col-sm-5">Model</dt> <dd class="col-sm-7">{{ sysinfo.model or '-' }}</dd>
|
||||
<dt class="col-sm-5">Serial</dt> <dd class="col-sm-7"><code>{{ sysinfo.serial or '-' }}</code></dd>
|
||||
<dt class="col-sm-5">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 vendor</dt> <dd class="col-sm-7">{{ bios.vendor or '-' }} {{ bios.version or '' }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-card">
|
||||
<div class="section-title">System</div>
|
||||
<dl class="row mb-0 small">
|
||||
<dt class="col-sm-5">Manufacturer</dt> <dd class="col-sm-7">{{ sysinfo.manufacturer or '-' }}</dd>
|
||||
<dt class="col-sm-5">Model</dt> <dd class="col-sm-7">{{ sysinfo.model or '-' }}</dd>
|
||||
<dt class="col-sm-5">Serial</dt> <dd class="col-sm-7"><code class="mono">{{ sysinfo.serial or '-' }}</code></dd>
|
||||
<dt class="col-sm-5">Chassis</dt> <dd class="col-sm-7">{{ sysinfo.chassis_type or '-' }}</dd>
|
||||
<dt class="col-sm-5">BIOS mode</dt> <dd class="col-sm-7">{{ sysinfo.bios_mode or '-' }} {% if sysinfo.secure_boot_state %}<span class="badge badge-secondary">SecureBoot {{ sysinfo.secure_boot_state }}</span>{% endif %}</dd>
|
||||
<dt class="col-sm-5">BIOS vendor</dt> <dd class="col-sm-7">{{ bios.vendor or '-' }} {{ bios.version or '' }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -111,48 +102,48 @@
|
||||
{% for er in data.erasures %}
|
||||
{% set t = er.target or {} %}
|
||||
{% set state = er.state or 'Unknown' %}
|
||||
{% set border = 'success' if state == 'Successful' else 'danger' if state else 'secondary' %}
|
||||
{% set tone = 'success' if state == 'Successful' else 'danger' if state else 'secondary' %}
|
||||
{% set steps = (er.steps or {}).step %}
|
||||
{% if steps is not iterable or steps is string or steps is mapping %}
|
||||
{% set steps = [steps] if steps else [] %}
|
||||
{% endif %}
|
||||
<div class="card border-{{ border }} mb-3">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<strong>Drive #{{ er.erasure_id or loop.index }}:</strong>
|
||||
{{ t.vendor or '' }} {{ t.model or '(unknown drive)' }}
|
||||
<code class="ms-2 small text-muted">{{ t.serial or '-' }}</code>
|
||||
<code class="mono ms-2" style="color:var(--text-light);">{{ t.serial or '-' }}</code>
|
||||
</div>
|
||||
<span class="badge bg-{{ border }}">{{ state }}</span>
|
||||
<span class="badge bg-{{ tone }}">{{ state }}</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-3">
|
||||
<div class="text-muted small">Interface</div>
|
||||
<div class="eyebrow">Interface</div>
|
||||
<div>{{ t.interface_type or '-' }}</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="text-muted small">Capacity</div>
|
||||
<div class="eyebrow">Capacity</div>
|
||||
<div>{{ hbytes(t.capacity or 0) }}</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div class="text-muted small">Health</div>
|
||||
<div class="eyebrow">Health</div>
|
||||
<div>{{ t.health or '-' }}</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div class="text-muted small">Errors</div>
|
||||
<div class="eyebrow">Errors</div>
|
||||
<div>{{ er.total_errors or 0 }}</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<div class="text-muted small">Sectors</div>
|
||||
<div class="eyebrow">Sectors</div>
|
||||
<div class="small">{{ er.processed_sectors or '-' }} / {{ t.sectors or '-' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if steps %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<div class="table-container">
|
||||
<table class="table mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:3rem;">#</th>
|
||||
<th>Step</th>
|
||||
@@ -167,13 +158,13 @@
|
||||
<tr>
|
||||
<td>{{ s.number or loop.index }}</td>
|
||||
<td>{{ s.type or '-' }}</td>
|
||||
<td class="small text-muted">{{ s.pattern or '-' }}</td>
|
||||
<td class="small" style="color:var(--text-light);">{{ s.pattern or '-' }}</td>
|
||||
<td>
|
||||
<span class="badge bg-{{ 'success' if s.state == 'completed' else 'danger' if s.state else 'secondary' }}">
|
||||
{{ s.state or '-' }}
|
||||
</span>
|
||||
</td>
|
||||
<td><code class="small">{{ s.elapsed_time or '-' }}</code></td>
|
||||
<td><code class="mono">{{ s.elapsed_time or '-' }}</code></td>
|
||||
<td>{{ s.errors or 0 }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
@@ -185,27 +176,27 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card">
|
||||
<div class="card-header">Document signing & provenance</div>
|
||||
<div class="card-body">
|
||||
<dl class="row mb-0 small">
|
||||
<dt class="col-sm-3">Document ID</dt>
|
||||
<dd class="col-sm-9"><code class="text-break">{{ meta.document_id or '-' }}</code></dd>
|
||||
<dd class="col-sm-9"><code class="mono text-break">{{ meta.document_id or '-' }}</code></dd>
|
||||
<dt class="col-sm-3">Product</dt>
|
||||
<dd class="col-sm-9">{{ meta.product_name }} {{ meta.product_version }} (rev {{ meta.product_revision }})</dd>
|
||||
<dt class="col-sm-3">Generated</dt>
|
||||
<dd class="col-sm-9"><code>{{ meta.date or '-' }}</code></dd>
|
||||
<dd class="col-sm-9"><code class="mono">{{ meta.date or '-' }}</code></dd>
|
||||
{% if data.license_consumption_ids %}
|
||||
<dt class="col-sm-3">License consumption ID{{ 's' if data.license_consumption_ids|length > 1 }}</dt>
|
||||
<dd class="col-sm-9">
|
||||
{% for lc in data.license_consumption_ids %}<code class="d-block">{{ lc }}</code>{% endfor %}
|
||||
{% for lc in data.license_consumption_ids %}<code class="mono d-block">{{ lc }}</code>{% endfor %}
|
||||
</dd>
|
||||
{% endif %}
|
||||
<dt class="col-sm-3">Integrity hash</dt>
|
||||
<dd class="col-sm-9">
|
||||
<details>
|
||||
<summary class="text-muted">Show base64 signature ({{ meta.integrity|length if meta.integrity else 0 }} chars)</summary>
|
||||
<code class="text-break small d-block mt-2">{{ meta.integrity }}</code>
|
||||
<summary style="color:var(--text-light);cursor:pointer;">Show base64 signature ({{ meta.integrity|length if meta.integrity else 0 }} chars)</summary>
|
||||
<code class="mono text-break d-block mt-2">{{ meta.integrity }}</code>
|
||||
</details>
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
@@ -1,114 +1,113 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Blancco Reports - PXE Server Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2 class="mb-0">Blancco Erasure Reports</h2>
|
||||
<span class="badge bg-secondary fs-6">{{ reports|length }} report{{ 's' if reports|length != 1 }}</span>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header d-flex align-items-center">
|
||||
Drive Erasure Certificates
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
{% if reports %}
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Serial</th>
|
||||
<th>Model</th>
|
||||
<th>Date</th>
|
||||
<th>Result</th>
|
||||
<th class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in reports %}
|
||||
<tr title="{{ r.filename }}">
|
||||
<td>{% if r.serial %}<code>{{ r.serial }}</code>{% else %}<span class="text-muted small">-</span>{% endif %}</td>
|
||||
<td class="small">{{ r.model or '-' }}</td>
|
||||
<td>{{ r.modified | timestamp_fmt }}</td>
|
||||
<td>
|
||||
{% if r.state == 'Successful' %}<span class="badge bg-success">Successful</span>
|
||||
{% elif r.state == 'Failed' %}<span class="badge bg-danger">Failed</span>
|
||||
{% elif r.state %}<span class="badge bg-secondary">{{ r.state }}</span>
|
||||
{% else %}<span class="text-muted small">-</span>{% endif %}
|
||||
</td>
|
||||
<td class="text-end text-nowrap">
|
||||
{% if r.filename.lower().endswith('.xml') %}
|
||||
<a href="{{ url_for('blancco_view_report', filename=r.filename) }}"
|
||||
class="btn btn-sm btn-outline-success" title="View formatted report">
|
||||
View
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('blancco_download_report', filename=r.filename) }}"
|
||||
class="btn btn-sm btn-outline-primary" title="Download">
|
||||
Download
|
||||
</a>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger"
|
||||
data-bs-toggle="modal" data-bs-target="#deleteModal"
|
||||
data-filename="{{ r.filename }}" title="Delete">
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-5">
|
||||
<p class="mt-2">No erasure reports yet.</p>
|
||||
<p class="small">Reports will appear here after Blancco Drive Eraser completes a wipe.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-3">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">Report Storage</h6>
|
||||
<p class="card-text mb-1">
|
||||
Blancco Drive Eraser saves erasure certificates to the network share
|
||||
<code>\\172.16.9.1\blancco-reports</code>.
|
||||
</p>
|
||||
<p class="card-text mb-0 text-muted">
|
||||
Reports are generated automatically after each drive wipe and contain proof of erasure for compliance and audit purposes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div class="modal fade" id="deleteModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form id="deleteForm" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Confirm Delete</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to delete <strong id="deleteFilename"></strong>?</p>
|
||||
<p class="text-muted mb-0">Erasure reports may be needed for compliance audits. This action cannot be undone.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-danger">Delete</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
document.getElementById('deleteModal').addEventListener('show.bs.modal', function (event) {
|
||||
var btn = event.relatedTarget;
|
||||
var filename = btn.getAttribute('data-filename');
|
||||
document.getElementById('deleteFilename').textContent = filename;
|
||||
document.getElementById('deleteForm').action = '/reports/delete/' + encodeURIComponent(filename);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Blancco Reports - PXE Server Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h2><i class="bi bi-shield-check"></i> Blancco Erasure Reports</h2>
|
||||
<div class="header-actions">
|
||||
<span class="badge badge-secondary badge-lg">{{ reports|length }} report{{ 's' if reports|length != 1 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<div class="section-title">Drive Erasure Certificates</div>
|
||||
{% if reports %}
|
||||
<div class="table-container">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Serial</th>
|
||||
<th>Model</th>
|
||||
<th>Date</th>
|
||||
<th>Result</th>
|
||||
<th class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in reports %}
|
||||
<tr title="{{ r.filename }}">
|
||||
<td>{% if r.serial %}<span class="mono">{{ r.serial }}</span>{% else %}<span class="text-muted">-</span>{% endif %}</td>
|
||||
<td>{{ r.model or '-' }}</td>
|
||||
<td>{{ r.modified | timestamp_fmt }}</td>
|
||||
<td>
|
||||
{% if r.state == 'Successful' %}<span class="badge badge-success">Successful</span>
|
||||
{% elif r.state == 'Failed' %}<span class="badge badge-danger">Failed</span>
|
||||
{% elif r.state %}<span class="badge badge-secondary">{{ r.state }}</span>
|
||||
{% else %}<span class="text-muted">-</span>{% endif %}
|
||||
</td>
|
||||
<td class="actions text-end text-nowrap">
|
||||
{% if r.filename.lower().endswith('.xml') %}
|
||||
<a href="{{ url_for('blancco_view_report', filename=r.filename) }}"
|
||||
class="pxe-btn pxe-btn-success btn-row-action" title="View formatted report">
|
||||
<i class="bi bi-eye"></i> View
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('blancco_download_report', filename=r.filename) }}"
|
||||
class="pxe-btn pxe-btn-secondary btn-row-action" title="Download">
|
||||
<i class="bi bi-download"></i> Download
|
||||
</a>
|
||||
<button type="button" class="pxe-btn pxe-btn-danger btn-row-action"
|
||||
data-bs-toggle="modal" data-bs-target="#deleteModal"
|
||||
data-filename="{{ r.filename }}" title="Delete">
|
||||
<i class="bi bi-trash"></i> Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center text-muted py-5">
|
||||
<p class="mt-2"><i class="bi bi-inbox" style="font-size:1.75rem;"></i></p>
|
||||
<p class="mb-1">No erasure reports yet.</p>
|
||||
<p class="small">Reports will appear here after Blancco Drive Eraser completes a wipe.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="pxe-card mt-3">
|
||||
<h3 class="pxe-card-title"><i class="bi bi-hdd-network"></i> Report Storage</h3>
|
||||
<p class="mb-1">
|
||||
Blancco Drive Eraser saves erasure certificates to the network share
|
||||
<span class="mono">\\172.16.9.1\blancco-reports</span>.
|
||||
</p>
|
||||
<p class="mb-0 text-muted">
|
||||
Reports are generated automatically after each drive wipe and contain proof of erasure for compliance and audit purposes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div class="modal fade" id="deleteModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form id="deleteForm" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Confirm Delete</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to delete <strong id="deleteFilename"></strong>?</p>
|
||||
<p class="text-muted mb-0">Erasure reports may be needed for compliance audits. This action cannot be undone.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="pxe-btn pxe-btn-ghost" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="pxe-btn pxe-btn-danger">Delete</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
document.getElementById('deleteModal').addEventListener('show.bs.modal', function (event) {
|
||||
var btn = event.relatedTarget;
|
||||
var filename = btn.getAttribute('data-filename');
|
||||
document.getElementById('deleteFilename').textContent = filename;
|
||||
document.getElementById('deleteForm').action = '/reports/delete/' + encodeURIComponent(filename);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,129 +1,151 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}startnet.cmd Editor - PXE Server Manager{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<style>
|
||||
.cmd-editor {
|
||||
font-family: 'Consolas', 'Courier New', monospace;
|
||||
font-size: 0.9rem;
|
||||
min-height: 600px;
|
||||
height: 70vh;
|
||||
resize: vertical;
|
||||
background-color: #1e1e1e;
|
||||
color: #d4d4d4;
|
||||
border: 1px solid #333;
|
||||
padding: 1rem;
|
||||
tab-size: 4;
|
||||
white-space: pre;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.cmd-editor:focus {
|
||||
outline: none;
|
||||
border-color: #0d6efd;
|
||||
box-shadow: 0 0 0 0.15rem rgba(13,110,253,.25);
|
||||
}
|
||||
.wim-info dt {
|
||||
font-weight: 600;
|
||||
color: #6c757d;
|
||||
}
|
||||
.wim-info dd {
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2 class="mb-0">startnet.cmd Editor</h2>
|
||||
</div>
|
||||
|
||||
{% if not wim_exists %}
|
||||
<div class="alert alert-warning">
|
||||
<strong>boot.wim not found</strong> at <code>{{ wim_path }}</code>.
|
||||
Run the PXE server setup playbook and import WinPE boot files first.
|
||||
</div>
|
||||
{% else %}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-9">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span>Windows\System32\startnet.cmd</span>
|
||||
<span class="badge bg-secondary">boot.wim</span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<form action="{{ url_for('startnet_save') }}" method="post" id="startnetForm">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<textarea name="content" class="form-control cmd-editor" id="cmdEditor"
|
||||
spellcheck="false">{{ content }}</textarea>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-between align-items-center">
|
||||
<small class="text-muted">
|
||||
Editing the startnet.cmd inside <code>{{ wim_path }}</code>
|
||||
</small>
|
||||
<button type="submit" form="startnetForm" class="btn btn-primary">
|
||||
Save to boot.wim
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-3">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">Common startnet.cmd Commands</h6>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<code class="d-block mb-1">wpeinit</code>
|
||||
<small class="text-muted d-block mb-2">Initialize WinPE networking</small>
|
||||
<code class="d-block mb-1">net use Z: \\172.16.9.1\winpeapps</code>
|
||||
<small class="text-muted d-block mb-2">Map Samba share for deployment</small>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<code class="d-block mb-1">wpeutil WaitForNetwork</code>
|
||||
<small class="text-muted d-block mb-2">Wait for network to be ready</small>
|
||||
<code class="d-block mb-1">Z:\gea-standard\Deploy\Tools\deploy.cmd</code>
|
||||
<small class="text-muted d-block mb-2">Launch deployment script</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-3">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
WIM Info
|
||||
</div>
|
||||
<div class="card-body wim-info">
|
||||
<dl class="mb-0">
|
||||
{% for key, val in wim_info.items() %}
|
||||
{% if key in ['Image Count', 'Compression', 'Total Bytes', 'Image Name', 'Image Description'] %}
|
||||
<dt>{{ key }}</dt>
|
||||
<dd>{{ val }}</dd>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if not wim_info %}
|
||||
<p class="text-muted mb-0">Could not read WIM info.</p>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
// Tab key inserts a tab in the editor instead of moving focus
|
||||
document.getElementById('cmdEditor')?.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
var start = this.selectionStart;
|
||||
var end = this.selectionEnd;
|
||||
this.value = this.value.substring(0, start) + '\t' + this.value.substring(end);
|
||||
this.selectionStart = this.selectionEnd = start + 1;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% extends "base.html" %}
|
||||
{% block title %}startnet.cmd Editor - PXE Server Manager{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<style>
|
||||
.cmd-editor {
|
||||
font-family: 'Consolas', 'Courier New', monospace;
|
||||
font-size: 0.9rem;
|
||||
min-height: 600px;
|
||||
height: 70vh;
|
||||
resize: vertical;
|
||||
width: 100%;
|
||||
display: block;
|
||||
background-color: #1e1e1e;
|
||||
color: #d4d4d4;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.25rem;
|
||||
padding: 1rem;
|
||||
tab-size: 4;
|
||||
white-space: pre;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.cmd-editor:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 0.15rem rgba(65, 129, 255, 0.25);
|
||||
}
|
||||
.wim-info dl { margin: 0; }
|
||||
.wim-info dt {
|
||||
font-weight: 600;
|
||||
color: var(--text-light);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.wim-info dd {
|
||||
margin: 0 0 0.65rem 0;
|
||||
color: var(--text);
|
||||
}
|
||||
.cmd-ref-item { margin-bottom: 0.85rem; }
|
||||
.cmd-ref-item code {
|
||||
display: block;
|
||||
margin-bottom: 0.2rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
.cmd-ref-item small { color: var(--text-light); }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h2>startnet.cmd Editor</h2>
|
||||
{% if wim_exists %}
|
||||
<div class="header-actions">
|
||||
<button type="submit" form="startnetForm" class="pxe-btn pxe-btn-primary">
|
||||
<i class="bi bi-save"></i> Save to boot.wim
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if not wim_exists %}
|
||||
<div class="alert alert-warning">
|
||||
<strong>boot.wim not found</strong> at <code>{{ wim_path }}</code>.
|
||||
Run the PXE server setup playbook and import WinPE boot files first.
|
||||
</div>
|
||||
{% else %}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-9">
|
||||
<div class="pxe-card">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<span class="mono"><i class="bi bi-terminal"></i> Windows\System32\startnet.cmd</span>
|
||||
<span class="badge badge-secondary">boot.wim</span>
|
||||
</div>
|
||||
<form action="{{ url_for('startnet_save') }}" method="post" id="startnetForm">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||
<textarea name="content" class="cmd-editor" id="cmdEditor"
|
||||
spellcheck="false">{{ content }}</textarea>
|
||||
</form>
|
||||
<div class="d-flex justify-content-between align-items-center mt-3">
|
||||
<small class="text-muted">
|
||||
Editing the startnet.cmd inside <code>{{ wim_path }}</code>
|
||||
</small>
|
||||
<button type="submit" form="startnetForm" class="pxe-btn pxe-btn-primary">
|
||||
<i class="bi bi-save"></i> Save to boot.wim
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<div class="section-title">Common startnet.cmd Commands</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="cmd-ref-item">
|
||||
<code>wpeinit</code>
|
||||
<small>Initialize WinPE networking</small>
|
||||
</div>
|
||||
<div class="cmd-ref-item">
|
||||
<code>net use Z: \\172.16.9.1\winpeapps</code>
|
||||
<small>Map Samba share for deployment</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="cmd-ref-item">
|
||||
<code>wpeutil WaitForNetwork</code>
|
||||
<small>Wait for network to be ready</small>
|
||||
</div>
|
||||
<div class="cmd-ref-item">
|
||||
<code>Z:\gea-standard\Deploy\Tools\deploy.cmd</code>
|
||||
<small>Launch deployment script</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-3">
|
||||
<div class="section-card wim-info">
|
||||
<div class="section-title">WIM Info</div>
|
||||
<dl>
|
||||
{% for key, val in wim_info.items() %}
|
||||
{% if key in ['Image Count', 'Compression', 'Total Bytes', 'Image Name', 'Image Description'] %}
|
||||
<dt>{{ key }}</dt>
|
||||
<dd>{{ val }}</dd>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if not wim_info %}
|
||||
<p class="text-muted mb-0">Could not read WIM info.</p>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script>
|
||||
// Tab key inserts a tab in the editor instead of moving focus
|
||||
document.getElementById('cmdEditor')?.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
var start = this.selectionStart;
|
||||
var end = this.selectionEnd;
|
||||
this.value = this.value.substring(0, start) + '\t' + this.value.substring(end);
|
||||
this.selectionStart = this.selectionEnd = start + 1;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user