Files
pxe-server/webapp/templates/startnet_editor.html
cproudlock 3880151e71 webapp: shopfloor sub-menu tab + nav rename (the edits dropped by a failed git add)
The prior 'nest shopfloor menu as a tab' commit only captured the shopfloor_menu.html deletion - the git add also named the just-removed file, which errored the add and staged nothing else. This commits the actual integration that was already deployed to preview and prod: startnet_editor GET passes shopfloor_items/shopfloor_available, the Shopfloor Sub-Menu tab pane + sf-prefixed JS in startnet_editor.html, /shopfloor-menu redirects into the tab, and the sidebar 'startnet.cmd' -> 'Boot Menu' rename.
2026-07-23 13:58:08 -04:00

821 lines
33 KiB
HTML

{% extends "base.html" %}
{% block title %}startnet.cmd Editor - PXE Server Manager{% endblock %}
{% block extra_head %}
<style>
/* ---- shared editor typography (keep pre + textarea metrics identical) ---- */
.stn-mono {
font-family: 'SF Mono', 'Consolas', 'Courier New', monospace;
font-size: 13px;
line-height: 21px;
letter-spacing: normal;
tab-size: 4;
-moz-tab-size: 4;
}
/* ---- Raw tab: gutter + highlight overlay + textarea ---- */
.raw-editor-wrap {
display: flex;
border: 1px solid var(--border);
border-radius: 0.25rem;
background: #1e1e1e;
overflow: hidden;
height: 62vh;
min-height: 460px;
}
.raw-gutter {
flex: 0 0 auto;
width: 3.25rem;
padding: 1rem 0.5rem 1rem 0;
text-align: right;
color: #6a737d;
background: #191919;
border-right: 1px solid #2a2a2a;
overflow: hidden;
user-select: none;
white-space: pre;
}
.raw-code {
position: relative;
flex: 1 1 auto;
overflow: hidden;
}
.raw-highlight,
.raw-textarea {
margin: 0;
padding: 1rem;
border: 0;
white-space: pre;
word-wrap: normal;
overflow: auto;
width: 100%;
height: 100%;
}
.raw-highlight {
position: absolute;
inset: 0;
color: #d4d4d4;
background: transparent;
pointer-events: none;
overflow: hidden;
}
.raw-textarea {
position: relative;
display: block;
resize: none;
background: transparent;
color: #d4d4d4; /* visible if JS/overlay never activates (fallback) */
caret-color: #d4d4d4;
}
/* only once the overlay is proven live do we hide the textarea's own glyphs */
.raw-editor-wrap.overlay-live .raw-textarea { color: transparent; }
.raw-textarea:focus { outline: none; }
.raw-textarea::selection { background: rgba(65, 129, 255, 0.35); }
/* batch token colors (VS Code-ish dark) */
.tok-comment { color: #6a9955; font-style: italic; }
.tok-label { color: #dcdcaa; font-weight: 600; }
.tok-keyword { color: #569cd6; }
.tok-var { color: #4ec9b0; }
.tok-string { color: #ce9178; }
/* ---- lint panel ---- */
.lint-list { list-style: none; margin: 0; padding: 0; }
.lint-list li {
display: flex;
gap: 0.5rem;
align-items: baseline;
padding: 0.4rem 0.2rem;
border-bottom: 1px solid var(--border);
font-size: 13px;
}
.lint-list li:last-child { border-bottom: 0; }
.lint-line {
flex: 0 0 auto;
font-family: 'SF Mono', 'Consolas', monospace;
font-size: 11px;
color: var(--text-light);
min-width: 3rem;
}
/* ---- diff viewer ---- */
.diff-view {
margin: 0;
padding: 1rem;
background: #1e1e1e;
color: #d4d4d4;
border-radius: 0.25rem;
max-height: 55vh;
overflow: auto;
white-space: pre;
font-family: 'SF Mono', 'Consolas', monospace;
font-size: 12.5px;
line-height: 1.5;
}
.diff-add { color: #6a9955; }
.diff-del { color: #f14c4c; }
.diff-hunk { color: #569cd6; }
.diff-meta { color: #808080; }
.diff-identical {
padding: 2rem;
text-align: center;
color: var(--success);
}
/* ---- misc ---- */
.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); }
.tab-content-pad { padding-top: 1.25rem; }
.menu-num-cell { font-variant-numeric: tabular-nums; color: var(--text-light); width: 2.5rem; }
.menu-move-btns { display: inline-flex; gap: 0.2rem; }
.field-hint { font-size: 12px; color: var(--text-light); margin-top: 0.25rem; }
.stn-modal { max-width: min(900px, 96vw); }
</style>
{% endblock %}
{% block content %}
<div class="page-header">
<h1>startnet.cmd Editor</h1>
{% if wim_exists %}
<div class="header-actions">
<span class="badge badge-secondary"><i class="bi bi-hdd"></i> boot.wim</span>
</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>
{% if snapshots %}
<div class="section-card">
<div class="section-title">Snapshots</div>
<p class="text-light">boot.wim is missing, but prior startnet.cmd snapshots are still on disk. Restore is unavailable until the WIM is present.</p>
</div>
{% endif %}
{% else %}
<ul class="nav nav-tabs editor-tabs" id="stnTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="tab-settings-btn" data-bs-toggle="tab" data-bs-target="#tab-settings" type="button" role="tab">
<i class="bi bi-sliders"></i> Settings
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tab-menu-btn" data-bs-toggle="tab" data-bs-target="#tab-menu" type="button" role="tab">
<i class="bi bi-list-ol"></i> Boot Menu
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tab-shopfloor-btn" data-bs-toggle="tab" data-bs-target="#tab-shopfloor" type="button" role="tab">
<i class="bi bi-diagram-3"></i> Shopfloor Sub-Menu
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tab-raw-btn" data-bs-toggle="tab" data-bs-target="#tab-raw" type="button" role="tab">
<i class="bi bi-code-slash"></i> Raw
{% if lint.errors %}<span class="badge badge-danger ms-1">{{ lint.errors|length }}</span>
{% elif lint.warnings %}<span class="badge badge-warning ms-1">{{ lint.warnings|length }}</span>{% endif %}
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tab-history-btn" data-bs-toggle="tab" data-bs-target="#tab-history" type="button" role="tab">
<i class="bi bi-clock-history"></i> History
{% if snapshots %}<span class="badge badge-secondary ms-1">{{ snapshots|length }}</span>{% endif %}
</button>
</li>
</ul>
<div class="tab-content tab-content-pad">
<!-- =============================== SETTINGS =============================== -->
<div class="tab-pane fade show active" id="tab-settings" role="tabpanel">
<div class="row">
<div class="col-lg-7">
<div class="pxe-card">
<h3 class="pxe-card-title">Boot Settings</h3>
<p class="text-light" style="font-size:13px;">
These edit only their targeted lines in startnet.cmd. Leave a field
<strong>blank to keep</strong> the current value. Everything round-trips
through the same boot.wim as the Raw tab.
</p>
<form action="{{ url_for('startnet_save_settings') }}" method="post">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="form-field">
<label for="setServerIp">PXE / Deployment Server IP</label>
<input type="text" class="form-control stn-mono" id="setServerIp" name="server_ip"
value="{{ settings.server_ip if settings.server_ip is not none else '' }}"
placeholder="e.g. 172.16.9.1">
<div class="field-hint">Address startnet.cmd maps the winpeapps share from.</div>
</div>
<div class="form-field">
<label for="setTimeout">Menu Timeout (seconds)</label>
<input type="number" min="0" class="form-control" id="setTimeout" name="menu_timeout"
value="{{ settings.menu_timeout if settings.menu_timeout is not none else '' }}"
placeholder="e.g. 30">
<div class="field-hint">Whole seconds before the default menu entry auto-selects.</div>
</div>
<div class="form-field">
<label for="setDefault">Default Menu Entry</label>
<input type="text" class="form-control" id="setDefault" name="menu_default"
value="{{ settings.menu_default if settings.menu_default is not none else '' }}"
placeholder="e.g. 1">
<div class="field-hint">The entry chosen when the timeout elapses.</div>
</div>
<button type="submit" class="pxe-btn pxe-btn-primary">
<i class="bi bi-save"></i> Apply &amp; Save to boot.wim
</button>
</form>
</div>
</div>
<div class="col-lg-5">
<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-light mb-0">Could not read WIM info.</p>
{% endif %}
</dl>
</div>
</div>
</div>
</div>
<!-- =============================== BOOT MENU ============================== -->
<div class="tab-pane fade" id="tab-menu" role="tabpanel">
<div class="pxe-card">
<div class="d-flex justify-content-between align-items-center mb-2">
<h3 class="pxe-card-title mb-0">Boot Menu Entries</h3>
<button type="button" class="pxe-btn pxe-btn-secondary pxe-btn-sm" id="menuAddBtn">
<i class="bi bi-plus-lg"></i> Add Entry
</button>
</div>
<div class="alert alert-info" style="font-size:13px;">
<i class="bi bi-info-circle"></i>
<strong>Appending</strong> a new image at the end always works. Reordering or
removing an entry whose number the enrollment <code>%choice%</code> router
cross-references may be <strong>refused by the server</strong> to keep numbering
in sync - if so, the raw text is left untouched and the reason is flashed.
</div>
<form action="{{ url_for('startnet_save_menu') }}" method="post" id="menuForm">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="items" id="menuItemsData" value="[]">
<div class="table-container">
<table class="data-table" id="menuTable" style="white-space:normal;">
<thead>
<tr>
<th style="width:2.5rem;">#</th>
<th>Label</th>
<th>Target</th>
<th>Target Image</th>
<th style="width:9rem;">Actions</th>
</tr>
</thead>
<tbody id="menuBody"><!-- rows injected by JS --></tbody>
</table>
</div>
<div id="menuEmpty" class="text-light" style="padding:1rem 0; display:none;">
No menu entries parsed. Use the Raw tab, or add an entry above.
</div>
<div class="mt-3">
<button type="submit" class="pxe-btn pxe-btn-primary">
<i class="bi bi-save"></i> Save Menu to boot.wim
</button>
</div>
</form>
</div>
</div>
<!-- ============================ SHOPFLOOR SUB-MENU ======================== -->
<div class="tab-pane fade" id="tab-shopfloor" role="tabpanel">
<div class="card">
<div class="alert alert-info">
The <strong>GEA Shopfloor</strong> choice drills into this data-driven sub-menu
(<span class="stn-mono">menu.json</span> on the enrollment share). Reorder / rename / hide / add entries -
no boot.wim edit. Each <strong>PC-type</strong> must be an existing
<span class="stn-mono">shopfloor-setup/gea-shopfloor-*</span> handler (dropdown); WinPE falls back to the
baked-in menu if the share/picker is unavailable.
</div>
<div style="margin-bottom:0.75rem;">
<button type="button" class="pxe-btn pxe-btn-secondary pxe-btn-sm" id="sfAddBtn"><i class="bi bi-plus-lg"></i> Add item</button>
<button type="submit" form="sfForm" class="pxe-btn pxe-btn-primary pxe-btn-sm"><i class="bi bi-save"></i> Save shopfloor menu</button>
</div>
<form action="{{ url_for('shopfloor_menu') }}" method="post" id="sfForm">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="items" id="sfItemsData" value="[]">
<div class="table-container">
<table class="data-table" id="sfTable" style="white-space:normal;">
<thead>
<tr><th style="width:3rem;">#</th><th>Label</th><th>PC-type (key)</th><th>Hint</th><th style="width:4rem;">Shown</th><th style="width:7rem;">Order</th><th style="width:3rem;"></th></tr>
</thead>
<tbody id="sfBody"><!-- rows injected by JS --></tbody>
</table>
</div>
</form>
</div>
</div>
<!-- ================================= RAW ================================= -->
<div class="tab-pane fade" id="tab-raw" role="tabpanel">
<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>
<div class="d-flex gap-2">
<button type="button" class="pxe-btn pxe-btn-ghost pxe-btn-sm" id="rawDiffBtn">
<i class="bi bi-file-earmark-diff"></i> Diff vs current
</button>
<button type="submit" form="startnetForm" class="pxe-btn pxe-btn-primary pxe-btn-sm">
<i class="bi bi-save"></i> Save to boot.wim
</button>
</div>
</div>
<form action="{{ url_for('startnet_save') }}" method="post" id="startnetForm">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="raw-editor-wrap" id="rawWrap">
<div class="raw-gutter stn-mono" id="rawGutter" aria-hidden="true">1</div>
<div class="raw-code">
<pre class="raw-highlight stn-mono" id="rawHighlight" aria-hidden="true"></pre>
<textarea name="content" class="raw-textarea stn-mono" id="cmdEditor"
spellcheck="false" autocomplete="off" autocapitalize="off"
wrap="off">{{ content }}</textarea>
</div>
</div>
</form>
<div class="d-flex justify-content-between align-items-center mt-3">
<small class="text-light">Editing 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">
<div class="section-title">
Lint
{% if lint.errors %}<span class="badge badge-danger">{{ lint.errors|length }} err</span>{% endif %}
{% if lint.warnings %}<span class="badge badge-warning">{{ lint.warnings|length }} warn</span>{% endif %}
</div>
{% if not lint.errors and not lint.warnings %}
<p class="text-light mb-0"><i class="bi bi-check-circle text-success"></i> No issues found.</p>
{% else %}
<ul class="lint-list">
{% for e in lint.errors %}
<li>
<span class="lint-line">{{ ('L' ~ e.line) if e.line is not none else '--' }}</span>
<span><i class="bi bi-x-octagon text-danger"></i> {{ e.message }}</span>
</li>
{% endfor %}
{% for w in lint.warnings %}
<li>
<span class="lint-line">{{ ('L' ~ w.line) if w.line is not none else '--' }}</span>
<span><i class="bi bi-exclamation-triangle text-warning"></i> {{ w.message }}</span>
</li>
{% endfor %}
</ul>
{% endif %}
</div>
<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-light mb-0">Could not read WIM info.</p>
{% endif %}
</dl>
</div>
</div>
</div>
</div>
<!-- =============================== HISTORY =============================== -->
<div class="tab-pane fade" id="tab-history" role="tabpanel">
<div class="pxe-card">
<h3 class="pxe-card-title">Snapshots</h3>
<p class="text-light" style="font-size:13px;">
Every save snapshots the prior startnet.cmd first. Diff a snapshot against the
current boot.wim, or restore it (restoring also snapshots the current copy first).
</p>
{% if not snapshots %}
<p class="text-light mb-0">No snapshots yet.</p>
{% else %}
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>Timestamp</th>
<th>Size</th>
<th>Note</th>
<th style="width:14rem;">Actions</th>
</tr>
</thead>
<tbody>
{% for s in snapshots %}
<tr>
<td class="mono">{{ s.timestamp }}</td>
<td>{{ '%.1f'|format(s.size / 1024) }} KB</td>
<td>{{ s.note or '-' }}</td>
<td class="actions">
<button type="button" class="pxe-btn pxe-btn-ghost pxe-btn-sm snap-diff-btn"
data-snapshot-id="{{ s.id }}">
<i class="bi bi-file-earmark-diff"></i> Diff
</button>
<form action="{{ url_for('startnet_restore_snapshot') }}" method="post" class="d-inline"
onsubmit="return confirm('Restore this snapshot to boot.wim? The current startnet.cmd is snapshotted first.');">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="snapshot_id" value="{{ s.id }}">
<button type="submit" class="pxe-btn pxe-btn-secondary pxe-btn-sm">
<i class="bi bi-arrow-counterclockwise"></i> Restore
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div>
</div>
</div>
<!-- ============================= DIFF MODAL ============================= -->
<div class="modal fade" id="diffModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered stn-modal">
<div class="pxe-modal">
<div class="modal-header">
<h3 id="diffModalTitle">Diff</h3>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div id="diffModalBody"><div class="text-light" style="padding:1rem;">Loading...</div></div>
</div>
<div class="modal-footer">
<button type="button" class="pxe-btn pxe-btn-ghost" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
{% endif %}
{% endblock %}
{% block extra_scripts %}
{% if wim_exists %}
<script>
(function () {
"use strict";
var CSRF = (document.querySelector('meta[name="csrf-token"]') || {}).content || '';
var DIFF_URL = "{{ url_for('startnet_diff') }}";
// ------------------------------------------------------------------ escape
function esc(s) {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// ---------------------------------------------------- batch highlighter
// Per-line so comments/labels never leak across lines. Operates on ESCAPED
// text and only ever wraps whole matched tokens, so it cannot corrupt input.
var KW = /\b(echo|set|setlocal|endlocal|if|else|for|in|do|goto|call|exit|pause|cls|start|del|copy|xcopy|robocopy|md|mkdir|rd|rmdir|cd|pushd|popd|choice|timeout|ping|reg|net|use|wpeinit|wpeutil|ver|title|color|shutdown|wmic|diskpart|dism|bcdboot|drvload|not|exist|defined|errorlevel|equ|neq|gtr|geq|lss|leq)\b/gi;
function hlLine(raw) {
var line = esc(raw);
var trimmed = line.replace(/^\s+/, '');
// whole-line comment (rem, ::, @rem)
if (/^(@?rem\b|::)/i.test(trimmed)) {
return '<span class="tok-comment">' + line + '</span>';
}
// label line ( :name )
if (/^:[^:\s]/.test(trimmed)) {
return '<span class="tok-label">' + line + '</span>';
}
// token pass: strings first, then %vars%, then leading keyword-ish words
line = line.replace(/&quot;[^&]*&quot;|"[^"]*"/g, function (m) {
return '<span class="tok-string">' + m + '</span>';
});
line = line.replace(/%[^%\s]+%|%%?[a-zA-Z]\b|![^!\s]+!/g, function (m) {
return '<span class="tok-var">' + m + '</span>';
});
line = line.replace(KW, function (m) {
return '<span class="tok-keyword">' + m + '</span>';
});
return line;
}
var wrap = document.getElementById('rawWrap');
var ta = document.getElementById('cmdEditor');
var hl = document.getElementById('rawHighlight');
var gutter = document.getElementById('rawGutter');
function renderHighlight() {
if (!ta || !hl) return;
var text = ta.value;
var lines = text.split('\n');
var out = [];
for (var i = 0; i < lines.length; i++) out.push(hlLine(lines[i]));
// trailing newline guard so the overlay height tracks the textarea
hl.innerHTML = out.join('\n') + '\n';
}
function renderGutter() {
if (!ta || !gutter) return;
var n = ta.value.split('\n').length;
var buf = [];
for (var i = 1; i <= n; i++) buf.push(i);
gutter.textContent = buf.join('\n');
}
function syncScroll() {
if (hl) { hl.scrollTop = ta.scrollTop; hl.scrollLeft = ta.scrollLeft; }
if (gutter) { gutter.scrollTop = ta.scrollTop; }
}
if (ta) {
try {
renderHighlight();
renderGutter();
syncScroll();
// Only hide the textarea glyphs once the overlay is confirmed built.
if (wrap && hl.innerHTML) wrap.classList.add('overlay-live');
ta.addEventListener('input', function () {
renderHighlight();
renderGutter();
syncScroll();
});
ta.addEventListener('scroll', syncScroll);
// Tab key inserts a real tab instead of moving focus.
ta.addEventListener('keydown', function (e) {
if (e.key === 'Tab') {
e.preventDefault();
var start = this.selectionStart, end = this.selectionEnd;
this.value = this.value.substring(0, start) + '\t' + this.value.substring(end);
this.selectionStart = this.selectionEnd = start + 1;
renderHighlight(); renderGutter(); syncScroll();
}
});
} catch (err) {
// Highlight is a nicety; on any failure fall back to the plain textarea.
if (wrap) wrap.classList.remove('overlay-live');
}
}
// ------------------------------------------------------------- diff modal
var diffModalEl = document.getElementById('diffModal');
var diffModal = diffModalEl && window.bootstrap ? new bootstrap.Modal(diffModalEl) : null;
function renderDiff(diffText) {
var body = document.getElementById('diffModalBody');
if (!diffText) {
body.innerHTML = '<div class="diff-identical"><i class="bi bi-check-circle"></i> ' +
'No differences - identical to the current boot.wim.</div>';
return;
}
var lines = diffText.split('\n');
var html = '';
for (var i = 0; i < lines.length; i++) {
var ln = lines[i], cls = '';
if (/^\+\+\+|^---/.test(ln)) cls = 'diff-meta';
else if (ln.charAt(0) === '@') cls = 'diff-hunk';
else if (ln.charAt(0) === '+') cls = 'diff-add';
else if (ln.charAt(0) === '-') cls = 'diff-del';
html += '<span class="' + cls + '">' + esc(ln) + '</span>\n';
}
body.innerHTML = '<pre class="diff-view">' + html + '</pre>';
}
function openDiff(title, payload) {
var body = document.getElementById('diffModalBody');
document.getElementById('diffModalTitle').textContent = title;
body.innerHTML = '<div class="text-light" style="padding:1rem;">Loading diff...</div>';
if (diffModal) diffModal.show();
fetch(DIFF_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': CSRF },
body: JSON.stringify(payload)
}).then(function (r) {
return r.json().then(function (j) { return { ok: r.ok, j: j }; });
}).then(function (res) {
if (!res.ok) {
body.innerHTML = '<div class="alert alert-danger mb-0">' +
esc(res.j.error || 'Diff failed.') + '</div>';
return;
}
renderDiff(res.j.diff || '');
}).catch(function () {
body.innerHTML = '<div class="alert alert-danger mb-0">Could not reach the diff endpoint.</div>';
});
}
var rawDiffBtn = document.getElementById('rawDiffBtn');
if (rawDiffBtn) {
rawDiffBtn.addEventListener('click', function () {
openDiff('Pending edit vs current boot.wim', { content: ta.value });
});
}
document.querySelectorAll('.snap-diff-btn').forEach(function (btn) {
btn.addEventListener('click', function () {
var id = btn.getAttribute('data-snapshot-id');
openDiff('Snapshot ' + id + ' vs current', { snapshot_id: id });
});
});
// -------------------------------------------------------- boot-menu editor
var MENU = {{ boot_menu | tojson }};
var IMAGE_TYPES = {{ image_types | tojson }};
var body = document.getElementById('menuBody');
var emptyMsg = document.getElementById('menuEmpty');
var form = document.getElementById('menuForm');
var hidden = document.getElementById('menuItemsData');
function optionsFor(selected) {
var opts = '';
var found = false;
for (var i = 0; i < IMAGE_TYPES.length; i++) {
var v = IMAGE_TYPES[i];
if (v === selected) found = true;
opts += '<option value="' + esc(v) + '"' + (v === selected ? ' selected' : '') + '>' + esc(v) + '</option>';
}
// preserve an existing target_image that is not in the current image list
if (selected && !found) {
opts = '<option value="' + esc(selected) + '" selected>' + esc(selected) + ' (unlisted)</option>' + opts;
}
opts = '<option value="">-- none --</option>' + opts;
return opts;
}
function rowHtml(item, idx, total) {
return '' +
'<td class="menu-num-cell">' + (idx + 1) + '</td>' +
'<td><input type="text" class="form-control form-control-sm m-label" value="' + esc(item.label || '') + '" placeholder="Menu label"></td>' +
'<td><input type="text" class="form-control form-control-sm m-target" value="' + esc(item.target || '') + '" placeholder="Target (goto/call)"></td>' +
'<td><select class="form-select form-control-sm m-image">' + optionsFor(item.target_image || '') + '</select></td>' +
'<td class="actions">' +
'<span class="menu-move-btns">' +
'<button type="button" class="pxe-btn pxe-btn-ghost pxe-btn-sm m-up" ' + (idx === 0 ? 'disabled' : '') + ' title="Move up"><i class="bi bi-arrow-up"></i></button>' +
'<button type="button" class="pxe-btn pxe-btn-ghost pxe-btn-sm m-down" ' + (idx === total - 1 ? 'disabled' : '') + ' title="Move down"><i class="bi bi-arrow-down"></i></button>' +
'<button type="button" class="pxe-btn pxe-btn-ghost pxe-btn-sm m-del" title="Remove"><i class="bi bi-trash text-danger"></i></button>' +
'</span>' +
'</td>';
}
// Read the live DOM back into an array (so in-progress typing is captured).
function collect() {
var items = [];
body.querySelectorAll('tr').forEach(function (tr) {
items.push({
label: (tr.querySelector('.m-label') || {}).value || '',
target: (tr.querySelector('.m-target') || {}).value || '',
target_image: (tr.querySelector('.m-image') || {}).value || ''
});
});
return items;
}
function render(items) {
body.innerHTML = '';
for (var i = 0; i < items.length; i++) {
var tr = document.createElement('tr');
tr.innerHTML = rowHtml(items[i], i, items.length);
body.appendChild(tr);
}
if (emptyMsg) emptyMsg.style.display = items.length ? 'none' : 'block';
}
if (body) {
render(MENU.map(function (m) {
return { label: m.label, target: m.target, target_image: m.target_image };
}));
body.addEventListener('click', function (e) {
var btn = e.target.closest('button');
if (!btn) return;
var tr = btn.closest('tr');
var rows = Array.prototype.slice.call(body.querySelectorAll('tr'));
var idx = rows.indexOf(tr);
var items = collect();
if (btn.classList.contains('m-up') && idx > 0) {
var t = items[idx - 1]; items[idx - 1] = items[idx]; items[idx] = t;
render(items);
} else if (btn.classList.contains('m-down') && idx < items.length - 1) {
var d = items[idx + 1]; items[idx + 1] = items[idx]; items[idx] = d;
render(items);
} else if (btn.classList.contains('m-del')) {
items.splice(idx, 1);
render(items);
}
});
var addBtn = document.getElementById('menuAddBtn');
if (addBtn) {
addBtn.addEventListener('click', function () {
var items = collect();
items.push({ label: '', target: '', target_image: '' });
render(items);
});
}
if (form) {
form.addEventListener('submit', function () {
hidden.value = JSON.stringify(collect());
});
}
}
})();
</script>
{% endif %}
<script>
(function(){
const SF_AVAIL = {{ shopfloor_available | tojson }};
const SF_ITEMS = {{ shopfloor_items | tojson }};
const body = document.getElementById('sfBody');
if (!body) return;
const opts = sel => SF_AVAIL.map(k=>`<option value="${k}" ${k===sel?'selected':''}>${k}</option>`).join('');
function row(it){
const tr=document.createElement('tr');
const key=it.key||(SF_AVAIL[0]||'');
tr.innerHTML =
`<td class="sf-n stn-mono"></td>`+
`<td><input class="form-control sf-label" value="${(it.label||'').replace(/"/g,'&quot;')}" placeholder="Menu label"></td>`+
`<td><select class="form-select sf-key">${opts(key)}</select></td>`+
`<td><input class="form-control sf-hint" value="${(it.hint||'').replace(/"/g,'&quot;')}" placeholder="(optional)"></td>`+
`<td style="text-align:center;"><input type="checkbox" class="sf-en" ${it.enabled===false?'':'checked'}></td>`+
`<td><button type="button" class="pxe-btn pxe-btn-sm pxe-btn-secondary sf-up" title="Up"><i class="bi bi-arrow-up"></i></button> `+
`<button type="button" class="pxe-btn pxe-btn-sm pxe-btn-secondary sf-down" title="Down"><i class="bi bi-arrow-down"></i></button></td>`+
`<td><button type="button" class="pxe-btn pxe-btn-sm pxe-btn-danger sf-rm" title="Remove"><i class="bi bi-trash"></i></button></td>`;
return tr;
}
const renum=()=>[...body.rows].forEach((r,i)=>r.querySelector('.sf-n').textContent=i+1);
const add=it=>{body.appendChild(row(it||{}));renum();};
body.addEventListener('click',e=>{
const tr=e.target.closest('tr'); if(!tr)return;
if(e.target.closest('.sf-rm')){tr.remove();renum();}
else if(e.target.closest('.sf-up')&&tr.previousElementSibling){tr.parentNode.insertBefore(tr,tr.previousElementSibling);renum();}
else if(e.target.closest('.sf-down')&&tr.nextElementSibling){tr.parentNode.insertBefore(tr.nextElementSibling,tr);renum();}
});
document.getElementById('sfAddBtn').addEventListener('click',()=>add({}));
document.getElementById('sfForm').addEventListener('submit',()=>{
const items=[...body.rows].map(r=>({label:r.querySelector('.sf-label').value.trim(),key:r.querySelector('.sf-key').value,hint:r.querySelector('.sf-hint').value.trim(),enabled:r.querySelector('.sf-en').checked})).filter(x=>x.label&&x.key);
document.getElementById('sfItemsData').value=JSON.stringify(items);
});
(SF_ITEMS.length?SF_ITEMS:[]).forEach(add);
if(!body.rows.length) add({});
})();
</script>
{% endblock %}