diff --git a/playbook/shopfloor-setup/Run-ShopfloorSetup.ps1 b/playbook/shopfloor-setup/Run-ShopfloorSetup.ps1 index c87c46e..0b197c9 100644 --- a/playbook/shopfloor-setup/Run-ShopfloorSetup.ps1 +++ b/playbook/shopfloor-setup/Run-ShopfloorSetup.ps1 @@ -26,6 +26,21 @@ Write-Host " Transcript: $transcriptPath" Write-Host "================================================================" Write-Host "" +# Imaging-progress reporter. Posts coarse stage updates to the PXE webapp +# at http://10.9.100.1:9009/imaging/status so the operator can watch +# progress in a browser. Best-effort: failures never block imaging. +$pxeStatusLib = Join-Path $PSScriptRoot 'Shopfloor\lib\Send-PxeStatus.ps1' +if (Test-Path $pxeStatusLib) { + try { . $pxeStatusLib } catch { Write-Warning "Send-PxeStatus load failed: $_" } +} +function Report-Stage { + param([string]$Stage, [int]$Index, [int]$Total = 8, [string]$Status = 'in_progress', [string]$Error_ = '') + if (Get-Command Send-PxeStatus -ErrorAction SilentlyContinue) { + Send-PxeStatus -Stage $Stage -StageIndex $Index -StageTotal $Total -Status $Status -Error_ $Error_ + } +} +Report-Stage -Stage 'Run-ShopfloorSetup: starting' -Index 1 + # AutoLogonCount is NOT set here. Previously we bumped it to 99/4, but # Windows decrements it per-logon and at 0 clears AutoAdminLogon -- which # nukes the lockdown-configured ShopFloor autologon later in the chain. @@ -452,6 +467,7 @@ if (-not $hasWifi -and -not $hasDefaultRoute) { $enrollScript = Join-Path $enrollDir 'run-enrollment.ps1' if (Test-Path -LiteralPath $enrollScript) { Write-Host "" + Report-Stage -Stage 'Run-ShopfloorSetup: PPKG enrollment' -Index 4 Write-Host "=== Running enrollment (PPKG install) ===" Write-Host "NOTE: PPKG schedules a near-immediate reboot. We will cancel" Write-Host " it and hand off to Monitor-IntuneProgress -PostPpkg, which" @@ -466,6 +482,7 @@ if (Test-Path -LiteralPath $enrollScript) { # persistent @logon sync_intune task fires on the next boot to resume # tracking through device-category-assignment + lockdown. Write-Host "" + Report-Stage -Stage 'Run-ShopfloorSetup: handoff to Monitor-IntuneProgress' -Index 7 Write-Host "=== Handing off to Monitor-IntuneProgress -PostPpkg ===" cmd /c "shutdown /a 2>nul" | Out-Null $monitor = Join-Path $setupDir 'Shopfloor\lib\Monitor-IntuneProgress.ps1' diff --git a/playbook/shopfloor-setup/Shopfloor/lib/Send-PxeStatus.ps1 b/playbook/shopfloor-setup/Shopfloor/lib/Send-PxeStatus.ps1 new file mode 100644 index 0000000..cd9b284 --- /dev/null +++ b/playbook/shopfloor-setup/Shopfloor/lib/Send-PxeStatus.ps1 @@ -0,0 +1,77 @@ +# Send-PxeStatus.ps1 +# Posts a coarse-grained progress update to the PXE webapp's /imaging/status +# endpoint. Never blocks imaging on a failed push (try/catch with no rethrow). +# Air-gapped LAN; no auth - the webapp endpoint is CSRF-exempt for machine +# clients (see services/csrf.py). + +function Send-PxeStatus { + [CmdletBinding()] + param( + [Parameter(Mandatory=$true)] + [string]$Stage, + [int]$StageIndex = 0, + [int]$StageTotal = 0, + [ValidateSet('in_progress','succeeded','failed')] + [string]$Status = 'in_progress', + [string]$Error_ = '', + [string[]]$LogLines = @(), + [string]$PxeServer = '10.9.100.1', + [int]$Port = 9009, + [int]$TimeoutSec = 5 + ) + + # Get serial early; if WMI fails we still want to push under a best-effort id. + $serial = $null + try { + $serial = (Get-CimInstance -ClassName Win32_BIOS -ErrorAction Stop).SerialNumber + } catch { + try { $serial = (Get-WmiObject -Class Win32_BIOS -ErrorAction Stop).SerialNumber } catch { } + } + if ([string]::IsNullOrWhiteSpace($serial)) { $serial = $env:COMPUTERNAME } + $serial = ($serial -as [string]).Trim() + + # MAC of the first up wired adapter (best-effort; PXE servers see this MAC in DHCP). + $mac = $null + try { + $nic = Get-NetAdapter -Physical | Where-Object { $_.Status -eq 'Up' -and $_.MediaType -eq '802.3' } | Select-Object -First 1 + if ($nic) { $mac = $nic.MacAddress -replace '-', ':' } + } catch { } + + # Enrollment context files (present after startnet.cmd stages them). + $pctype = '' + $machno = '' + if (Test-Path 'C:\Enrollment\pc-type.txt') { $pctype = (Get-Content 'C:\Enrollment\pc-type.txt' -ErrorAction SilentlyContinue | Select-Object -First 1).Trim() } + if (Test-Path 'C:\Enrollment\machine-number.txt') { $machno = (Get-Content 'C:\Enrollment\machine-number.txt' -ErrorAction SilentlyContinue | Select-Object -First 1).Trim() } + + $payload = @{ + serial = $serial + mac = $mac + hostname_target = $env:COMPUTERNAME + pctype = $pctype + machinenumber = $machno + current_stage = $Stage + stage_index = $StageIndex + stage_total = $StageTotal + status = $Status + } + if ($Error_) { $payload.error = $Error_ } + if ($LogLines) { $payload.log_lines = $LogLines } + + $body = $payload | ConvertTo-Json -Compress + $uri = "http://${PxeServer}:${Port}/imaging/status" + + try { + Invoke-WebRequest -Uri $uri -Method POST ` + -Body $body -ContentType 'application/json' ` + -UseBasicParsing -TimeoutSec $TimeoutSec ` + -ErrorAction Stop | Out-Null + } catch { + # Never block imaging on a failed status push. Write to local log only. + try { + $logDir = 'C:\Logs' + if (-not (Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir -Force | Out-Null } + "$(Get-Date -Format s) Send-PxeStatus failed: $($_.Exception.Message)" | + Out-File -FilePath (Join-Path $logDir 'send-pxe-status.log') -Append -Encoding utf8 + } catch { } + } +} diff --git a/playbook/shopfloor-setup/gea-shopfloor-keyence/09-Setup-Keyence.ps1 b/playbook/shopfloor-setup/gea-shopfloor-keyence/09-Setup-Keyence.ps1 index 2b3154c..96a83ee 100644 --- a/playbook/shopfloor-setup/gea-shopfloor-keyence/09-Setup-Keyence.ps1 +++ b/playbook/shopfloor-setup/gea-shopfloor-keyence/09-Setup-Keyence.ps1 @@ -44,6 +44,12 @@ Write-KeyenceLog "Running as: $([System.Security.Principal.WindowsIdentity]::Get Write-KeyenceLog "Script root: $PSScriptRoot" Write-KeyenceLog "================================================================" +# Status push to PXE webapp - best-effort, never blocks imaging. +$pxeStatusLib = Join-Path $PSScriptRoot '..\Shopfloor\lib\Send-PxeStatus.ps1' +if (Test-Path $pxeStatusLib) { + try { . $pxeStatusLib; Send-PxeStatus -Stage '09-Setup-Keyence: starting' -StageIndex 5 -StageTotal 8 } catch { } +} + # Diagnostic dump foreach ($file in @('pc-type.txt','pc-subtype.txt','machine-number.txt')) { $path = "C:\Enrollment\$file" @@ -69,6 +75,23 @@ if (-not (Test-Path $manifestPath)) { Write-KeyenceLog "Install-FromManifest returned $rc" } +# ============================================================================ +# Step 2: OpenText auto-start at login (HostExplorer "WJ Shopfloor" session) +# ============================================================================ +$autoStartLib = Join-Path $PSScriptRoot '..\Shopfloor\lib\Set-OpenTextAutoStart.ps1' +if (Test-Path -LiteralPath $autoStartLib) { + Write-KeyenceLog "Calling $autoStartLib" + & $autoStartLib +} else { + Write-KeyenceLog "Set-OpenTextAutoStart.ps1 not found at $autoStartLib - OpenText auto-start NOT configured" 'WARN' +} + +if (Get-Command Send-PxeStatus -ErrorAction SilentlyContinue) { + $finalStatus = if ($rc -eq 0) { 'in_progress' } else { 'failed' } + $finalErr = if ($rc -ne 0) { "Install-FromManifest exit $rc" } else { '' } + Send-PxeStatus -Stage '09-Setup-Keyence: complete' -StageIndex 6 -StageTotal 8 -Status $finalStatus -Error_ $finalErr +} + Write-KeyenceLog "================================================================" Write-KeyenceLog "=== Keyence Setup session end ===" Write-KeyenceLog "================================================================" diff --git a/webapp/app.py b/webapp/app.py index 96d39a4..3b13850 100644 --- a/webapp/app.py +++ b/webapp/app.py @@ -33,7 +33,7 @@ from lxml import etree from werkzeug.utils import secure_filename import config -from services import blancco_report, deploy, fs, images, system, unattend, wim +from services import blancco_report, deploy, fs, images, imaging_status, system, unattend, wim from services.audit import audit from services.csrf import init_csrf @@ -363,15 +363,32 @@ def blancco_reports(): if os.path.isdir(config.BLANCCO_REPORTS): for f in sorted(os.listdir(config.BLANCCO_REPORTS), reverse=True): fpath = os.path.join(config.BLANCCO_REPORTS, f) - if os.path.isfile(fpath): - stat = os.stat(fpath) - ext = os.path.splitext(f)[1].lower() - reports.append({ - "filename": f, - "size": stat.st_size, - "modified": stat.st_mtime, - "type": ext.lstrip(".").upper() or "FILE", - }) + if not os.path.isfile(fpath): + continue + stat = os.stat(fpath) + ext = os.path.splitext(f)[1].lower() + # Surface BIOS serial + system model so operators can find a + # report for a specific bay without opening each one. Parse + # cost is one XML walk per .xml report (~50KB-3MB each; + # negligible at fleet sizes). + serial = "" + model = "" + if ext == ".xml": + try: + data = blancco_report.parse(fpath) + sysinfo = (data.get("hardware") or {}).get("system") or {} + serial = sysinfo.get("serial", "") or "" + model = sysinfo.get("model", "") or "" + except Exception: + pass + reports.append({ + "filename": f, + "size": stat.st_size, + "modified": stat.st_mtime, + "type": ext.lstrip(".").upper() or "FILE", + "serial": serial, + "model": model, + }) return render_template( "reports.html", reports=reports, @@ -421,6 +438,50 @@ def blancco_delete_report(filename): return redirect(url_for("blancco_reports")) +# --------------------------------------------------------------------------- +# Routes - Imaging Progress +# --------------------------------------------------------------------------- + +@app.route("/imaging") +def imaging_dashboard(): + sessions = imaging_status.list_sessions() + return render_template("imaging.html", sessions=sessions) + + +@app.route("/imaging/status", methods=["POST"]) +def imaging_status_post(): + # CSRF-exempt machine-to-machine endpoint; see services/csrf.py exempt list. + payload = request.get_json(silent=True) or {} + if not payload.get("serial"): + return jsonify({"error": "missing serial"}), 400 + try: + state = imaging_status.update_session(payload) + except Exception as ex: + audit("IMAGING_STATUS_ERROR", str(ex)) + return jsonify({"error": str(ex)}), 500 + return jsonify({"ok": True, "serial": state["serial"]}), 200 + + +@app.route("/imaging/.json") +def imaging_session_json(serial): + serial = secure_filename(serial) + s = imaging_status.get_session(serial) + if not s: + return jsonify({"error": "not found"}), 404 + return jsonify(s) + + +@app.route("/imaging/delete/", methods=["POST"]) +def imaging_delete_session(serial): + serial = secure_filename(serial) + if imaging_status.delete_session(serial): + audit("IMAGING_DELETE", serial) + flash(f"Cleared imaging session {serial}.", "success") + else: + flash(f"Session not found: {serial}", "danger") + return redirect(url_for("imaging_dashboard")) + + # --------------------------------------------------------------------------- # Routes - Enrollment Packages # --------------------------------------------------------------------------- diff --git a/webapp/config.py b/webapp/config.py index 7a0cde7..56189e5 100644 --- a/webapp/config.py +++ b/webapp/config.py @@ -20,6 +20,7 @@ SHARED_DIR = os.path.join(SAMBA_SHARE, "_shared") WEB_ROOT = os.environ.get("WEB_ROOT", "/var/www/html") BOOT_WIM = os.path.join(WEB_ROOT, "win11", "sources", "boot.wim") AUDIT_LOG = os.environ.get("AUDIT_LOG", "/var/log/pxe-webapp-audit.log") +IMAGING_DIR = os.environ.get("IMAGING_DIR", "/var/log/pxe-imaging") # --- Flask ------------------------------------------------------------------- FLASK_SECRET_KEY = os.environ.get("FLASK_SECRET_KEY", "pxe-manager-dev-key-change-in-prod") diff --git a/webapp/services/csrf.py b/webapp/services/csrf.py index b1a60d3..10b30ff 100644 --- a/webapp/services/csrf.py +++ b/webapp/services/csrf.py @@ -4,6 +4,13 @@ import secrets from flask import abort, request, session +# Endpoint paths exempt from CSRF (machine-to-machine POSTs from PXE clients +# that have no browser session to carry a token). Air-gapped LAN; treated as +# trust-by-network. Keep the list short and explicit. +CSRF_EXEMPT_PATHS = { + "/imaging/status", +} + def generate_csrf_token(): """Return the CSRF token for the current session, creating one if needed.""" @@ -19,6 +26,8 @@ def init_csrf(app): def _validate_csrf(): if request.method != "POST": return + if request.path in CSRF_EXEMPT_PATHS: + return token = request.form.get("_csrf_token") or request.headers.get("X-CSRF-Token") if not token or token != generate_csrf_token(): abort(403) diff --git a/webapp/services/imaging_status.py b/webapp/services/imaging_status.py new file mode 100644 index 0000000..138060e --- /dev/null +++ b/webapp/services/imaging_status.py @@ -0,0 +1,144 @@ +"""PXE imaging progress tracking. + +Imaging clients POST coarse-grained status updates to /imaging/status as +they progress through WIM apply -> drivers -> first boot -> PPKG -> per-PC +shopfloor setup. Each session is keyed by the BIOS serial number (stable +across the WinPE -> Windows transition; survives a target hostname change). + +Storage is one JSON file per serial under IMAGING_DIR. Atomic write via +tempfile + rename. Reads merge the new payload into existing state so +clients can post partial updates (just the current_stage + log_tail tick). +""" +from __future__ import annotations + +import json +import os +import re +import tempfile +from datetime import datetime, timezone +from typing import Optional + +import config + +# How many recent log lines to keep per session. +LOG_TAIL_MAX = 50 +# Sessions older than this are considered stale and dropped from the dashboard +# "active" list. Still readable individually. +ACTIVE_WINDOW_HOURS = 6 + +# Filenames are derived from serial; sanitize to avoid path traversal / +# weird filesystem characters. Anything outside [A-Za-z0-9._-] becomes _. +_SAFE_SERIAL = re.compile(r"[^A-Za-z0-9._-]") + + +def _ensure_dir(): + os.makedirs(config.IMAGING_DIR, exist_ok=True) + + +def _path_for(serial: str) -> str: + safe = _SAFE_SERIAL.sub("_", serial.strip()) or "unknown" + return os.path.join(config.IMAGING_DIR, f"{safe}.json") + + +def _now_iso() -> str: + return datetime.now().astimezone().isoformat(timespec="seconds") + + +def update_session(payload: dict) -> dict: + """Merge `payload` into the JSON file for payload['serial']. + + payload must include 'serial'. All other fields are optional; whatever is + present overwrites the existing field. log_tail is appended to (capped). + Returns the resulting full state. + """ + serial = (payload.get("serial") or "").strip() + if not serial: + raise ValueError("payload missing 'serial'") + + _ensure_dir() + path = _path_for(serial) + + state: dict = {} + if os.path.isfile(path): + try: + with open(path, "r") as f: + state = json.load(f) + except (json.JSONDecodeError, OSError): + state = {} + + if not state: + state = { + "serial": serial, + "started_at": _now_iso(), + "log_tail": [], + } + + # Append any new log lines (preserve old; cap to LOG_TAIL_MAX). + new_lines = payload.pop("log_lines", None) + if new_lines: + if isinstance(new_lines, str): + new_lines = [new_lines] + tail = list(state.get("log_tail", [])) + tail.extend(new_lines) + state["log_tail"] = tail[-LOG_TAIL_MAX:] + + for key, value in payload.items(): + if value is None or value == "": + continue + state[key] = value + + state["last_updated"] = _now_iso() + if "status" not in state: + state["status"] = "in_progress" + + fd, tmp = tempfile.mkstemp(dir=config.IMAGING_DIR, prefix=".tmp-", suffix=".json") + try: + with os.fdopen(fd, "w") as f: + json.dump(state, f, indent=2) + os.replace(tmp, path) + except Exception: + try: + os.unlink(tmp) + except OSError: + pass + raise + return state + + +def list_sessions() -> list[dict]: + """Return all sessions sorted by last_updated desc.""" + _ensure_dir() + out: list[dict] = [] + for name in os.listdir(config.IMAGING_DIR): + if not name.endswith(".json") or name.startswith(".tmp-"): + continue + path = os.path.join(config.IMAGING_DIR, name) + try: + with open(path, "r") as f: + out.append(json.load(f)) + except (json.JSONDecodeError, OSError): + continue + out.sort(key=lambda s: s.get("last_updated", ""), reverse=True) + return out + + +def get_session(serial: str) -> Optional[dict]: + path = _path_for(serial) + if not os.path.isfile(path): + return None + try: + with open(path, "r") as f: + return json.load(f) + except (json.JSONDecodeError, OSError): + return None + + +def delete_session(serial: str) -> bool: + path = _path_for(serial) + if not os.path.isfile(path): + return False + try: + os.unlink(path) + return True + except OSError: + return False diff --git a/webapp/templates/base.html b/webapp/templates/base.html index f9fff21..e185823 100644 --- a/webapp/templates/base.html +++ b/webapp/templates/base.html @@ -121,6 +121,12 @@ Image Import + diff --git a/webapp/templates/imaging.html b/webapp/templates/imaging.html new file mode 100644 index 0000000..d3dd648 --- /dev/null +++ b/webapp/templates/imaging.html @@ -0,0 +1,126 @@ +{% extends "base.html" %} +{% block title %}Imaging Progress - PXE Server Manager{% endblock %} + +{% block extra_head %} + +{% endblock %} + +{% block content %} + +
+
+

Imaging Progress

+ Auto-refresh 5s. POST updates from imaging clients arrive at /imaging/status. +
+ {{ sessions|length }} session{{ 's' if sessions|length != 1 }} +
+ +{% if not sessions %} +
+
+

No imaging sessions yet.

+

A PC being imaged will post status here. + Until then, nothing to show.

+
+
+{% endif %} + +{% for s in sessions %} + {% set stage_idx = s.stage_index | int(0) %} + {% set stage_total = s.stage_total | int(0) %} + {% set pct = 100 if s.status == 'succeeded' else ((stage_idx / stage_total * 100) | round(0, 'floor')) if stage_total > 0 else 0 %} + {% set is_failed = s.status == 'failed' %} + {% set is_done = s.status == 'succeeded' %} + {% set border = 'danger' if is_failed else ('success' if is_done else 'primary') %} +
+
+
+ {{ s.serial or '(no serial)' }} + {% if s.hostname_target %}{{ s.hostname_target }}{% endif %} + {% if s.pctype %}{{ s.pctype }}{% endif %} + {% if s.machinenumber %}#{{ s.machinenumber }}{% endif %} +
+ {{ s.status or 'in_progress' }} +
+
+
+
+
Current stage
+
{{ s.current_stage or '-' }}
+
+
+
Stage
+
{{ stage_idx }} / {{ stage_total or '?' }}
+
+
+
Started
+
{{ s.started_at or '-' }}
+
+
+
Last update
+
{{ s.last_updated or '-' }}
+
+
+
MAC
+
{{ s.mac or '-' }}
+
+
+ +
+
{{ pct }}%
+
+ + {% if s.error %} +
+ Error: {{ s.error }} +
+ {% endif %} + + {% if s.log_tail %} +
+ Log tail ({{ s.log_tail | length }} line{{ 's' if s.log_tail | length != 1 }}) +
{% for line in s.log_tail %}{{ line }}
+{% endfor %}
+
+ {% endif %} + +
+
+ + +
+
+
+
+{% endfor %} + +
+
+ How to push status from an imaging client: +
POST http://10.9.100.1:9009/imaging/status
+Content-Type: application/json
+
+{
+  "serial": "4HBLF33",
+  "mac": "e4:54:e8:dc:b1:f0",
+  "hostname_target": "EDNMG3D4",
+  "pctype": "gea-shopfloor-keyence",
+  "machinenumber": "9999",
+  "current_stage": "Run-ShopfloorSetup: 09-Setup-Keyence",
+  "stage_index": 7,
+  "stage_total": 9,
+  "status": "in_progress",
+  "log_lines": ["last few log lines from the stage"]
+}
+
+
+ +{% endblock %} diff --git a/webapp/templates/reports.html b/webapp/templates/reports.html index c1e5e1a..b42e5bf 100644 --- a/webapp/templates/reports.html +++ b/webapp/templates/reports.html @@ -17,6 +17,8 @@ Filename + Serial + Model Type Size Date @@ -27,6 +29,8 @@ {% for r in reports %} {{ r.filename }} + {% if r.serial %}{{ r.serial }}{% else %}-{% endif %} + {{ r.model or '-' }} {{ r.type }} {% if r.size > 1048576 %}