shopfloor menu: data-driven from menu.json (picker + webapp editor)

Replace the hardcoded GEA Shopfloor PC-type sub-menu with a data-driven one:
- menu.json on the enrollment share lists the shopfloor items {key=PCTYPE, label, hint, enabled}; key must match a shopfloor-setup/gea-shopfloor-* handler dir.
- select-shopfloor-type.ps1 renders it in WinPE and writes the chosen PCTYPE (mirrors the CMM bay picker); startnet.cmd runs it and falls back to the baked-in menu if the share/picker is unavailable.
- Webapp /shopfloor-menu editor: reorder/rename/hide/add items; the PC-type is a dropdown of existing handler dirs (can't wire a choice to a non-existent type); writes menu.json. Nav link under Tools.
Kills the duplicated-knowledge problem (menu list was hardcoded in startnet AND the handler dirs AND site-config); add a PC-type = drop in the handler dir + it appears in the menu.
This commit is contained in:
cproudlock
2026-07-23 13:27:08 -04:00
parent f1d9bdf478
commit 626561a1fa
6 changed files with 276 additions and 2 deletions

View File

@@ -1189,6 +1189,83 @@ def startnet_diff():
return jsonify({"error": "provide snapshot_id or content"}), 400
# ---------------------------------------------------------------------------
# Routes - Shopfloor boot-menu (data-driven menu.json)
# ---------------------------------------------------------------------------
SHOPFLOOR_SETUP_DIR = os.path.join(config.ENROLLMENT_SHARE, "shopfloor-setup")
SHOPFLOOR_MENU_JSON = os.path.join(SHOPFLOOR_SETUP_DIR, "menu.json")
def read_shopfloor_menu():
"""The shopfloor PC-type menu items from menu.json (list; [] if absent)."""
try:
with open(SHOPFLOOR_MENU_JSON) as fh:
return json.load(fh).get("shopfloor", [])
except (OSError, json.JSONDecodeError):
return []
def available_pctypes():
"""gea-shopfloor-* handler dirs on the share - the only valid menu keys."""
try:
return sorted(
d for d in os.listdir(SHOPFLOOR_SETUP_DIR)
if d.startswith("gea-shopfloor-")
and os.path.isdir(os.path.join(SHOPFLOOR_SETUP_DIR, d))
)
except OSError:
return []
@app.route("/shopfloor-menu", methods=["GET", "POST"])
def shopfloor_menu():
if request.method == "POST":
try:
items = json.loads(request.form.get("items", "[]"))
except json.JSONDecodeError:
flash("Invalid menu payload.", "danger")
return redirect(url_for("shopfloor_menu"))
valid = set(available_pctypes())
clean = []
for it in items:
if not isinstance(it, dict):
continue
key = (it.get("key") or "").strip()
label = (it.get("label") or "").strip()
if not key or not label:
continue
if key not in valid:
flash(f"Skipped '{label}': {key} has no shopfloor-setup handler.", "warning")
continue
clean.append({
"key": key,
"label": label,
"hint": (it.get("hint") or "").strip(),
"enabled": bool(it.get("enabled", True)),
})
if not clean:
flash("Refused to save an empty menu.", "danger")
return redirect(url_for("shopfloor_menu"))
try:
os.makedirs(SHOPFLOOR_SETUP_DIR, exist_ok=True)
with open(SHOPFLOOR_MENU_JSON, "w") as fh:
json.dump({"shopfloor": clean}, fh, indent=2)
audit("SHOPFLOOR_MENU_SAVE", f"{len(clean)} items")
flash("Shopfloor boot menu saved - WinPE reads it at next boot.", "success")
except OSError as exc:
flash(f"Could not write menu.json: {exc}", "danger")
return redirect(url_for("shopfloor_menu"))
return render_template(
"shopfloor_menu.html",
items=read_shopfloor_menu(),
available=available_pctypes(),
image_types=config.IMAGE_TYPES,
friendly_names=config.FRIENDLY_NAMES,
)
# ---------------------------------------------------------------------------
# Routes - Audit Log
# ---------------------------------------------------------------------------

View File

@@ -63,6 +63,12 @@
<i class="bi bi-terminal"></i> startnet.cmd
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'shopfloor_menu' %}active{% endif %}"
href="{{ url_for('shopfloor_menu') }}">
<i class="bi bi-list-ol"></i> Shopfloor Menu
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'clonezilla_backups' %}active{% endif %}"
href="{{ url_for('clonezilla_backups') }}">

View File

@@ -0,0 +1,99 @@
{% extends "base.html" %}
{% block title %}Shopfloor Boot Menu - PXE Server Manager{% endblock %}
{% block content %}
<div class="page-header">
<h1><i class="bi bi-list-ol"></i> Shopfloor Boot Menu</h1>
<div class="header-actions">
<button type="button" class="btn btn-secondary" id="addRow"><i class="bi bi-plus-lg"></i> Add item</button>
<button type="submit" form="menuForm" class="btn btn-primary"><i class="bi bi-save"></i> Save</button>
</div>
</div>
<div class="alert alert-info">
This is the <strong>data-driven GEA Shopfloor sub-menu</strong> WinPE shows at imaging
(<span class="mono">menu.json</span> on the enrollment share). Reorder, rename, or hide entries -
no boot.wim edit. Each item's <strong>PC-type</strong> must be an existing
<span class="mono">shopfloor-setup/gea-shopfloor-*</span> handler (dropdown below); WinPE falls back to the
baked-in menu if the share/picker is unavailable.
</div>
<div class="card">
<div class="table-container">
<table class="data-table" id="menuTable">
<thead>
<tr>
<th style="width:3rem;">#</th>
<th>Label</th>
<th>PC-type (key)</th>
<th>Hint</th>
<th style="width:5rem;">Shown</th>
<th style="width:8rem;">Order</th>
<th style="width:3rem;"></th>
</tr>
</thead>
<tbody id="menuBody"></tbody>
</table>
</div>
</div>
<form id="menuForm" method="post" action="{{ url_for('shopfloor_menu') }}">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="items" id="itemsField">
</form>
<script>
const AVAILABLE = {{ available | tojson }};
const ITEMS = {{ items | tojson }};
const body = document.getElementById('menuBody');
function optionList(sel) {
return AVAILABLE.map(k => `<option value="${k}" ${k === sel ? 'selected' : ''}>${k}</option>`).join('');
}
function makeRow(it) {
const tr = document.createElement('tr');
const key = it.key || (AVAILABLE[0] || '');
tr.innerHTML =
`<td class="rownum mono"></td>` +
`<td><input class="form-control f-label" value="${(it.label || '').replace(/"/g, '&quot;')}" placeholder="Menu label"></td>` +
`<td><select class="form-select f-key">${optionList(key)}</select></td>` +
`<td><input class="form-control f-hint" value="${(it.hint || '').replace(/"/g, '&quot;')}" placeholder="(optional)"></td>` +
`<td style="text-align:center;"><input type="checkbox" class="f-enabled" ${it.enabled === false ? '' : 'checked'}></td>` +
`<td><button type="button" class="btn btn-sm btn-secondary mv-up" title="Up"><i class="bi bi-arrow-up"></i></button> ` +
`<button type="button" class="btn btn-sm btn-secondary mv-down" title="Down"><i class="bi bi-arrow-down"></i></button></td>` +
`<td><button type="button" class="btn btn-sm btn-danger rm" title="Remove"><i class="bi bi-trash"></i></button></td>`;
return tr;
}
function renumber() {
[...body.rows].forEach((r, i) => r.querySelector('.rownum').textContent = i + 1);
}
function addRow(it) { body.appendChild(makeRow(it || {})); renumber(); }
body.addEventListener('click', e => {
const tr = e.target.closest('tr');
if (!tr) return;
if (e.target.closest('.rm')) { tr.remove(); renumber(); }
else if (e.target.closest('.mv-up') && tr.previousElementSibling) { tr.parentNode.insertBefore(tr, tr.previousElementSibling); renumber(); }
else if (e.target.closest('.mv-down') && tr.nextElementSibling) { tr.parentNode.insertBefore(tr.nextElementSibling, tr); renumber(); }
});
document.getElementById('addRow').addEventListener('click', () => addRow({}));
document.getElementById('menuForm').addEventListener('submit', () => {
const items = [...body.rows].map(r => ({
label: r.querySelector('.f-label').value.trim(),
key: r.querySelector('.f-key').value,
hint: r.querySelector('.f-hint').value.trim(),
enabled: r.querySelector('.f-enabled').checked,
})).filter(x => x.label && x.key);
document.getElementById('itemsField').value = JSON.stringify(items);
});
(ITEMS.length ? ITEMS : []).forEach(addRow);
if (!body.rows.length) addRow({});
</script>
{% endblock %}