Files
pxe-server/webapp/templates/startnet_editor.html
cproudlock 04db6d6605 Generic build for other GE sites: no WJ data, one shopfloor type, Windows USB builder
Everything West Jefferson specific moved out to a separate repo
(pxe-site-wj): NTLARS/UDC/heat-treat backups keyed by WJ machine numbers,
CMM and Wax/Trace bay lists, WJ OpenText profiles, site-config.json, the
WJ preinstall list, and the WJ shopfloor PC types (Collections,
NoCollections, Common, Keyence, CMM, Genspect, HeatTreat, Wax/Trace,
Display, PartMarker) with GE-Enforce, machine numbers and the desktop
layout. The part-marker bench tools moved to their own repo. Dead
scripts removed: build-proxmox-iso, deploy-bios, sync_hardware_models,
mirror-from-gold, pull-bios, startnet-template.cmd.

Shopfloor is one generic type, gea-shopfloor-standard, with today's
timing:
- PPKG at first logon (unattend Order 10, unchanged).
- Install-AppList.ps1 runs C:\PreInstall\preinstall.json during setup.
- A new SYSTEM task, GE Shopfloor Post-PPKG, runs postppkg.json once
  after the post-PPKG reboot. It also clears AutoLogonCount after the
  ShopFloor handover (the Users-level monitor can be denied that HKLM
  write) and removes the Intune Sync task when done.
- Both lists ship empty. A site adds entries and installers.
- WinPE choice 3 goes straight to the PPKG menu. Staging, fetch and heal
  carry only the generic payload. The webapp's shopfloor sub-menu editor
  is gone, and the imaging tiles name the new stages.
- The playbook deploys the empty lists only if missing. It now deploys
  the PowerShell 7 MSI the shopfloor unattend installs, which it never
  did before.

Blancco site settings move into the webapp (Updates > Blancco releases):
business location, licensing Wi-Fi SSID and password. They are rendered
into the served preferences.xml and applied to every staged release, and
are seeded from site-vars (site_name, blancco_wifi_ssid,
blancco_wifi_password) only while none exist. The repo XML carries no
site values; the plaintext Wi-Fi password is gone from it.

New site_name and site_timezone settings: report and evidence-pack site
name, server clock and webapp time zone. No more West Jefferson defaults
in webapp/config.py or the dashboard title. Sync-BPRT.ps1 gets the
server address filled in at download instead of WJ's GE address.

USB on Windows:
- scripts/Build-PxeUsb.ps1 runs the same checks as build-usb.sh. It
  writes one FAT32 CIDATA partition (ISO files plus patched grub.cfg plus
  payload) with diskpart and reads every file back by SHA-256.
- Tested with Windows PowerShell 5.1 in the win11 VM, and the layout
  installs and first-boots in the pxetest VM.
- scripts/stage-usb-share.sh stages the build folder on a share.
- site-content/ is optional for both builders.

KB docs/kb/How_To_Deploy_PXE_Server_New_Site.html: new-site deployment,
Windows only, with a site-vars.yml guide.

Checks: webapp tests pass (5 suites); the unattend lint and playbook
syntax checks pass; every .ps1 parses; there are no WJ hosts, IPs, names
or PC types in playbook/, webapp/, scripts/ or autoinstall/.
2026-10-16 20:37:22 -04:00

710 lines
37 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-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>
<!-- ================================= 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 %}
{% endblock %}