webapp: imaging progress dashboard + serial column on reports list
Adds end-to-end progress tracking for PXE imaging sessions and surfaces
each Blancco report's BIOS serial in the report list.
webapp:
* services/imaging_status.py - JSON-per-serial state store under
IMAGING_DIR (default /var/log/pxe-imaging). Atomic write via
tempfile + rename. log_tail capped at 50 lines. Merges partial
updates so clients can post just the current_stage tick.
* config.py - new IMAGING_DIR env-overridable path.
* services/csrf.py - explicit exempt list for machine-to-machine
endpoints; /imaging/status is the first entry. Air-gapped LAN;
trust-by-network for client posts.
* app.py - four new routes:
GET /imaging dashboard (renders all sessions)
POST /imaging/status client status push (JSON body)
GET /imaging/<serial>.json raw session JSON for ad-hoc polling
POST /imaging/delete/<s> clear a session from the dashboard
Also parses each Blancco XML in the /reports list to surface
system.serial + system.model columns.
* templates/imaging.html - Bootstrap dashboard with per-session
cards (state badge, progress bar, stage idx/total, mac, elapsed,
log tail). meta http-equiv refresh=5 for auto-tick.
* templates/base.html - new "Imaging Progress" nav entry.
* templates/reports.html - Serial + Model columns added.
playbook:
* shopfloor-setup/Shopfloor/lib/Send-PxeStatus.ps1 - new helper.
Dot-source this then call Send-PxeStatus -Stage X -StageIndex N
-StageTotal M from any stage script. BIOS serial via CIM, MAC via
Get-NetAdapter, pctype + machinenumber from C:\Enrollment.
Failures are swallowed to a local log so a network blip doesn't
block imaging.
* shopfloor-setup/Run-ShopfloorSetup.ps1 - dot-sources helper +
posts at three coarse milestones (start, PPKG enrollment,
handoff to Monitor-IntuneProgress).
* shopfloor-setup/gea-shopfloor-keyence/09-Setup-Keyence.ps1 -
posts at session start + after Install-FromManifest with
succeeded/failed status derived from $rc. Other 09-Setup-*.ps1
scripts can follow the same pattern.
ID is BIOS serial (stable across WinPE -> Windows transition and
across reboots, unlike hostname which is random pre-PPKG). Operator
already knows the serial of the bay they imaged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -26,6 +26,21 @@ Write-Host " Transcript: $transcriptPath"
|
|||||||
Write-Host "================================================================"
|
Write-Host "================================================================"
|
||||||
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
|
# AutoLogonCount is NOT set here. Previously we bumped it to 99/4, but
|
||||||
# Windows decrements it per-logon and at 0 clears AutoAdminLogon -- which
|
# Windows decrements it per-logon and at 0 clears AutoAdminLogon -- which
|
||||||
# nukes the lockdown-configured ShopFloor autologon later in the chain.
|
# 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'
|
$enrollScript = Join-Path $enrollDir 'run-enrollment.ps1'
|
||||||
if (Test-Path -LiteralPath $enrollScript) {
|
if (Test-Path -LiteralPath $enrollScript) {
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
|
Report-Stage -Stage 'Run-ShopfloorSetup: PPKG enrollment' -Index 4
|
||||||
Write-Host "=== Running enrollment (PPKG install) ==="
|
Write-Host "=== Running enrollment (PPKG install) ==="
|
||||||
Write-Host "NOTE: PPKG schedules a near-immediate reboot. We will cancel"
|
Write-Host "NOTE: PPKG schedules a near-immediate reboot. We will cancel"
|
||||||
Write-Host " it and hand off to Monitor-IntuneProgress -PostPpkg, which"
|
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
|
# persistent @logon sync_intune task fires on the next boot to resume
|
||||||
# tracking through device-category-assignment + lockdown.
|
# tracking through device-category-assignment + lockdown.
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
|
Report-Stage -Stage 'Run-ShopfloorSetup: handoff to Monitor-IntuneProgress' -Index 7
|
||||||
Write-Host "=== Handing off to Monitor-IntuneProgress -PostPpkg ==="
|
Write-Host "=== Handing off to Monitor-IntuneProgress -PostPpkg ==="
|
||||||
cmd /c "shutdown /a 2>nul" | Out-Null
|
cmd /c "shutdown /a 2>nul" | Out-Null
|
||||||
$monitor = Join-Path $setupDir 'Shopfloor\lib\Monitor-IntuneProgress.ps1'
|
$monitor = Join-Path $setupDir 'Shopfloor\lib\Monitor-IntuneProgress.ps1'
|
||||||
|
|||||||
77
playbook/shopfloor-setup/Shopfloor/lib/Send-PxeStatus.ps1
Normal file
77
playbook/shopfloor-setup/Shopfloor/lib/Send-PxeStatus.ps1
Normal file
@@ -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 { }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,6 +44,12 @@ Write-KeyenceLog "Running as: $([System.Security.Principal.WindowsIdentity]::Get
|
|||||||
Write-KeyenceLog "Script root: $PSScriptRoot"
|
Write-KeyenceLog "Script root: $PSScriptRoot"
|
||||||
Write-KeyenceLog "================================================================"
|
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
|
# Diagnostic dump
|
||||||
foreach ($file in @('pc-type.txt','pc-subtype.txt','machine-number.txt')) {
|
foreach ($file in @('pc-type.txt','pc-subtype.txt','machine-number.txt')) {
|
||||||
$path = "C:\Enrollment\$file"
|
$path = "C:\Enrollment\$file"
|
||||||
@@ -69,6 +75,23 @@ if (-not (Test-Path $manifestPath)) {
|
|||||||
Write-KeyenceLog "Install-FromManifest returned $rc"
|
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 "================================================================"
|
||||||
Write-KeyenceLog "=== Keyence Setup session end ==="
|
Write-KeyenceLog "=== Keyence Setup session end ==="
|
||||||
Write-KeyenceLog "================================================================"
|
Write-KeyenceLog "================================================================"
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ from lxml import etree
|
|||||||
from werkzeug.utils import secure_filename
|
from werkzeug.utils import secure_filename
|
||||||
|
|
||||||
import config
|
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.audit import audit
|
||||||
from services.csrf import init_csrf
|
from services.csrf import init_csrf
|
||||||
|
|
||||||
@@ -363,14 +363,31 @@ def blancco_reports():
|
|||||||
if os.path.isdir(config.BLANCCO_REPORTS):
|
if os.path.isdir(config.BLANCCO_REPORTS):
|
||||||
for f in sorted(os.listdir(config.BLANCCO_REPORTS), reverse=True):
|
for f in sorted(os.listdir(config.BLANCCO_REPORTS), reverse=True):
|
||||||
fpath = os.path.join(config.BLANCCO_REPORTS, f)
|
fpath = os.path.join(config.BLANCCO_REPORTS, f)
|
||||||
if os.path.isfile(fpath):
|
if not os.path.isfile(fpath):
|
||||||
|
continue
|
||||||
stat = os.stat(fpath)
|
stat = os.stat(fpath)
|
||||||
ext = os.path.splitext(f)[1].lower()
|
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({
|
reports.append({
|
||||||
"filename": f,
|
"filename": f,
|
||||||
"size": stat.st_size,
|
"size": stat.st_size,
|
||||||
"modified": stat.st_mtime,
|
"modified": stat.st_mtime,
|
||||||
"type": ext.lstrip(".").upper() or "FILE",
|
"type": ext.lstrip(".").upper() or "FILE",
|
||||||
|
"serial": serial,
|
||||||
|
"model": model,
|
||||||
})
|
})
|
||||||
return render_template(
|
return render_template(
|
||||||
"reports.html",
|
"reports.html",
|
||||||
@@ -421,6 +438,50 @@ def blancco_delete_report(filename):
|
|||||||
return redirect(url_for("blancco_reports"))
|
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/<serial>.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/<serial>", 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
|
# Routes - Enrollment Packages
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ SHARED_DIR = os.path.join(SAMBA_SHARE, "_shared")
|
|||||||
WEB_ROOT = os.environ.get("WEB_ROOT", "/var/www/html")
|
WEB_ROOT = os.environ.get("WEB_ROOT", "/var/www/html")
|
||||||
BOOT_WIM = os.path.join(WEB_ROOT, "win11", "sources", "boot.wim")
|
BOOT_WIM = os.path.join(WEB_ROOT, "win11", "sources", "boot.wim")
|
||||||
AUDIT_LOG = os.environ.get("AUDIT_LOG", "/var/log/pxe-webapp-audit.log")
|
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 -------------------------------------------------------------------
|
||||||
FLASK_SECRET_KEY = os.environ.get("FLASK_SECRET_KEY", "pxe-manager-dev-key-change-in-prod")
|
FLASK_SECRET_KEY = os.environ.get("FLASK_SECRET_KEY", "pxe-manager-dev-key-change-in-prod")
|
||||||
|
|||||||
@@ -4,6 +4,13 @@ import secrets
|
|||||||
|
|
||||||
from flask import abort, request, session
|
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():
|
def generate_csrf_token():
|
||||||
"""Return the CSRF token for the current session, creating one if needed."""
|
"""Return the CSRF token for the current session, creating one if needed."""
|
||||||
@@ -19,6 +26,8 @@ def init_csrf(app):
|
|||||||
def _validate_csrf():
|
def _validate_csrf():
|
||||||
if request.method != "POST":
|
if request.method != "POST":
|
||||||
return
|
return
|
||||||
|
if request.path in CSRF_EXEMPT_PATHS:
|
||||||
|
return
|
||||||
token = request.form.get("_csrf_token") or request.headers.get("X-CSRF-Token")
|
token = request.form.get("_csrf_token") or request.headers.get("X-CSRF-Token")
|
||||||
if not token or token != generate_csrf_token():
|
if not token or token != generate_csrf_token():
|
||||||
abort(403)
|
abort(403)
|
||||||
|
|||||||
144
webapp/services/imaging_status.py
Normal file
144
webapp/services/imaging_status.py
Normal file
@@ -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
|
||||||
@@ -121,6 +121,12 @@
|
|||||||
Image Import
|
Image Import
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if request.endpoint == 'imaging_dashboard' %}active{% endif %}"
|
||||||
|
href="{{ url_for('imaging_dashboard') }}">
|
||||||
|
Imaging Progress
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="nav-section-divider"></div>
|
<div class="nav-section-divider"></div>
|
||||||
|
|||||||
126
webapp/templates/imaging.html
Normal file
126
webapp/templates/imaging.html
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Imaging Progress - PXE Server Manager{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_head %}
|
||||||
|
<meta http-equiv="refresh" content="5">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<div>
|
||||||
|
<h2 class="mb-0">Imaging Progress</h2>
|
||||||
|
<small class="text-muted">Auto-refresh 5s. POST updates from imaging clients arrive at <code>/imaging/status</code>.</small>
|
||||||
|
</div>
|
||||||
|
<span class="badge bg-secondary fs-6">{{ sessions|length }} session{{ 's' if sessions|length != 1 }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if not sessions %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body text-center text-muted py-5">
|
||||||
|
<p class="mb-1">No imaging sessions yet.</p>
|
||||||
|
<p class="small mb-0">A PC being imaged will post status here.
|
||||||
|
Until then, nothing to show.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% 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') %}
|
||||||
|
<div class="card border-{{ border }} mb-3">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<strong>{{ s.serial or '(no serial)' }}</strong>
|
||||||
|
{% if s.hostname_target %}<code class="ms-2 small text-muted">{{ s.hostname_target }}</code>{% endif %}
|
||||||
|
{% if s.pctype %}<span class="badge bg-info text-dark ms-2">{{ s.pctype }}</span>{% endif %}
|
||||||
|
{% if s.machinenumber %}<span class="badge bg-secondary ms-1">#{{ s.machinenumber }}</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
<span class="badge bg-{{ border }}">{{ s.status or 'in_progress' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-3 mb-3 small">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="text-muted">Current stage</div>
|
||||||
|
<div><strong>{{ s.current_stage or '-' }}</strong></div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<div class="text-muted">Stage</div>
|
||||||
|
<div>{{ stage_idx }} / {{ stage_total or '?' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<div class="text-muted">Started</div>
|
||||||
|
<div><code>{{ s.started_at or '-' }}</code></div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="text-muted">Last update</div>
|
||||||
|
<div><code>{{ s.last_updated or '-' }}</code></div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<div class="text-muted">MAC</div>
|
||||||
|
<div><code class="small">{{ s.mac or '-' }}</code></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="progress mb-2" style="height: 1.4rem;">
|
||||||
|
<div class="progress-bar bg-{{ border }} {% if not is_done and not is_failed %}progress-bar-striped progress-bar-animated{% endif %}"
|
||||||
|
role="progressbar"
|
||||||
|
style="width: {{ pct }}%;"
|
||||||
|
aria-valuenow="{{ pct }}"
|
||||||
|
aria-valuemin="0"
|
||||||
|
aria-valuemax="100">{{ pct }}%</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if s.error %}
|
||||||
|
<div class="alert alert-danger small py-2 mb-2">
|
||||||
|
<strong>Error:</strong> {{ s.error }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if s.log_tail %}
|
||||||
|
<details>
|
||||||
|
<summary class="text-muted small">Log tail ({{ s.log_tail | length }} line{{ 's' if s.log_tail | length != 1 }})</summary>
|
||||||
|
<pre class="bg-light p-2 mt-2 small mb-0" style="max-height: 12rem; overflow-y: auto;">{% for line in s.log_tail %}{{ line }}
|
||||||
|
{% endfor %}</pre>
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="mt-3 text-end">
|
||||||
|
<form method="post" action="{{ url_for('imaging_delete_session', serial=s.serial) }}" style="display: inline;">
|
||||||
|
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-secondary"
|
||||||
|
onclick="return confirm('Clear session {{ s.serial }}?');">
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<div class="card mt-3">
|
||||||
|
<div class="card-body small text-muted">
|
||||||
|
<strong>How to push status from an imaging client:</strong>
|
||||||
|
<pre class="mb-0 mt-2">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"]
|
||||||
|
}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -17,6 +17,8 @@
|
|||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
<tr>
|
<tr>
|
||||||
<th>Filename</th>
|
<th>Filename</th>
|
||||||
|
<th>Serial</th>
|
||||||
|
<th>Model</th>
|
||||||
<th>Type</th>
|
<th>Type</th>
|
||||||
<th>Size</th>
|
<th>Size</th>
|
||||||
<th>Date</th>
|
<th>Date</th>
|
||||||
@@ -27,6 +29,8 @@
|
|||||||
{% for r in reports %}
|
{% for r in reports %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><code>{{ r.filename }}</code></td>
|
<td><code>{{ r.filename }}</code></td>
|
||||||
|
<td>{% if r.serial %}<code>{{ r.serial }}</code>{% else %}<span class="text-muted small">-</span>{% endif %}</td>
|
||||||
|
<td class="small">{{ r.model or '-' }}</td>
|
||||||
<td><span class="badge bg-info text-dark">{{ r.type }}</span></td>
|
<td><span class="badge bg-info text-dark">{{ r.type }}</span></td>
|
||||||
<td>
|
<td>
|
||||||
{% if r.size > 1048576 %}
|
{% if r.size > 1048576 %}
|
||||||
|
|||||||
Reference in New Issue
Block a user