Files
pxe-server/webapp/templates/image_config.html
cproudlock 18db077475 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.
2026-07-23 10:05:09 -04:00

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 &mdash; OS Selection:
<strong>{{ config.os_selection or 'Not set' }}</strong>
</div>
</div>
<div class="header-actions">
<a href="{{ url_for('unattend_editor', image_type=image_type) }}" class="btn btn-outline-secondary btn-sm">
<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 %}