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:
15
playbook/shopfloor-setup/menu.json
Normal file
15
playbook/shopfloor-setup/menu.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"_comment": "Data-driven WinPE shopfloor PC-type menu. Read at boot by select-shopfloor-type.ps1 (mirrors the CMM bay picker) and edited by the PXE webapp. 'key' is the PCTYPE value - it MUST match a shopfloor-setup/gea-shopfloor-<x> handler dir. Order = display order. Set enabled=false to hide without deleting. Deployed to the enrollment share; startnet.cmd falls back to its baked-in menu if this file or the picker is unavailable.",
|
||||||
|
"shopfloor": [
|
||||||
|
{ "key": "gea-shopfloor-collections", "label": "Machine with Collections", "hint": "eDNC + UDC + Plant Apps", "enabled": true },
|
||||||
|
{ "key": "gea-shopfloor-nocollections", "label": "Machine without Collections", "hint": "eDNC + Plant Apps, no UDC", "enabled": true },
|
||||||
|
{ "key": "gea-shopfloor-common", "label": "Common", "hint": "Timeclock, Lab; WJ Shopfloor only", "enabled": true },
|
||||||
|
{ "key": "gea-shopfloor-keyence", "label": "Keyence", "hint": "VR-3000 / VR-5000 / VR-6000 microscope","enabled": true },
|
||||||
|
{ "key": "gea-shopfloor-cmm", "label": "CMM", "hint": "Hexagon PC-DMIS + Protect Viewer", "enabled": true },
|
||||||
|
{ "key": "gea-shopfloor-genspect", "label": "Genspect", "hint": "", "enabled": true },
|
||||||
|
{ "key": "gea-shopfloor-heattreat", "label": "Heattreat", "hint": "eDNC + HeatTreat app", "enabled": true },
|
||||||
|
{ "key": "gea-shopfloor-waxtrace", "label": "Wax and Trace", "hint": "", "enabled": true },
|
||||||
|
{ "key": "gea-shopfloor-display", "label": "Display", "hint": "kiosk dashboard", "enabled": true },
|
||||||
|
{ "key": "gea-shopfloor-partmarker", "label": "Part Marker", "hint": "eDNC + Telesis Mark", "enabled": true }
|
||||||
|
]
|
||||||
|
}
|
||||||
63
playbook/shopfloor-setup/select-shopfloor-type.ps1
Normal file
63
playbook/shopfloor-setup/select-shopfloor-type.ps1
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<#
|
||||||
|
select-shopfloor-type.ps1 - data-driven GEA Shopfloor PC-type picker for WinPE.
|
||||||
|
|
||||||
|
Reads the shopfloor menu from menu.json (edited by the PXE webapp), renders a
|
||||||
|
numbered menu, and writes the chosen PCTYPE key (e.g. gea-shopfloor-cmm) to
|
||||||
|
-OutFile. startnet.cmd runs this instead of a hardcoded menu, and falls back
|
||||||
|
to its baked-in menu if this script or menu.json is missing. Mirrors the CMM
|
||||||
|
bay picker (select-cmm-bay.ps1).
|
||||||
|
|
||||||
|
Usage (from startnet.cmd):
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -File select-shopfloor-type.ps1 `
|
||||||
|
-MenuJson Y:\menu.json -OutFile X:\pctype.txt
|
||||||
|
Exit 0 + a written key on success; non-zero (and no file) on any failure so
|
||||||
|
the batch fallback kicks in.
|
||||||
|
#>
|
||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)][string]$MenuJson,
|
||||||
|
[Parameter(Mandatory = $true)][string]$OutFile
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
Remove-Item -LiteralPath $OutFile -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (-not (Test-Path -LiteralPath $MenuJson)) { Write-Host "menu.json not found: $MenuJson"; exit 2 }
|
||||||
|
$data = Get-Content -LiteralPath $MenuJson -Raw | ConvertFrom-Json
|
||||||
|
$items = @($data.shopfloor | Where-Object { $_.key -and ($_.enabled -ne $false) })
|
||||||
|
if ($items.Count -eq 0) { Write-Host "menu.json has no enabled shopfloor items"; exit 3 }
|
||||||
|
|
||||||
|
while ($true) {
|
||||||
|
Clear-Host
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "========================================"
|
||||||
|
Write-Host " GEA Shopfloor PC Sub-Type"
|
||||||
|
Write-Host "========================================"
|
||||||
|
Write-Host ""
|
||||||
|
for ($i = 0; $i -lt $items.Count; $i++) {
|
||||||
|
$n = $i + 1
|
||||||
|
$lbl = $items[$i].label
|
||||||
|
$hint = $items[$i].hint
|
||||||
|
$line = "{0,3}. {1}" -f $n, $lbl
|
||||||
|
if ($hint) { $line = "{0,-45} ({1})" -f $line, $hint }
|
||||||
|
Write-Host $line
|
||||||
|
}
|
||||||
|
Write-Host ""
|
||||||
|
$sel = Read-Host ("Enter your choice (1-{0})" -f $items.Count)
|
||||||
|
$num = 0
|
||||||
|
if ([int]::TryParse($sel, [ref]$num) -and $num -ge 1 -and $num -le $items.Count) {
|
||||||
|
$key = $items[$num - 1].key
|
||||||
|
# OutFile is on X: (WinPE scratch); write the bare PCTYPE key, no BOM/newline surprises
|
||||||
|
[System.IO.File]::WriteAllText($OutFile, $key)
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host ("Selected: {0} ({1})" -f $items[$num - 1].label, $key)
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
Write-Host "Invalid choice - try again."
|
||||||
|
Start-Sleep -Milliseconds 800
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Host "shopfloor picker error: $($_.Exception.Message)"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
@@ -54,6 +54,19 @@ goto enroll_staged
|
|||||||
|
|
||||||
:gea_shopfloor_submenu
|
:gea_shopfloor_submenu
|
||||||
cls
|
cls
|
||||||
|
set PCTYPE=
|
||||||
|
REM Data-driven PC-type menu: the PXE webapp maintains menu.json on the
|
||||||
|
REM enrollment share; select-shopfloor-type.ps1 renders it and writes the
|
||||||
|
REM chosen PCTYPE to X:\pctype.txt. Falls back to the baked-in menu below if
|
||||||
|
REM the share or the picker is unavailable (mirrors the CMM bay picker).
|
||||||
|
net use Y: \\172.16.9.1\enrollment /user:pxe-upload pxe /persistent:no >NUL 2>NUL
|
||||||
|
del X:\pctype.txt 2>NUL
|
||||||
|
if exist "Y:\shopfloor-setup\select-shopfloor-type.ps1" powershell.exe -NoProfile -ExecutionPolicy Bypass -File "Y:\shopfloor-setup\select-shopfloor-type.ps1" -MenuJson "Y:\shopfloor-setup\menu.json" -OutFile "X:\pctype.txt"
|
||||||
|
if exist X:\pctype.txt set /p PCTYPE=<X:\pctype.txt
|
||||||
|
if not "%PCTYPE%"=="" goto gea_shopfloor_have_type
|
||||||
|
|
||||||
|
:gea_shopfloor_submenu_fallback
|
||||||
|
cls
|
||||||
echo.
|
echo.
|
||||||
echo ========================================
|
echo ========================================
|
||||||
echo GEA Shopfloor PC Sub-Type
|
echo GEA Shopfloor PC Sub-Type
|
||||||
@@ -70,7 +83,6 @@ echo 8. Wax and Trace
|
|||||||
echo 9. Display (kiosk dashboard)
|
echo 9. Display (kiosk dashboard)
|
||||||
echo 10. Part Marker (eDNC + Telesis Mark)
|
echo 10. Part Marker (eDNC + Telesis Mark)
|
||||||
echo.
|
echo.
|
||||||
set PCTYPE=
|
|
||||||
set /p ges_choice=Enter your choice (1-10):
|
set /p ges_choice=Enter your choice (1-10):
|
||||||
if "%ges_choice%"=="1" set PCTYPE=gea-shopfloor-collections
|
if "%ges_choice%"=="1" set PCTYPE=gea-shopfloor-collections
|
||||||
if "%ges_choice%"=="2" set PCTYPE=gea-shopfloor-nocollections
|
if "%ges_choice%"=="2" set PCTYPE=gea-shopfloor-nocollections
|
||||||
@@ -82,7 +94,9 @@ if "%ges_choice%"=="7" set PCTYPE=gea-shopfloor-heattreat
|
|||||||
if "%ges_choice%"=="8" set PCTYPE=gea-shopfloor-waxtrace
|
if "%ges_choice%"=="8" set PCTYPE=gea-shopfloor-waxtrace
|
||||||
if "%ges_choice%"=="9" set PCTYPE=gea-shopfloor-display
|
if "%ges_choice%"=="9" set PCTYPE=gea-shopfloor-display
|
||||||
if "%ges_choice%"=="10" set PCTYPE=gea-shopfloor-partmarker
|
if "%ges_choice%"=="10" set PCTYPE=gea-shopfloor-partmarker
|
||||||
if "%PCTYPE%"=="" goto gea_shopfloor_submenu
|
if "%PCTYPE%"=="" goto gea_shopfloor_submenu_fallback
|
||||||
|
|
||||||
|
:gea_shopfloor_have_type
|
||||||
if "%PCTYPE%"=="gea-shopfloor-display" goto display_submenu
|
if "%PCTYPE%"=="gea-shopfloor-display" goto display_submenu
|
||||||
if "%PCTYPE%"=="gea-shopfloor-keyence" goto keyence_submenu
|
if "%PCTYPE%"=="gea-shopfloor-keyence" goto keyence_submenu
|
||||||
if "%PCTYPE%"=="gea-shopfloor-cmm" goto cmm_submenu
|
if "%PCTYPE%"=="gea-shopfloor-cmm" goto cmm_submenu
|
||||||
|
|||||||
@@ -1189,6 +1189,83 @@ def startnet_diff():
|
|||||||
return jsonify({"error": "provide snapshot_id or content"}), 400
|
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
|
# Routes - Audit Log
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -63,6 +63,12 @@
|
|||||||
<i class="bi bi-terminal"></i> startnet.cmd
|
<i class="bi bi-terminal"></i> startnet.cmd
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</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">
|
<li class="nav-item">
|
||||||
<a class="nav-link {% if request.endpoint == 'clonezilla_backups' %}active{% endif %}"
|
<a class="nav-link {% if request.endpoint == 'clonezilla_backups' %}active{% endif %}"
|
||||||
href="{{ url_for('clonezilla_backups') }}">
|
href="{{ url_for('clonezilla_backups') }}">
|
||||||
|
|||||||
99
webapp/templates/shopfloor_menu.html
Normal file
99
webapp/templates/shopfloor_menu.html
Normal 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, '"')}" 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, '"')}" 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 %}
|
||||||
Reference in New Issue
Block a user