Files
pxe-server/webapp/templates/startnet_editor.html
cproudlock fd00d5ff89 Webapp redesign phase 6: the remaining pages
Restyled to the same row pattern (one line, details in an expandable
body, one action plus a menu, tokens for light and dark):
- hardware coverage, audit log (family chips, destructive actions in
  red, client-side filters, first 300 rows), Clonezilla backups,
  enrollment packages (active package marked and undeletable, MCL flags
  with a visible legend)
- image Overview, OS and packages, Unattend (sticky save bar), Import
- Dell, iPXE, Blancco and BPRT update pages; Dell history links to the
  rollback review instead of rolling back on one click
- boot menu editor (tabs, rows with a menu, lint chips that jump to the
  line, tokens instead of hard-coded dark colours) and the imaging
  session detail page

Fixes found on the way:
- BPRT batch Activate never asked to confirm: a hidden input named
  'confirm' shadowed window.confirm in the inline handler, so the form
  submitted without a prompt. It now calls window.confirm.
- Backup upload refused when a backup with that name exists, unless
  'Replace the existing backup' is ticked (it overwrote silently).
- app.js: em dashes in comments replaced (plain ASCII).
2026-10-14 14:29:03 -04:00

792 lines
43 KiB
HTML

{% extends "base.html" %}
{% import '_ui.html' as ui %}
{% block title %}Boot menu editor - PXE Server Manager{% endblock %}
{% set snap_labels = {'STARTNET_SAVE': 'Raw edit', 'STARTNET_SETTINGS': 'Settings change', 'STARTNET_MENU': 'Boot menu change', 'STARTNET_RESTORE': 'Restore'} %}
{% set n_lint = (lint.errors | length) + (lint.warnings | length) %}
{% block extra_head %}
<style>
/* ---- Code palette. Contained to this page and documented here: the
editor uses the theme surface tokens for its background, gutter and
text, and these five syntax colours, picked for 4.5:1 or better on
--surface-2 in each theme. Light first, then dark (same keys as
pxe-ui.css: data-theme, and prefers-color-scheme with no data-theme). */
.stn {
--code-bg: var(--surface-2); --code-gutter: var(--surface-3); --code-text: var(--text); --code-line: var(--line-2);
--tok-comment: #3F6E2A; --tok-label: #7A4E00; --tok-keyword: #1F5FBF; --tok-var: #0B6B60; --tok-string: #9C3A16;
--code-sel: rgba(31, 95, 191, .22);
}
[data-theme="dark"] .stn {
--code-bg: #121820; --code-gutter: #0F141A;
--tok-comment: #8CC277; --tok-label: #E5D38A; --tok-keyword: #8DB8F5; --tok-var: #5FD3BC; --tok-string: #E3A587;
--code-sel: rgba(120, 169, 240, .30);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme]) .stn {
--code-bg: #121820; --code-gutter: #0F141A;
--tok-comment: #8CC277; --tok-label: #E5D38A; --tok-keyword: #8DB8F5; --tok-var: #5FD3BC; --tok-string: #E3A587;
--code-sel: rgba(120, 169, 240, .30);
}
}
/* ---- shared editor typography (keep pre + textarea metrics identical) ---- */
.stn-mono { font-family: var(--font-mono); font-size: 13px; line-height: 21px; letter-spacing: normal; tab-size: 4; -moz-tab-size: 4; }
/* ---- header + underline tabs (same look as _image_header.html) ---- */
.stn-head h1 { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 12px; }
.stn-head h1 .mono { font-size: 15px; font-weight: 400; color: var(--muted); }
.stn-tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--line-2); overflow-x: auto; scrollbar-width: none; margin: 0 0 20px; padding: 0; list-style: none; }
.stn-tabs .stn-tab { flex: none; display: inline-flex; align-items: center; gap: 6px; min-height: 42px; padding: 0 14px; font: inherit; font-size: 14px;
color: var(--text-2); background: none; border: 0; border-bottom: 3px solid transparent; margin-bottom: -1px; white-space: nowrap; cursor: pointer; }
.stn-tabs .stn-tab:hover { color: var(--text); }
.stn-tabs .stn-tab.active { color: var(--text); font-weight: 600; border-bottom-color: var(--text); }
.stn-tabs .tab-count { font-family: var(--font-mono); font-size: 12px; color: var(--muted); font-weight: 400; }
.stn-tabs .chip { font-size: 11px; padding: 1px 6px; }
@media (max-width: 600px) { .stn-tabs { flex-wrap: wrap; row-gap: 0; } .stn-tabs .stn-tab { padding: 0 10px; min-height: 40px; } }
/* ---- panels + two-column layout ---- */
.stn-cols { display: grid; grid-template-columns: minmax(0, 1.7fr) minmax(260px, 1fr); gap: 16px; align-items: start; }
@media (max-width: 1100px) { .stn-cols { grid-template-columns: minmax(0, 1fr); } }
.stn-pad { padding: 18px 20px; }
.stn-side { display: flex; flex-direction: column; min-width: 0; }
.stn-side > section { padding: 16px 18px; }
.stn-side > section + section, .stn-side > details { border-top: 1px solid var(--line); }
.stn h2 { margin: 0; font-size: 16px; font-weight: 600; }
.stn h3 { margin: 0 0 10px; font-size: 14px; font-weight: 600; }
.stn-bar { display: flex; flex-wrap: wrap; gap: 10px 12px; align-items: center; }
.stn-bar .grow { margin-right: auto; min-width: 0; }
.kv { margin: 0; display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 6px 16px; font-size: 13px; }
.kv dt { color: var(--muted); font-weight: 400; } .kv dd { margin: 0; overflow-wrap: anywhere; }
/* ---- settings form ---- */
.stn-form { display: flex; flex-direction: column; gap: 16px; max-width: 460px; margin-top: 14px; }
.stn-form label { display: block; font-size: 13px; font-weight: 600; margin-bottom: 4px; }
.stn-form .field { width: 100%; }
.field-hint { font-size: 12px; color: var(--muted); margin-top: 4px; }
select.field { padding-right: 28px; }
/* ---- editable list rows (boot menu, shopfloor) ---- */
.ed-list { container-type: inline-size; margin-top: 14px; }
.ed-row, .ed-hrow { display: grid; align-items: center; gap: 8px 12px; padding: 8px 0; }
.ed-row { border-top: 1px solid var(--line); }
.ed-hrow { padding: 0 0 6px; font-size: 12px; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); }
.m-grid { grid-template-columns: 2rem minmax(0, 1.4fr) minmax(0, 1fr) minmax(0, 1fr) 44px; grid-template-areas: "n lab tgt img act"; }
.sf-grid { grid-template-columns: 2rem minmax(0, 1.3fr) minmax(0, 1.1fr) minmax(0, 1fr) 64px 44px; grid-template-areas: "n lab key hint en act"; }
.ed-row .field { width: 100%; height: 34px; font-size: 13px; padding: 0 10px; }
.ed-n { grid-area: n; font-variant-numeric: tabular-nums; color: var(--muted); font-family: var(--font-mono); font-size: 13px; }
.ed-lab { grid-area: lab; } .ed-tgt { grid-area: tgt; } .ed-img { grid-area: img; } .ed-key { grid-area: key; } .ed-hint { grid-area: hint; }
.ed-en { grid-area: en; display: flex; align-items: center; gap: 6px; font-size: 13px; }
.ed-en input { width: 16px; height: 16px; accent-color: var(--accent); }
.ed-act { grid-area: act; display: flex; justify-content: flex-end; }
.ed-cap { display: none; font-size: 12px; color: var(--muted); margin-bottom: 2px; }
.ed-empty { padding: 14px 0; border-top: 1px solid var(--line); }
@container (max-width: 700px) {
.ed-hrow { display: none; }
.ed-cap { display: block; }
.m-grid { grid-template-columns: 2rem minmax(0, 1fr) 44px; grid-template-areas: "n lab act" ". tgt tgt" ". img img"; padding: 12px 0; }
.sf-grid { grid-template-columns: 2rem minmax(0, 1fr) 44px; grid-template-areas: "n lab act" ". key key" ". hint hint" ". en en"; padding: 12px 0; }
}
/* ---- Raw tab: gutter + highlight overlay + textarea ---- */
.raw-editor-wrap { display: flex; border: 1px solid var(--code-line); border-radius: 8px; background: var(--code-bg); 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: var(--muted); background: var(--code-gutter);
border-right: 1px solid var(--code-line); 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%; box-sizing: border-box; }
.raw-highlight { position: absolute; inset: 0; color: var(--code-text); background: transparent; pointer-events: none; overflow: hidden; }
.raw-textarea { position: relative; display: block; resize: none; background: transparent;
color: var(--code-text); /* visible if JS/overlay never activates (fallback) */
caret-color: var(--code-text); }
/* 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-editor-wrap:focus-within { outline: 3px solid var(--accent); outline-offset: 2px; }
.raw-textarea::selection { background: var(--code-sel); }
.tok-comment { color: var(--tok-comment); font-style: italic; }
.tok-label { color: var(--tok-label); font-weight: 600; }
.tok-keyword { color: var(--tok-keyword); }
.tok-var { color: var(--tok-var); }
.tok-string { color: var(--tok-string); }
/* ---- lint ---- */
.lint-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
.lint-list li { display: flex; gap: 8px; align-items: flex-start; font-size: 13px; line-height: 1.45; }
.lint-list .msg { min-width: 0; overflow-wrap: anywhere; padding-top: 2px; }
.lint-chip { border: 0; cursor: pointer; font-family: var(--font-mono); flex: none; }
.lint-chip[disabled] { cursor: default; }
.cmd-ref { display: flex; flex-direction: column; gap: 10px; padding: 0 18px 16px; font-size: 13px; }
.cmd-ref code { display: block; font-family: var(--font-mono); font-size: 12px; color: var(--text); overflow-wrap: anywhere; }
/* ---- history rows ---- */
.hs-row { display: grid; grid-template-columns: minmax(150px, 190px) minmax(0, 1fr) 80px; grid-template-areas: "ts note size"; align-items: center; gap: 4px 16px;
padding: 12px 150px 12px 20px; min-height: 52px; box-sizing: border-box; }
.hs-ts { grid-area: ts; font-family: var(--font-mono); font-size: 13px; }
.hs-note { grid-area: note; min-width: 0; display: flex; gap: 8px; align-items: center; }
.hs-size { grid-area: size; color: var(--muted); font-size: 13px; text-align: right; }
.ui-row.hs > .act { top: 8px; }
@container (max-width: 640px) {
.hs-row { grid-template-columns: minmax(0, 1fr); grid-template-areas: "ts" "note" "size"; padding: 12px 18px; }
.hs-size { text-align: left; }
.hs-head { display: none; }
.ui-row.hs > .act { position: static; padding: 0 18px 12px; justify-content: flex-start; }
}
/* ---- diff modal ---- */
.stn-modal { max-width: min(900px, 96vw); }
.diff-view { margin: 0; max-height: 55vh; overflow: auto; font-family: var(--font-mono); font-size: 12px; line-height: 1.5;
background: var(--surface); border: 1px solid var(--line-2); border-radius: 8px; padding: 6px 0; }
.diff-view span { display: block; padding: 0 12px; white-space: pre-wrap; word-break: break-all; min-height: 1.5em; }
.diff-add { background: var(--add-bg); color: var(--add-text); }
.diff-del { background: var(--del-bg); color: var(--del-text); }
.diff-hunk { background: var(--surface-2); color: var(--accent); }
.diff-meta { color: var(--muted); }
.diff-identical { padding: 1.5rem; text-align: center; }
</style>
{% endblock %}
{% block content %}
<div class="stn">
<header class="ui-head stn-head">
<div style="min-width: 0">
<div class="eyebrow-t">WinPE boot</div>
<h1>Boot menu editor <span class="mono">startnet.cmd</span></h1>
<div class="sub" style="margin-top: 4px; font-size: 13px; max-width: 70ch">
Saves rewrite startnet.cmd inside boot.wim and affect every PXE boot. The prior copy is snapshotted first, so any save can be restored from History.
</div>
</div>
<div class="actions">
<span class="tip right inl" data-tip="{{ wim_path }}"><span class="chip {{ 'c-neutral' if wim_exists else 'c-err' }} mono" style="font-weight: 400">boot.wim{{ '' if wim_exists else ' missing' }}</span></span>
</div>
</header>
{% if not wim_exists %}
<div class="ui-stack">
<div class="notice bad" role="alert"><span>
<strong>boot.wim not found</strong> at <span class="mono">{{ wim_path }}</span>.
Run the PXE server setup playbook and import the WinPE boot files first. Nothing on this page can be edited until it is there.</span>
</div>
{% if snapshots %}
<section class="panel stn-pad">
<h2>Snapshots</h2>
<p class="sub" style="margin: 6px 0 0">{{ snapshots | length }} earlier startnet.cmd snapshot{{ 's' if snapshots | length != 1 }} {{ 'are' if snapshots | length != 1 else 'is' }} still on disk. Restore is unavailable until boot.wim is present.</p>
</section>
{% endif %}
</div>
{% else %}
<ul class="stn-tabs" id="stnTabs" role="tablist" aria-label="Editor sections">
<li role="presentation"><button class="stn-tab active" id="tab-settings-btn" data-bs-toggle="tab" data-bs-target="#tab-settings" type="button" role="tab" aria-controls="tab-settings" aria-selected="true">Settings</button></li>
<li role="presentation"><button class="stn-tab" id="tab-menu-btn" data-bs-toggle="tab" data-bs-target="#tab-menu" type="button" role="tab" aria-controls="tab-menu" aria-selected="false">Boot menu
{% if boot_menu %}<span class="tab-count">{{ boot_menu | length }}</span>{% endif %}</button></li>
<li role="presentation"><button class="stn-tab" id="tab-shopfloor-btn" data-bs-toggle="tab" data-bs-target="#tab-shopfloor" type="button" role="tab" aria-controls="tab-shopfloor" aria-selected="false">Shopfloor sub-menu
{% if shopfloor_items %}<span class="tab-count">{{ shopfloor_items | length }}</span>{% endif %}</button></li>
<li role="presentation"><button class="stn-tab" id="tab-raw-btn" data-bs-toggle="tab" data-bs-target="#tab-raw" type="button" role="tab" aria-controls="tab-raw" aria-selected="false">Raw
{% if lint.errors %}{{ ui.chip(lint.errors | length ~ (' error' if lint.errors | length == 1 else ' errors'), 'err') }}
{% elif lint.warnings %}{{ ui.chip(lint.warnings | length ~ (' warning' if lint.warnings | length == 1 else ' warnings'), 'warn') }}{% endif %}</button></li>
<li role="presentation"><button class="stn-tab" id="tab-history-btn" data-bs-toggle="tab" data-bs-target="#tab-history" type="button" role="tab" aria-controls="tab-history" aria-selected="false">History
{% if snapshots %}<span class="tab-count">{{ snapshots | length }}</span>{% endif %}</button></li>
</ul>
{% macro wim_info_dl() %}
<dl class="kv">
{% for key, val in wim_info.items() %}
{% if key in ['Image Count', 'Compression', 'Total Bytes', 'Image Name', 'Image Description', 'Name', 'Description', 'Size'] %}
<dt>{{ key }}</dt><dd>{{ val }}</dd>
{% endif %}
{% endfor %}
</dl>
{% if not wim_info %}<p class="sub" style="margin: 0">Could not read WIM info.</p>{% endif %}
{% endmacro %}
<div class="tab-content">
<!-- =============================== SETTINGS =============================== -->
<div class="tab-pane fade show active" id="tab-settings" role="tabpanel" aria-labelledby="tab-settings-btn">
<div class="stn-cols">
<section class="panel stn-pad" aria-labelledby="setHead">
<h2 id="setHead">Boot settings</h2>
<p class="sub" style="margin: 4px 0 0; font-size: 13px; max-width: 62ch">
Each field edits only its own line in startnet.cmd. Leave a field blank to keep the current value.
The save goes through the same boot.wim path as the Raw tab.
</p>
<form action="{{ url_for('startnet_save_settings') }}" method="post" class="stn-form">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div>
<label for="setServerIp">PXE / deployment server IP</label>
<input type="text" class="field stn-mono" id="setServerIp" name="server_ip" aria-describedby="setServerIpHint"
value="{{ settings.server_ip if settings.server_ip is not none else '' }}" placeholder="e.g. 172.16.9.1">
<div class="field-hint" id="setServerIpHint">Address startnet.cmd maps the winpeapps share from.</div>
</div>
<div>
<label for="setTimeout">Menu timeout (seconds)</label>
<input type="number" min="0" class="field" id="setTimeout" name="menu_timeout" aria-describedby="setTimeoutHint"
value="{{ settings.menu_timeout if settings.menu_timeout is not none else '' }}" placeholder="e.g. 30">
<div class="field-hint" id="setTimeoutHint">Whole seconds before the default menu entry auto-selects.</div>
</div>
<div>
<label for="setDefault">Default menu entry</label>
<input type="text" class="field" id="setDefault" name="menu_default" aria-describedby="setDefaultHint"
value="{{ settings.menu_default if settings.menu_default is not none else '' }}" placeholder="e.g. 1">
<div class="field-hint" id="setDefaultHint">The entry chosen when the timeout elapses.</div>
</div>
<div><button type="submit" class="ui-btn pri">Save settings to boot.wim</button></div>
</form>
</section>
<div class="panel stn-side">
<section><h3>WIM info</h3>{{ wim_info_dl() }}</section>
</div>
</div>
</div>
<!-- =============================== BOOT MENU ============================== -->
<div class="tab-pane fade" id="tab-menu" role="tabpanel" aria-labelledby="tab-menu-btn">
<section class="panel stn-pad" aria-labelledby="menuHead">
<div class="stn-bar">
<div class="grow"><h2 id="menuHead">Boot menu entries</h2></div>
<button type="button" class="ui-btn" id="menuAddBtn">Add entry</button>
<button type="submit" form="menuForm" class="ui-btn pri">Save menu to boot.wim</button>
</div>
<div class="notice info" style="margin-top: 12px">
<span>Appending a new image at the end always works. Reordering or removing an entry whose number the enrollment
<span class="mono">%choice%</span> router cross-references may be refused by the server to keep numbering in sync.
If so, the raw text is left untouched and the reason is shown at the top of the page.</span>
</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="ed-list" id="menuTable">
<div class="ed-hrow m-grid" aria-hidden="true"><span>#</span><span class="ed-lab">Label</span><span class="ed-tgt">Target label</span><span class="ed-img">Target image</span><span></span></div>
<div id="menuBody"><!-- rows injected by JS --></div>
<div id="menuEmpty" class="ed-empty sub" style="display:none;">No menu entries parsed. Use the Raw tab, or add an entry above.</div>
</div>
</form>
</section>
</div>
<!-- ============================ SHOPFLOOR SUB-MENU ======================== -->
<div class="tab-pane fade" id="tab-shopfloor" role="tabpanel" aria-labelledby="tab-shopfloor-btn">
<section class="panel stn-pad" aria-labelledby="sfHead">
<div class="stn-bar">
<div class="grow"><h2 id="sfHead">Shopfloor sub-menu</h2>
<div class="sub">menu.json on the enrollment share. No boot.wim edit.</div></div>
<button type="button" class="ui-btn" id="sfAddBtn">Add item</button>
<button type="submit" form="sfForm" class="ui-btn pri">Save shopfloor menu</button>
</div>
<div class="notice info" style="margin-top: 12px"><span>
The GEA Shopfloor choice drills into this sub-menu. Each PC type must be an existing
<span class="mono">shopfloor-setup/gea-shopfloor-*</span> handler. WinPE falls back to the
built-in menu if the share or picker is unavailable. Rows without a label are dropped on save.</span>
</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="ed-list" id="sfTable">
<div class="ed-hrow sf-grid" aria-hidden="true"><span>#</span><span class="ed-lab">Label</span><span class="ed-key">PC type (key)</span><span class="ed-hint">Hint</span><span class="ed-en">Shown</span><span></span></div>
<div id="sfBody"><!-- rows injected by JS --></div>
</div>
</form>
</section>
</div>
<!-- ================================= RAW ================================= -->
<div class="tab-pane fade" id="tab-raw" role="tabpanel" aria-labelledby="tab-raw-btn">
<div class="stn-cols">
<section class="panel stn-pad" aria-labelledby="rawHead">
<div class="stn-bar" style="margin-bottom: 12px">
<div class="grow"><h2 id="rawHead" class="vh">Raw startnet.cmd</h2>
<span class="tip" data-tip="{{ wim_path }}"><span class="trunc mono" style="font-size: 13px">Windows\System32\startnet.cmd</span></span></div>
<button type="button" class="ui-btn" id="rawDiffBtn">Diff vs current</button>
<button type="submit" form="startnetForm" class="ui-btn pri">Save to boot.wim</button>
</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" aria-label="startnet.cmd contents"
spellcheck="false" autocomplete="off" autocapitalize="off"
wrap="off">{{ content }}</textarea>
</div>
</div>
</form>
<div class="sub" style="margin-top: 10px">Editing startnet.cmd inside <span class="mono">{{ wim_path }}</span>. Tab inserts a tab; Esc then Tab moves focus on.</div>
</section>
<div class="panel stn-side">
<section aria-labelledby="lintHead">
<div class="stn-bar" style="margin-bottom: 10px; gap: 6px 8px">
<h3 id="lintHead" class="grow" style="margin: 0">Lint</h3>
{% if lint.errors %}{{ ui.chip(lint.errors | length ~ ' error' ~ ('s' if lint.errors | length != 1 else ''), 'err') }}{% endif %}
{% if lint.warnings %}{{ ui.chip(lint.warnings | length ~ ' warning' ~ ('s' if lint.warnings | length != 1 else ''), 'warn') }}{% endif %}
</div>
{% if not n_lint %}
<p class="sub" style="margin: 0">No issues found in the saved copy.</p>
{% else %}
<ul class="lint-list">
{% for e in lint.errors %}
<li><button type="button" class="chip c-err lint-chip" data-line="{{ e.line if e.line is not none else '' }}"
{% if e.line is none %}disabled{% endif %} aria-label="Error{{ (' on line ' ~ e.line) if e.line is not none else '' }}{{ ', go to line' if e.line is not none }}">{{ ('L' ~ e.line) if e.line is not none else 'Error' }}</button>
<span class="msg">{{ e.message }}</span></li>
{% endfor %}
{% for w in lint.warnings %}
<li><button type="button" class="chip c-warn lint-chip" data-line="{{ w.line if w.line is not none else '' }}"
{% if w.line is none %}disabled{% endif %} aria-label="Warning{{ (' on line ' ~ w.line) if w.line is not none else '' }}{{ ', go to line' if w.line is not none }}">{{ ('L' ~ w.line) if w.line is not none else 'Warn' }}</button>
<span class="msg">{{ w.message }}</span></li>
{% endfor %}
</ul>
<p class="sub" style="margin: 10px 0 0">Lint runs on the saved copy. Select a line number to jump to it.</p>
{% endif %}
</section>
<section><h3>WIM info</h3>{{ wim_info_dl() }}</section>
<details>
<summary class="disc" style="padding: 0 18px; min-height: 44px"><span class="chev" aria-hidden="true">&#9662;</span>Common startnet.cmd commands</summary>
<div class="cmd-ref">
<div><code>wpeinit</code><span class="sub">Initialize WinPE networking</span></div>
<div><code>wpeutil WaitForNetwork</code><span class="sub">Wait for network to be ready</span></div>
<div><code>net use Z: \\172.16.9.1\winpeapps</code><span class="sub">Map the Samba share for deployment</span></div>
<div><code>Z:\gea-standard\Deploy\Tools\deploy.cmd</code><span class="sub">Launch the deployment script</span></div>
</div>
</details>
</div>
</div>
</div>
<!-- =============================== HISTORY =============================== -->
<div class="tab-pane fade" id="tab-history" role="tabpanel" aria-labelledby="tab-history-btn">
<section class="panel ui-list" aria-labelledby="histHead">
<div class="stn-pad" style="padding-bottom: 14px">
<h2 id="histHead">Snapshots</h2>
<p class="sub" style="margin: 4px 0 0; font-size: 13px; max-width: 70ch">
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>
</div>
{% if not snapshots %}
<div class="ed-empty sub" style="padding: 16px 20px">No snapshots yet.</div>
{% else %}
<div class="hs-row hs-head ui-rowhead" style="min-height: 0; padding-top: 0; padding-bottom: 6px" aria-hidden="true"><span class="hs-ts">Taken</span><span class="hs-note">Before</span><span class="hs-size">Size</span></div>
{% for s in snapshots %}
<div class="ui-row hs">
<div class="hs-row">
<span class="hs-ts">{{ s.timestamp }}</span>
<span class="hs-note"><span class="tip" data-tip="{{ s.id }}"><span class="trunc">{{ snap_labels.get(s.note, s.note) if s.note else 'Snapshot' }}</span></span>
{% if loop.first %}{{ ui.chip('Latest', 'neutral') }}{% endif %}</span>
<span class="hs-size">{{ '%.1f'|format(s.size / 1024) }} KB</span>
</div>
<div class="act">
<button type="button" class="ui-btn sm snap-diff-btn" data-snapshot-id="{{ s.id }}">Diff</button>
{% call ui.more('snapshot ' ~ s.timestamp, left=True) %}
<form action="{{ url_for('startnet_restore_snapshot') }}" method="post"
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="snap-restore-btn">Restore to boot.wim</button>
</form>
{% endcall %}
</div>
</div>
{% endfor %}
{% endif %}
</section>
</div>
</div>
<!-- ============================= DIFF MODAL ============================= -->
<div class="modal fade" id="diffModal" tabindex="-1" aria-labelledby="diffModalTitle" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable stn-modal">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title" 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="sub" style="padding:1rem;">Loading...</div></div>
</div>
<div class="modal-footer">
<button type="button" class="ui-btn" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
{% endif %}
</div>
{% 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 String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// ---------------------------------------------------- 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 escText(s) {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function hlLine(raw) {
var line = escText(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 lines = ta.value.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. Esc first lets
// a keyboard user Tab out of the editor.
var escaped = false;
ta.addEventListener('keydown', function (e) {
if (e.key === 'Escape') { escaped = true; return; }
if (e.key === 'Tab' && !escaped) {
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();
}
escaped = false;
});
} catch (err) {
// Highlight is a nicety; on any failure fall back to the plain textarea.
if (wrap) wrap.classList.remove('overlay-live');
}
}
// ------------------------------------------------- lint chip: go to line
document.querySelectorAll('.lint-chip[data-line]').forEach(function (chip) {
chip.addEventListener('click', function () {
var n = parseInt(chip.getAttribute('data-line'), 10);
if (!ta || !n) return;
var lines = ta.value.split('\n');
if (n > lines.length) n = lines.length;
var pos = 0;
for (var i = 0; i < n - 1; i++) pos += lines[i].length + 1;
ta.focus();
ta.setSelectionRange(pos, pos + lines[n - 1].length);
var lh = parseFloat(getComputedStyle(ta).lineHeight) || 21;
ta.scrollTop = Math.max(0, (n - 1) * lh - ta.clientHeight / 3);
syncScroll();
});
});
// ------------------------------------------------------------- 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">' + ui_chip('No differences', 'ok') +
'<div class="sub" style="margin-top:6px">Identical to the current boot.wim.</div></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';
// spans are display:block, so no newline between them
html += '<span class="' + cls + '">' + escText(ln) + '</span>';
}
body.innerHTML = '<pre class="diff-view">' + html + '</pre>';
}
function ui_chip(text, kind) {
return '<span class="chip c-' + kind + '">' + esc(text) + '</span>';
}
function openDiff(title, payload) {
var body = document.getElementById('diffModalBody');
document.getElementById('diffModalTitle').textContent = title;
body.innerHTML = '<div class="sub" 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="errbox">' + esc(res.j.error || 'Diff failed.') + '</div>';
return;
}
renderDiff(res.j.diff || '');
}).catch(function () {
body.innerHTML = '<div class="errbox">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 });
});
});
// Row "..." menus: close any other open one, and close on outside click.
document.addEventListener('click', function (e) {
document.querySelectorAll('.stn details.more[open]').forEach(function (d) {
if (!d.contains(e.target)) d.removeAttribute('open');
});
});
// -------------------------------------------------------- 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;
}
// One line per entry: number, label, target, image, and a "..." menu with
// Move up / Move down / Remove (Remove is DOM-only until Save).
function rowHtml(item, idx, total) {
var n = idx + 1;
return '' +
'<span class="ed-n">' + n + '</span>' +
'<div class="ed-lab"><label class="ed-cap" for="mLab' + idx + '">Label</label>' +
'<input type="text" class="field m-label" id="mLab' + idx + '" aria-label="Entry ' + n + ' label" value="' + esc(item.label || '') + '" placeholder="Menu label"></div>' +
'<div class="ed-tgt"><label class="ed-cap" for="mTgt' + idx + '">Target label</label>' +
'<input type="text" class="field stn-mono m-target" id="mTgt' + idx + '" aria-label="Entry ' + n + ' target label" value="' + esc(item.target || '') + '" placeholder="Target (goto/call)"></div>' +
'<div class="ed-img"><label class="ed-cap" for="mImg' + idx + '">Target image</label>' +
'<select class="field m-image" id="mImg' + idx + '" aria-label="Entry ' + n + ' target image">' + optionsFor(item.target_image || '') + '</select></div>' +
'<div class="ed-act"><details class="more">' +
'<summary class="ui-btn ghost" aria-label="More actions for entry ' + n + '">&#8943;</summary>' +
'<div class="menu">' +
'<button type="button" class="m-up"' + (idx === 0 ? ' disabled' : '') + '>Move up</button>' +
'<button type="button" class="m-down"' + (idx === total - 1 ? ' disabled' : '') + '>Move down</button>' +
'<div class="sep"></div>' +
'<button type="button" class="m-del del">Remove entry</button>' +
'</div></details></div>';
}
// Read the live DOM back into an array (so in-progress typing is captured).
function collect() {
var items = [];
body.querySelectorAll('.m-row').forEach(function (row) {
items.push({
label: (row.querySelector('.m-label') || {}).value || '',
target: (row.querySelector('.m-target') || {}).value || '',
target_image: (row.querySelector('.m-image') || {}).value || ''
});
});
return items;
}
function render(items, focusIdx) {
body.innerHTML = '';
for (var i = 0; i < items.length; i++) {
var row = document.createElement('div');
row.className = 'ed-row m-grid m-row';
row.innerHTML = rowHtml(items[i], i, items.length);
body.appendChild(row);
}
if (emptyMsg) emptyMsg.style.display = items.length ? 'none' : 'block';
if (focusIdx != null && body.children[focusIdx]) {
var s = body.children[focusIdx].querySelector('summary');
if (s) s.focus();
}
}
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 row = btn.closest('.m-row');
var rows = Array.prototype.slice.call(body.querySelectorAll('.m-row'));
var idx = rows.indexOf(row);
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, idx - 1);
} 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, idx + 1);
} 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);
var last = body.lastElementChild && body.lastElementChild.querySelector('.m-label');
if (last) last.focus();
});
}
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 esc = s => String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
const opts = sel => SF_AVAIL.map(k=>`<option value="${esc(k)}" ${k===sel?'selected':''}>${esc(k)}</option>`).join('');
let uid = 0;
// One line per item; Move up / Move down / Remove live in the row's "..." menu.
function row(it){
const r=document.createElement('div');
r.className='ed-row sf-grid sf-row';
const key=it.key||(SF_AVAIL[0]||'');
const u=++uid;
r.innerHTML =
`<span class="ed-n sf-n"></span>`+
`<div class="ed-lab"><label class="ed-cap" for="sfL${u}">Label</label><input class="field sf-label" id="sfL${u}" value="${esc(it.label||'')}" placeholder="Menu label"></div>`+
`<div class="ed-key"><label class="ed-cap" for="sfK${u}">PC type (key)</label><select class="field sf-key" id="sfK${u}">${opts(key)}</select></div>`+
`<div class="ed-hint"><label class="ed-cap" for="sfH${u}">Hint</label><input class="field sf-hint" id="sfH${u}" value="${esc(it.hint||'')}" placeholder="(optional)"></div>`+
`<label class="ed-en"><input type="checkbox" class="sf-en" ${it.enabled===false?'':'checked'}><span class="ed-cap sf-en-txt" style="margin:0">Shown</span></label>`+
`<div class="ed-act"><details class="more"><summary class="ui-btn ghost sf-more">&#8943;</summary><div class="menu">`+
`<button type="button" class="sf-up">Move up</button><button type="button" class="sf-down">Move down</button>`+
`<div class="sep"></div><button type="button" class="sf-rm del">Remove item</button></div></details></div>`;
return r;
}
const rows=()=>[...body.querySelectorAll('.sf-row')];
const renum=()=>rows().forEach((r,i,all)=>{
const n=i+1;
r.querySelector('.sf-n').textContent=n;
r.querySelector('.sf-label').setAttribute('aria-label','Item '+n+' label');
r.querySelector('.sf-key').setAttribute('aria-label','Item '+n+' PC type');
r.querySelector('.sf-hint').setAttribute('aria-label','Item '+n+' hint');
r.querySelector('.sf-en').setAttribute('aria-label','Item '+n+' shown in menu');
r.querySelector('.sf-more').setAttribute('aria-label','More actions for item '+n);
r.querySelector('.sf-up').disabled = i===0;
r.querySelector('.sf-down').disabled = i===all.length-1;
});
const add=it=>{body.appendChild(row(it||{}));renum();};
body.addEventListener('click',e=>{
const r=e.target.closest('.sf-row'); if(!r)return;
const menu=r.querySelector('details.more');
if(e.target.closest('.sf-rm')){r.remove();renum();}
else if(e.target.closest('.sf-up')&&r.previousElementSibling){r.parentNode.insertBefore(r,r.previousElementSibling);renum();menu.removeAttribute('open');menu.querySelector('summary').focus();}
else if(e.target.closest('.sf-down')&&r.nextElementSibling){r.parentNode.insertBefore(r.nextElementSibling,r);renum();menu.removeAttribute('open');menu.querySelector('summary').focus();}
});
document.getElementById('sfAddBtn').addEventListener('click',()=>{add({});const l=body.lastElementChild.querySelector('.sf-label');if(l)l.focus();});
document.getElementById('sfForm').addEventListener('submit',()=>{
const items=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(!rows().length) add({});
})();
</script>
{% endblock %}