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.
279 lines
11 KiB
HTML
279 lines
11 KiB
HTML
{% 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 %}
|