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.
493 lines
20 KiB
HTML
493 lines
20 KiB
HTML
{% 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 %}
|