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:
cproudlock
2026-07-23 10:05:09 -04:00
parent 8df30017aa
commit 18db077475
15 changed files with 3446 additions and 2412 deletions

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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>

View File

@@ -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 &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 %}
{% 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 &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> <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 %}

View File

@@ -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 %}

View File

@@ -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
&mdash; 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 &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 %}

View File

@@ -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 &rarr; <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();

View File

@@ -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">
&laquo; 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);
});
}
});

View File

@@ -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 %}

View File

@@ -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;">&larr; 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">&larr; 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 &amp; 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>

View File

@@ -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 %}

View File

@@ -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