Files
pxe-server/webapp/templates/dashboard.html
cproudlock 69a1682a7f webapp: imaging UX overhaul + image management CRUD
Imaging dashboard
- services/imaging_log_tail.py: parses dnsmasq leases, Apache access log,
  Samba per-host log files, and dnsmasq syslog (DHCP/TFTP). Synthesizes
  inferred sessions keyed by MAC for bays that have only touched the boot
  chain but not yet pushed to /imaging/status. Active window 90 min.
- imaging_status.list_sessions() merges inferred sessions into the dashboard
  list. Real client-pushed sessions win for the same MAC.
- imaging_status: stage_history field tracks every stage transition (capped
  30); sidecar .log file per serial records every log_lines push uncapped
  (read_full_log() caps detail-page response to 1 MB).
- delete_session/delete_all_sessions clean up sidecar .log too.
- New SSE endpoint /imaging/stream emits a session-list hash every 5s.
  Client fetches /imaging/tiles (HTML partial) on hash change and swaps
  #imaging-tiles innerHTML. Polling fallback at 15s if SSE drops.
- Tile-swap preserves scroll, filter input, expanded state via localStorage,
  and any LAPS input the operator is mid-pasting (swap skipped when a
  laps-input is focused).
- imaging.html: removed 15s location.reload(). Added live-status dot in
  header (gray idle / green SSE connected / red SSE lost).
- _imaging_tiles.html: shared partial used by both /imaging full render and
  /imaging/tiles SSE refresh. Inferred bays render with yellow border +
  log-inferred badge + no progress bar (stage_index inference is coarse).
- imaging_detail.html (new): per-bay forensics page at /imaging/session/
  <serial>. Session metadata grid, stage timeline table, full sidecar log
  with truncation indicator, Copy-support-summary button. Linked from each
  client-pushed tile.
- qr-render.js exposes window.renderAllQRs() so the SSE swap can re-render
  Intune device-ID QRs in the swapped-in tiles.

Image management
- services/image_registry.py: JSON registry of image types at
  {SAMBA_SHARE}/image-registry.json. Bootstraps from baked-in
  config.IMAGE_TYPES on first run. create/clone/delete/rename_friendly
  mutate the file then call reload() which rewrites config.IMAGE_TYPES +
  config.FRIENDLY_NAMES in place. Sidebar reflects on next request.
- app.py routes: /images/new, /images/<t>/clone, /images/<t>/delete (with
  optional content-wipe checkbox), /images/<t>/rename.
- dashboard.html: + New image type button + Clone/Delete per row, all in
  Bootstrap modals with confirmation copy.
- Clone copies Deploy/ tree but preserves symlinks to shared dirs (Out-of-
  box Drivers, Operating Systems, Packages) so disk usage stays low.
- Delete with content checked unlinks symlinks (does not follow into shared
  dirs).

Driver / package upload + orphan adoption
- services/images.py: upload_driver, adopt_orphan, remove_orphans,
  upload_package. Filename sanitization blocks path traversal.
- app.py routes: /images/<t>/drivers/upload, /images/<t>/drivers/adopt,
  /images/<t>/drivers/orphans/delete, /images/<t>/packages/upload.
- image_config.html: Upload .zip button + modal on Drivers section. Orphan
  drivers card-footer rebuilt as interactive list with per-row Adopt inline
  form (family + destinationDir inputs) and bulk select+delete.
- Upload .zip on Packages section with optional destinationDir field that
  appends a packages.json entry.

Configuration
- config.py: new env vars DNSMASQ_LEASES, APACHE_ACCESS_LOG, SAMBA_LOG_DIR,
  DNSMASQ_SYSLOG for the log-tailer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 13:21:06 -04:00

242 lines
9.4 KiB
HTML

{% 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 &lsquo;<src> (copy)&rsquo;">
</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 %}