webapp: imaging UX overhaul + image management CRUD

Imaging dashboard
- services/imaging_log_tail.py: parses dnsmasq leases, Apache access log,
  Samba per-host log files, and dnsmasq syslog (DHCP/TFTP). Synthesizes
  inferred sessions keyed by MAC for bays that have only touched the boot
  chain but not yet pushed to /imaging/status. Active window 90 min.
- imaging_status.list_sessions() merges inferred sessions into the dashboard
  list. Real client-pushed sessions win for the same MAC.
- imaging_status: stage_history field tracks every stage transition (capped
  30); sidecar .log file per serial records every log_lines push uncapped
  (read_full_log() caps detail-page response to 1 MB).
- delete_session/delete_all_sessions clean up sidecar .log too.
- New SSE endpoint /imaging/stream emits a session-list hash every 5s.
  Client fetches /imaging/tiles (HTML partial) on hash change and swaps
  #imaging-tiles innerHTML. Polling fallback at 15s if SSE drops.
- Tile-swap preserves scroll, filter input, expanded state via localStorage,
  and any LAPS input the operator is mid-pasting (swap skipped when a
  laps-input is focused).
- imaging.html: removed 15s location.reload(). Added live-status dot in
  header (gray idle / green SSE connected / red SSE lost).
- _imaging_tiles.html: shared partial used by both /imaging full render and
  /imaging/tiles SSE refresh. Inferred bays render with yellow border +
  log-inferred badge + no progress bar (stage_index inference is coarse).
- imaging_detail.html (new): per-bay forensics page at /imaging/session/
  <serial>. Session metadata grid, stage timeline table, full sidecar log
  with truncation indicator, Copy-support-summary button. Linked from each
  client-pushed tile.
- qr-render.js exposes window.renderAllQRs() so the SSE swap can re-render
  Intune device-ID QRs in the swapped-in tiles.

Image management
- services/image_registry.py: JSON registry of image types at
  {SAMBA_SHARE}/image-registry.json. Bootstraps from baked-in
  config.IMAGE_TYPES on first run. create/clone/delete/rename_friendly
  mutate the file then call reload() which rewrites config.IMAGE_TYPES +
  config.FRIENDLY_NAMES in place. Sidebar reflects on next request.
- app.py routes: /images/new, /images/<t>/clone, /images/<t>/delete (with
  optional content-wipe checkbox), /images/<t>/rename.
- dashboard.html: + New image type button + Clone/Delete per row, all in
  Bootstrap modals with confirmation copy.
- Clone copies Deploy/ tree but preserves symlinks to shared dirs (Out-of-
  box Drivers, Operating Systems, Packages) so disk usage stays low.
- Delete with content checked unlinks symlinks (does not follow into shared
  dirs).

Driver / package upload + orphan adoption
- services/images.py: upload_driver, adopt_orphan, remove_orphans,
  upload_package. Filename sanitization blocks path traversal.
- app.py routes: /images/<t>/drivers/upload, /images/<t>/drivers/adopt,
  /images/<t>/drivers/orphans/delete, /images/<t>/packages/upload.
- image_config.html: Upload .zip button + modal on Drivers section. Orphan
  drivers card-footer rebuilt as interactive list with per-row Adopt inline
  form (family + destinationDir inputs) and bulk select+delete.
- Upload .zip on Packages section with optional destinationDir field that
  appends a packages.json entry.

Configuration
- config.py: new env vars DNSMASQ_LEASES, APACHE_ACCESS_LOG, SAMBA_LOG_DIR,
  DNSMASQ_SYSLOG for the log-tailer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-05-30 13:21:06 -04:00
parent c74148a222
commit 69a1682a7f
12 changed files with 2034 additions and 251 deletions

View File

@@ -12,15 +12,18 @@ This file is the route surface; most logic lives in ``services/``:
services.wim - boot.wim startnet.cmd extract/update via wimtools services.wim - boot.wim startnet.cmd extract/update via wimtools
""" """
import hashlib
import json import json
import os import os
import shutil import shutil
import tempfile import tempfile
import time
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from flask import ( from flask import (
Flask, Flask,
Response,
abort, abort,
flash, flash,
jsonify, jsonify,
@@ -28,13 +31,24 @@ from flask import (
render_template, render_template,
request, request,
send_file, send_file,
stream_with_context,
url_for, url_for,
) )
from lxml import etree 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, imaging_status, system, unattend, wim from services import (
blancco_report,
deploy,
fs,
image_registry,
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
@@ -44,6 +58,10 @@ app.config["MAX_CONTENT_LENGTH"] = config.MAX_CONTENT_LENGTH
init_csrf(app) init_csrf(app)
# Pull IMAGE_TYPES/FRIENDLY_NAMES from the registry file (created from the
# baked-in config.py defaults on first run). Mutates config.* in place.
image_registry.reload()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Routes - pages # Routes - pages
@@ -281,6 +299,171 @@ def image_config_save(image_type):
return redirect(url_for("image_config", image_type=image_type)) return redirect(url_for("image_config", image_type=image_type))
# ---------------------------------------------------------------------------
# Routes - Driver/package upload + orphan adoption
# ---------------------------------------------------------------------------
@app.route("/images/<image_type>/drivers/upload", methods=["POST"])
def images_drivers_upload(image_type):
if image_type not in config.IMAGE_TYPES:
flash(f"Unknown image type: {image_type}", "danger")
return redirect(url_for("dashboard"))
f = request.files.get("driver_file")
if not f or not f.filename:
flash("No file selected.", "danger")
return redirect(url_for("image_config", image_type=image_type))
family = (request.form.get("family") or "").strip()
dest = (request.form.get("destination_dir") or "").strip()
overwrite = request.form.get("overwrite") == "1"
try:
rec = images.upload_driver(image_type, f, family=family,
destination_dir=dest, overwrite=overwrite)
audit("DRIVER_UPLOAD", f"{image_type}/{rec['filename']} registered={rec['registered']}")
msg = f"Uploaded {rec['filename']}."
msg += " Registered to family in HardwareDriver.json." if rec["registered"] else " Now in orphans list until adopted."
flash(msg, "success")
except (ValueError, FileExistsError, FileNotFoundError) as ex:
flash(str(ex), "danger")
except Exception as ex:
flash(f"Upload failed: {ex}", "danger")
return redirect(url_for("image_config", image_type=image_type))
@app.route("/images/<image_type>/drivers/adopt", methods=["POST"])
def images_drivers_adopt(image_type):
if image_type not in config.IMAGE_TYPES:
flash(f"Unknown image type: {image_type}", "danger")
return redirect(url_for("dashboard"))
filename = (request.form.get("filename") or "").strip()
family = (request.form.get("family") or "").strip()
dest = (request.form.get("destination_dir") or "").strip()
try:
rec = images.adopt_orphan(image_type, filename, family, dest)
audit("DRIVER_ADOPT", f"{image_type}/{rec['filename']} family={family}")
if rec.get("already_registered"):
flash(f"{rec['filename']} was already registered.", "info")
else:
flash(f"Adopted {rec['filename']} into HardwareDriver.json.", "success")
except (ValueError, FileNotFoundError) as ex:
flash(str(ex), "danger")
except Exception as ex:
flash(f"Adopt failed: {ex}", "danger")
return redirect(url_for("image_config", image_type=image_type))
@app.route("/images/<image_type>/drivers/orphans/delete", methods=["POST"])
def images_drivers_orphans_delete(image_type):
if image_type not in config.IMAGE_TYPES:
flash(f"Unknown image type: {image_type}", "danger")
return redirect(url_for("dashboard"))
# Filenames come as repeated form fields (one per checkbox).
filenames = request.form.getlist("filename")
if not filenames:
flash("No files selected for removal.", "warning")
return redirect(url_for("image_config", image_type=image_type))
rec = images.remove_orphans(image_type, filenames)
audit("DRIVER_ORPHAN_REMOVE", f"{image_type} removed={len(rec['removed'])} missing={len(rec['missing'])}")
parts = []
if rec["removed"]:
parts.append(f"Removed {len(rec['removed'])} orphan(s).")
if rec["missing"]:
parts.append(f"{len(rec['missing'])} not found / errored.")
flash(" ".join(parts) or "No-op.", "success" if rec["removed"] else "warning")
return redirect(url_for("image_config", image_type=image_type))
@app.route("/images/<image_type>/packages/upload", methods=["POST"])
def images_packages_upload(image_type):
if image_type not in config.IMAGE_TYPES:
flash(f"Unknown image type: {image_type}", "danger")
return redirect(url_for("dashboard"))
f = request.files.get("package_file")
if not f or not f.filename:
flash("No file selected.", "danger")
return redirect(url_for("image_config", image_type=image_type))
dest = (request.form.get("destination_dir") or "").strip()
overwrite = request.form.get("overwrite") == "1"
try:
rec = images.upload_package(image_type, f, destination_dir=dest, overwrite=overwrite)
audit("PACKAGE_UPLOAD", f"{image_type}/{rec['filename']} registered={rec['registered']}")
msg = f"Uploaded {rec['filename']}."
msg += " Registered in packages.json." if rec["registered"] else " Not registered (no destination_dir provided)."
flash(msg, "success")
except (ValueError, FileExistsError) as ex:
flash(str(ex), "danger")
except Exception as ex:
flash(f"Upload failed: {ex}", "danger")
return redirect(url_for("image_config", image_type=image_type))
# ---------------------------------------------------------------------------
# Routes - Image type CRUD (registry-backed)
# ---------------------------------------------------------------------------
@app.route("/images/new", methods=["POST"])
def images_new():
key = (request.form.get("key") or "").strip().lower()
friendly_name = (request.form.get("friendly_name") or "").strip()
try:
rec = image_registry.create(key, friendly_name)
audit("IMAGE_REGISTRY_CREATE", f"{rec['key']} ({rec['friendly_name']})")
flash(f"Created image type {rec['key']}.", "success")
except image_registry.RegistryError as ex:
flash(str(ex), "danger")
return redirect(url_for("dashboard"))
@app.route("/images/<image_type>/clone", methods=["POST"])
def images_clone(image_type):
if image_type not in config.IMAGE_TYPES:
flash(f"Unknown source image type: {image_type}", "danger")
return redirect(url_for("dashboard"))
dst_key = (request.form.get("dst_key") or "").strip().lower()
friendly_name = (request.form.get("friendly_name") or "").strip() or None
try:
rec = image_registry.clone(image_type, dst_key, friendly_name)
audit("IMAGE_REGISTRY_CLONE", f"{image_type} -> {rec['key']}")
flash(f"Cloned {image_type} -> {rec['key']}.", "success")
except image_registry.RegistryError as ex:
flash(str(ex), "danger")
except Exception as ex:
flash(f"Clone failed: {ex}", "danger")
return redirect(url_for("dashboard"))
@app.route("/images/<image_type>/delete", methods=["POST"])
def images_delete(image_type):
if image_type not in config.IMAGE_TYPES:
flash(f"Unknown image type: {image_type}", "danger")
return redirect(url_for("dashboard"))
delete_content = request.form.get("delete_content") == "1"
try:
rec = image_registry.delete(image_type, delete_content=delete_content)
audit("IMAGE_REGISTRY_DELETE", f"{rec['key']} content={rec['removed_content']}")
msg = f"Removed image type {rec['key']} from registry."
if rec["removed_content"]:
msg += " On-disk content wiped."
flash(msg, "success")
except image_registry.RegistryError as ex:
flash(str(ex), "danger")
return redirect(url_for("dashboard"))
@app.route("/images/<image_type>/rename", methods=["POST"])
def images_rename(image_type):
if image_type not in config.IMAGE_TYPES:
flash(f"Unknown image type: {image_type}", "danger")
return redirect(url_for("dashboard"))
friendly_name = (request.form.get("friendly_name") or "").strip()
try:
rec = image_registry.rename_friendly(image_type, friendly_name)
audit("IMAGE_REGISTRY_RENAME", f"{rec['key']} -> {rec['friendly_name']}")
flash(f"Renamed {rec['key']} to '{rec['friendly_name']}'.", "success")
except image_registry.RegistryError as ex:
flash(str(ex), "danger")
return redirect(url_for("dashboard"))
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Routes - Clonezilla Backups # Routes - Clonezilla Backups
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -462,6 +645,75 @@ def imaging_dashboard():
return render_template("imaging.html", sessions=sessions) return render_template("imaging.html", sessions=sessions)
@app.route("/imaging/tiles")
def imaging_tiles_partial():
"""HTML fragment of the per-bay tile loop only, used by the dashboard's
SSE/polling refresh to swap #imaging-tiles innerHTML without a full page
reload."""
sessions = imaging_status.list_sessions()
return render_template("_imaging_tiles.html", sessions=sessions)
def _sessions_hash() -> str:
"""Compact fingerprint of the current session list. Used by the SSE
stream to detect changes without sending the full payload. Hashing
(serial-or-key, status, stage_index, current_stage, last_updated) covers
every field the dashboard renders prominently."""
sessions = imaging_status.list_sessions()
h = hashlib.sha256()
for s in sessions:
key = s.get("serial") or f"{s.get('mac','')}-{s.get('ip','')}"
h.update(repr((
key,
s.get("source", "client"),
s.get("status", ""),
s.get("stage_index", 0),
s.get("current_stage", ""),
s.get("last_updated", ""),
s.get("laps_password", "") and "1" or "0",
)).encode())
return h.hexdigest()[:16]
@app.route("/imaging/stream")
def imaging_stream():
"""Server-Sent Events stream of session-list change pings.
Emits one JSON event every SSE_PING_INTERVAL seconds. When the hash
changes from the previously sent value, the client fetches /imaging/tiles
and re-renders. A keepalive heartbeat is sent on the same cadence so
intermediate proxies don't close the connection.
Single-threaded dev server can only serve one SSE client at a time. The
live PXE box runs gunicorn with multiple workers (see playbook) so this
is fine in production.
"""
SSE_PING_INTERVAL = 5 # seconds between hash checks
SSE_MAX_DURATION = 600 # cap connection length so the worker recycles
@stream_with_context
def gen():
start = time.time()
last = None
while time.time() - start < SSE_MAX_DURATION:
try:
cur = _sessions_hash()
except Exception as ex:
yield f": error {ex}\n\n"
cur = last
payload = json.dumps({"hash": cur, "ts": int(time.time())})
yield f"data: {payload}\n\n"
last = cur
time.sleep(SSE_PING_INTERVAL)
headers = {
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no", # disable nginx/apache proxy buffering
"Connection": "keep-alive",
}
return Response(gen(), mimetype="text/event-stream", headers=headers)
@app.route("/imaging/status", methods=["POST"]) @app.route("/imaging/status", methods=["POST"])
def imaging_status_post(): def imaging_status_post():
# CSRF-exempt machine-to-machine endpoint; see services/csrf.py exempt list. # CSRF-exempt machine-to-machine endpoint; see services/csrf.py exempt list.
@@ -485,6 +737,25 @@ def imaging_session_json(serial):
return jsonify(s) return jsonify(s)
@app.route("/imaging/session/<serial>")
def imaging_session_detail(serial):
"""Per-bay forensics page: stage timeline, full sidecar log, all session
metadata. Linked from the dashboard tile. Returns 404 if no session JSON
exists for the serial."""
serial = secure_filename(serial)
s = imaging_status.get_session(serial)
if not s:
flash(f"No session for serial {serial}.", "danger")
return redirect(url_for("imaging_dashboard"))
full_log, truncated = imaging_status.read_full_log(serial)
return render_template(
"imaging_detail.html",
session=s,
full_log=full_log,
full_log_truncated=truncated,
)
@app.route("/imaging/delete/<serial>", methods=["POST"]) @app.route("/imaging/delete/<serial>", methods=["POST"])
def imaging_delete_session(serial): def imaging_delete_session(serial):
serial = secure_filename(serial) serial = secure_filename(serial)

View File

@@ -22,6 +22,16 @@ 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") IMAGING_DIR = os.environ.get("IMAGING_DIR", "/var/log/pxe-imaging")
# Log sources used by services/imaging_log_tail.py to infer client progress
# when no /imaging/status push has arrived yet. Override per-host if any of
# these live elsewhere on the live PXE server.
DNSMASQ_LEASES = os.environ.get("DNSMASQ_LEASES", "/var/lib/misc/dnsmasq.leases")
APACHE_ACCESS_LOG = os.environ.get("APACHE_ACCESS_LOG", "/var/log/apache2/access.log")
SAMBA_LOG_DIR = os.environ.get("SAMBA_LOG_DIR", "/var/log/samba")
# dnsmasq log-dhcp + TFTP requests land in syslog by default. Used to spot
# very-early boot (TFTP bootloader fetch) before Apache sees anything.
DNSMASQ_SYSLOG = os.environ.get("DNSMASQ_SYSLOG", "/var/log/syslog")
# --- 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")
MAX_CONTENT_LENGTH = 16 * 1024 * 1024 * 1024 # 16 GB max upload MAX_CONTENT_LENGTH = 16 * 1024 * 1024 * 1024 # 16 GB max upload

View File

@@ -0,0 +1,254 @@
"""Mutable registry for image types.
Previously IMAGE_TYPES + FRIENDLY_NAMES were hardcoded in config.py and
adding a new image type required a code edit + Ansible re-run. Now they
live in a JSON file (REGISTRY_PATH) that the webapp can read and write at
runtime.
On import (called from app.py startup), reload() rewrites
config.IMAGE_TYPES and config.FRIENDLY_NAMES in-place so the rest of the
code (and the base.html sidebar context) sees the live list without
threading the registry through every call.
Schema:
{
"image_types": [
{"key": "gea-standard", "friendly_name": "GE Aerospace Standard"},
...
]
}
The seed comes from whatever IMAGE_TYPES/FRIENDLY_NAMES are baked into
config.py at first run. Once the registry file exists, it wins.
"""
from __future__ import annotations
import json
import os
import re
import shutil
import tempfile
from datetime import datetime
from typing import Optional
import config
# Persist alongside the Samba share so an Ansible re-run that recreates
# SAMBA_SHARE keeps the registry. Override via env for tests.
REGISTRY_PATH = os.environ.get(
"IMAGE_REGISTRY_PATH",
os.path.join(config.SAMBA_SHARE, "image-registry.json"),
)
# image_type keys are used as directory names + URL path components. Match
# the existing baked-in convention (lowercase + hyphens + alnum).
_VALID_KEY = re.compile(r"^[a-z][a-z0-9-]{1,63}$")
class RegistryError(Exception):
pass
def _read_file() -> dict:
try:
with open(REGISTRY_PATH, "r", encoding="utf-8") as f:
return json.load(f)
except (OSError, json.JSONDecodeError):
return {}
def _write_file(data: dict):
os.makedirs(os.path.dirname(REGISTRY_PATH), exist_ok=True)
fd, tmp = tempfile.mkstemp(
dir=os.path.dirname(REGISTRY_PATH),
prefix=".tmp-registry-",
suffix=".json",
)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
f.write("\n")
os.replace(tmp, REGISTRY_PATH)
except Exception:
try:
os.unlink(tmp)
except OSError:
pass
raise
def _seed_from_config() -> dict:
"""First-run bootstrap: build a registry dict from whatever's baked into
config.py. Preserves order so the sidebar layout doesn't shuffle on the
first reload."""
items = []
for key in config.IMAGE_TYPES:
items.append({
"key": key,
"friendly_name": config.FRIENDLY_NAMES.get(key, key),
"created_at": None,
})
return {"image_types": items}
def load_registry() -> dict:
"""Return the registry dict. Bootstraps from config.py on first read."""
data = _read_file()
if not data.get("image_types"):
data = _seed_from_config()
try:
_write_file(data)
except OSError:
# Read-only filesystem / no perms; fall back to in-memory seed.
pass
return data
def reload():
"""Refresh config.IMAGE_TYPES + config.FRIENDLY_NAMES from the registry
file. Called at startup and after every CRUD op. Mutates in place so
existing references stay valid."""
data = load_registry()
keys = [item["key"] for item in data.get("image_types", []) if "key" in item]
friendly = {item["key"]: item.get("friendly_name", item["key"])
for item in data.get("image_types", [])}
# In-place replace so module-level references still point at the same
# list/dict objects.
config.IMAGE_TYPES[:] = keys
config.FRIENDLY_NAMES.clear()
config.FRIENDLY_NAMES.update(friendly)
def _validate_key(key: str):
if not key or not _VALID_KEY.match(key):
raise RegistryError(
f"Invalid image_type key '{key}'. Must be lowercase alphanumeric "
f"+ hyphens, 2-64 chars, starting with a letter."
)
def create(key: str, friendly_name: str) -> dict:
"""Add a new image type with no on-disk content. Caller is responsible
for populating Deploy/ via the import flow afterwards."""
_validate_key(key)
if not friendly_name:
friendly_name = key
data = load_registry()
existing_keys = {item["key"] for item in data["image_types"]}
if key in existing_keys:
raise RegistryError(f"image_type '{key}' already exists")
data["image_types"].append({
"key": key,
"friendly_name": friendly_name,
"created_at": datetime.now().astimezone().isoformat(timespec="seconds"),
})
_write_file(data)
reload()
return {"key": key, "friendly_name": friendly_name}
def clone(src_key: str, dst_key: str, friendly_name: Optional[str] = None) -> dict:
"""Duplicate a source image type's on-disk content + register the new
key. Copies Deploy/ tree (which contains Control/ JSON configs +
unattend XML), Tools/ if present, and the top-level Sources/ symlink if
present. Shared dirs (Out-of-box Drivers, Operating Systems, Packages)
are re-symlinked into the new image root, not duplicated."""
_validate_key(dst_key)
data = load_registry()
keys = {item["key"] for item in data["image_types"]}
if src_key not in keys:
raise RegistryError(f"source image_type '{src_key}' not registered")
if dst_key in keys:
raise RegistryError(f"destination image_type '{dst_key}' already exists")
from services import fs # local import to avoid circular at module load
src_root = fs.image_root(src_key)
dst_root = fs.image_root(dst_key)
if os.path.exists(dst_root):
raise RegistryError(f"destination path already exists on disk: {dst_root}")
os.makedirs(dst_root, exist_ok=True)
if os.path.isdir(src_root):
for entry in os.listdir(src_root):
src_item = os.path.join(src_root, entry)
dst_item = os.path.join(dst_root, entry)
if os.path.islink(src_item):
# Preserve symlink (shared dir) instead of dereferencing.
target = os.readlink(src_item)
os.symlink(target, dst_item)
elif os.path.isdir(src_item):
shutil.copytree(src_item, dst_item, symlinks=True)
else:
shutil.copy2(src_item, dst_item)
if not friendly_name:
src_friendly = next(
(i["friendly_name"] for i in data["image_types"] if i["key"] == src_key),
src_key,
)
friendly_name = f"{src_friendly} (copy)"
data["image_types"].append({
"key": dst_key,
"friendly_name": friendly_name,
"created_at": datetime.now().astimezone().isoformat(timespec="seconds"),
"cloned_from": src_key,
})
_write_file(data)
reload()
return {"key": dst_key, "friendly_name": friendly_name, "cloned_from": src_key}
def delete(key: str, delete_content: bool = False) -> dict:
"""Remove key from the registry. When delete_content is True, also wipes
the on-disk Deploy/Tools/etc tree. Shared symlinked dirs are NOT
followed when deleting content (we unlink the symlink, not its target)."""
data = load_registry()
items = data.get("image_types", [])
found = [i for i in items if i["key"] == key]
if not found:
raise RegistryError(f"image_type '{key}' not registered")
data["image_types"] = [i for i in items if i["key"] != key]
_write_file(data)
reload()
removed_content = False
if delete_content:
from services import fs
root = fs.image_root(key)
if os.path.isdir(root):
# Walk top-level: unlink symlinks (don't follow into shared
# dirs), rmtree real directories, unlink files.
for entry in os.listdir(root):
p = os.path.join(root, entry)
try:
if os.path.islink(p):
os.unlink(p)
elif os.path.isdir(p):
shutil.rmtree(p)
else:
os.unlink(p)
except OSError:
pass
try:
os.rmdir(root)
except OSError:
pass
removed_content = True
return {"key": key, "removed_content": removed_content}
def rename_friendly(key: str, friendly_name: str) -> dict:
"""Update the human-readable name without touching the key or on-disk
content. Sidebar reflects on next request."""
if not friendly_name:
raise RegistryError("friendly_name required")
data = load_registry()
for item in data.get("image_types", []):
if item["key"] == key:
item["friendly_name"] = friendly_name
_write_file(data)
reload()
return {"key": key, "friendly_name": friendly_name}
raise RegistryError(f"image_type '{key}' not registered")

View File

@@ -1,11 +1,24 @@
"""Per-image-type state probes: status + config (drivers / OS / packages / models).""" """Per-image-type state probes: status + config (drivers / OS / packages / models)."""
import os import os
import re
import shutil
import config import config
from services import fs from services import fs
# File-name sanitizer for uploads. Strips anything outside [A-Za-z0-9._-]
# so a malicious filename can't break out of the destination directory.
_SAFE_NAME = re.compile(r"[^A-Za-z0-9._-]")
def _safe_filename(name: str) -> str:
base = os.path.basename(name or "")
cleaned = _SAFE_NAME.sub("_", base)
return cleaned or "upload.bin"
def image_status(image_type): def image_status(image_type):
"""Return a dict describing the state of an image type.""" """Return a dict describing the state of an image type."""
dp = fs.deploy_path(image_type) dp = fs.deploy_path(image_type)
@@ -121,3 +134,131 @@ def load_image_config(image_type):
"orphan_drivers": orphan_drivers, "orphan_drivers": orphan_drivers,
"os_selection": os_selection, "os_selection": os_selection,
} }
# ---------------------------------------------------------------------------
# Driver / package upload + orphan adoption
# ---------------------------------------------------------------------------
DRIVERS_SUBDIR = "Out-of-box Drivers"
PACKAGES_SUBDIR = "Packages"
def _drivers_dir(image_type: str) -> str:
return os.path.join(fs.deploy_path(image_type), DRIVERS_SUBDIR)
def _packages_dir(image_type: str) -> str:
return os.path.join(fs.deploy_path(image_type), PACKAGES_SUBDIR)
def upload_driver(image_type: str, uploaded_file, family: str = "",
destination_dir: str = "", overwrite: bool = False) -> dict:
"""Save an uploaded driver .zip into the image's Out-of-box Drivers dir.
When family + destination_dir are provided, also append a HardwareDriver
.json entry so the driver is recognized at deploy time. Returns a dict
describing what landed on disk + whether an entry was registered."""
fname = _safe_filename(uploaded_file.filename)
if not fname.lower().endswith(".zip"):
raise ValueError("Driver upload must be a .zip file")
dst_dir = _drivers_dir(image_type)
os.makedirs(dst_dir, exist_ok=True)
dst_path = os.path.join(dst_dir, fname)
if os.path.exists(dst_path) and not overwrite:
raise FileExistsError(f"{fname} already exists in {DRIVERS_SUBDIR}/")
uploaded_file.save(dst_path)
registered = False
if family and destination_dir:
adopt_orphan(image_type, fname, family, destination_dir)
registered = True
return {
"filename": fname,
"path": dst_path,
"registered": registered,
}
def adopt_orphan(image_type: str, filename: str, family: str,
destination_dir: str) -> dict:
"""Append a HardwareDriver.json entry for an existing .zip in the image's
Out-of-box Drivers dir, so it stops showing up as an orphan + gets
deployed for the named hardware family. Idempotent: a second adopt with
the same filename is a no-op."""
safe_name = _safe_filename(filename)
drivers_path = _drivers_dir(image_type)
if not os.path.isfile(os.path.join(drivers_path, safe_name)):
raise FileNotFoundError(f"{safe_name} not found in {DRIVERS_SUBDIR}/")
if not family:
raise ValueError("family is required (matches HardwareModelSelection.Id)")
if not destination_dir:
raise ValueError("destination_dir is required")
ctrl = fs.control_path(image_type)
hw_file = os.path.join(ctrl, "HardwareDriver.json")
entries = fs.load_json(hw_file)
for e in entries:
if (e.get("FileName") or e.get("fileName") or "").lower() == safe_name.lower():
return {"filename": safe_name, "already_registered": True}
entries.append({
"FileName": safe_name,
"DestinationDir": destination_dir,
"family": family,
})
fs.save_json(hw_file, entries)
return {"filename": safe_name, "already_registered": False}
def remove_orphans(image_type: str, filenames: list[str]) -> dict:
"""Delete the named files from Out-of-box Drivers/. Caller is
responsible for confirming this is what the user wants (orphan files
have no JSON entry, so deleting them is safe in the sense that nothing
references them; but they may be stash for future adoption)."""
drivers_path = _drivers_dir(image_type)
removed, missing = [], []
for fn in filenames:
safe = _safe_filename(fn)
path = os.path.join(drivers_path, safe)
if not os.path.isfile(path):
missing.append(safe)
continue
try:
os.unlink(path)
removed.append(safe)
except OSError:
missing.append(safe)
return {"removed": removed, "missing": missing}
def upload_package(image_type: str, uploaded_file, destination_dir: str = "",
overwrite: bool = False) -> dict:
"""Save an uploaded package (any extension) into the image's Packages
dir, and append an entry to packages.json when destination_dir is set
so it deploys at imaging time."""
fname = _safe_filename(uploaded_file.filename)
dst_dir = _packages_dir(image_type)
os.makedirs(dst_dir, exist_ok=True)
dst_path = os.path.join(dst_dir, fname)
if os.path.exists(dst_path) and not overwrite:
raise FileExistsError(f"{fname} already exists in {PACKAGES_SUBDIR}/")
uploaded_file.save(dst_path)
registered = False
if destination_dir:
ctrl = fs.control_path(image_type)
pkg_file = os.path.join(ctrl, "packages.json")
entries = fs.load_json(pkg_file)
already = any(
(e.get("fileName") or e.get("FileName") or "").lower() == fname.lower()
for e in entries
)
if not already:
entries.append({
"fileName": fname,
"destinationDir": destination_dir,
})
fs.save_json(pkg_file, entries)
registered = True
return {
"filename": fname,
"path": dst_path,
"registered": registered,
}

View File

@@ -0,0 +1,376 @@
"""Infer in-progress imaging sessions from server-side logs.
The /imaging/status endpoint only sees clients that have reached the WinPE
startnet.cmd push (stage_index 2). A bay that gets stuck earlier (no DHCP,
TFTP fail, boot.wim download stall) is invisible to the operator.
This module tails dnsmasq leases + Apache access log + per-host Samba logs
and synthesizes a "session" record per active MAC, with a coarse inferred
stage based on which boot assets the client has actually fetched. Output
shape matches services/imaging_status so list_sessions() can merge both.
Inferred sessions carry source="inferred" and never overwrite a real
client-pushed session for the same serial (correlated by MAC when present).
"""
from __future__ import annotations
import os
import re
import time
from datetime import datetime, timezone
from typing import Optional
import config
# Active window: an inferred session is shown if any evidence is newer than
# this many seconds. Past that we assume the bay is idle / done / off.
INFERRED_ACTIVE_WINDOW_S = 90 * 60 # 90 min
# Tail size caps so a giant log doesn't pull the whole file into memory on
# each dashboard refresh.
APACHE_TAIL_BYTES = 512 * 1024 # 512 KB
SAMBA_TAIL_BYTES = 64 * 1024 # 64 KB per file
SYSLOG_TAIL_BYTES = 256 * 1024 # 256 KB
# Path prefixes Apache serves, mapped to coarse imaging stage signals.
# Order matters: we take the highest-numbered match (latest in the boot chain).
_APACHE_STAGE_HITS = [
("/menu.ipxe", ("stage_0_menu", 0)),
("/win11/boot/", ("stage_0_boot_pre", 0)),
("/win11/efi/", ("stage_0_boot_pre", 0)),
("/win11/sources/boot.wim", ("stage_1_wim_get", 1)),
("/win11/sources/", ("stage_1_wim_get", 1)),
]
# TFTP bootloader fetches arrive via dnsmasq, not Apache. Mapped same way.
_TFTP_STAGE_HITS = [
("undionly.kpxe", ("stage_0_tftp_bios", 0)),
("ipxe.efi", ("stage_0_tftp_uefi", 0)),
]
# Apache combined log: <ip> - - [DD/Mon/YYYY:HH:MM:SS +ZZZZ] "GET /path HTTP/x.y" status bytes "ref" "ua"
_APACHE_RE = re.compile(
r'^(?P<ip>\S+)\s+\S+\s+\S+\s+'
r'\[(?P<ts>[^\]]+)\]\s+'
r'"(?P<method>\S+)\s+(?P<path>\S+)\s+\S+"\s+'
r'(?P<status>\d+)\s+(?P<bytes>\S+)'
)
_APACHE_TS_FMT = "%d/%b/%Y:%H:%M:%S %z"
# dnsmasq syslog: "<MMM DD HH:MM:SS> host dnsmasq-tftp[pid]: sent /tftp/path to ip"
# or "dnsmasq-dhcp[pid]: DHCPACK(...) ip mac hostname"
_SYSLOG_DNSMASQ_RE = re.compile(
r'^(?P<mon>\w{3})\s+(?P<day>\d+)\s+(?P<time>\d{2}:\d{2}:\d{2})\s+'
r'\S+\s+dnsmasq-(?P<kind>\w+)\[\d+\]:\s+(?P<msg>.+)$'
)
def _tail_bytes(path: str, max_bytes: int) -> str:
"""Read last max_bytes of a text log, return decoded string. Returns
empty string if file is missing / unreadable. Skips partial first line."""
try:
size = os.path.getsize(path)
except OSError:
return ""
start = max(0, size - max_bytes)
try:
with open(path, "rb") as f:
f.seek(start)
data = f.read()
except OSError:
return ""
text = data.decode("utf-8", errors="replace")
if start > 0:
# Drop partial first line.
nl = text.find("\n")
if nl >= 0:
text = text[nl + 1:]
return text
def _now_iso() -> str:
return datetime.now().astimezone().isoformat(timespec="seconds")
def _epoch_to_iso(epoch: float) -> str:
return datetime.fromtimestamp(epoch).astimezone().isoformat(timespec="seconds")
def parse_leases() -> list[dict]:
"""dnsmasq.leases format: <expires_epoch> <mac> <ip> <hostname> <client_id>."""
out = []
try:
with open(config.DNSMASQ_LEASES, "r") as f:
for line in f:
parts = line.split()
if len(parts) < 4:
continue
try:
expires = int(parts[0])
except ValueError:
continue
out.append({
"expires": expires,
"mac": parts[1].lower(),
"ip": parts[2],
"hostname": parts[3] if parts[3] != "*" else "",
})
except OSError:
pass
return out
def parse_apache_hits() -> dict:
"""Return {ip: {"last_ts": epoch, "max_stage": int, "stage_label": str,
"paths": [recent_paths]}}."""
out: dict = {}
text = _tail_bytes(config.APACHE_ACCESS_LOG, APACHE_TAIL_BYTES)
for line in text.splitlines():
m = _APACHE_RE.match(line)
if not m:
continue
ip = m.group("ip")
path = m.group("path").lower()
try:
ts = datetime.strptime(m.group("ts"), _APACHE_TS_FMT).timestamp()
except ValueError:
continue
# Match against known stage prefixes; keep the highest-numbered.
for prefix, (label, stage) in _APACHE_STAGE_HITS:
if path.startswith(prefix):
rec = out.setdefault(ip, {
"last_ts": 0.0, "max_stage": -1,
"stage_label": "", "paths": [],
})
if ts > rec["last_ts"]:
rec["last_ts"] = ts
if stage > rec["max_stage"]:
rec["max_stage"] = stage
rec["stage_label"] = label
rec["paths"].append(path)
# Cap path list so a busy client doesn't explode the dict.
rec["paths"] = rec["paths"][-10:]
break
return out
def parse_syslog_tftp() -> dict:
"""Return {mac_or_ip: {"last_ts": epoch, "max_stage": int,
"stage_label": str, "evidence": [str]}} from dnsmasq syslog lines."""
out: dict = {}
text = _tail_bytes(config.DNSMASQ_SYSLOG, SYSLOG_TAIL_BYTES)
year = datetime.now().year
for line in text.splitlines():
m = _SYSLOG_DNSMASQ_RE.match(line)
if not m:
continue
kind = m.group("kind")
msg = m.group("msg")
# Best-effort ts parse. Syslog format has no year; assume current.
try:
ts = datetime.strptime(
f"{year} {m.group('mon')} {m.group('day')} {m.group('time')}",
"%Y %b %d %H:%M:%S"
).timestamp()
except ValueError:
continue
if kind == "tftp":
# "sent /srv/tftp/ipxe.efi to 172.16.9.42"
tm = re.search(r'(?:sent|read|file)\s+(?P<file>\S+)\s+to\s+(?P<ip>\S+)', msg)
if not tm:
continue
key = tm.group("ip")
fname = os.path.basename(tm.group("file")).lower()
for needle, (label, stage) in _TFTP_STAGE_HITS:
if needle in fname:
rec = out.setdefault(key, {
"last_ts": 0.0, "max_stage": -1,
"stage_label": "", "evidence": [],
})
if ts > rec["last_ts"]:
rec["last_ts"] = ts
if stage > rec["max_stage"]:
rec["max_stage"] = stage
rec["stage_label"] = label
rec["evidence"].append(f"tftp:{fname}")
rec["evidence"] = rec["evidence"][-5:]
break
elif kind == "dhcp":
# DHCPACK lines confirm a lease handshake completed; useful as a
# very-early "this MAC is alive" signal even before TFTP fires.
dm = re.search(
r'DHCP(?:ACK|OFFER|REQUEST)\([^)]+\)\s+'
r'(?P<ip>\d+\.\d+\.\d+\.\d+)\s+'
r'(?P<mac>[0-9a-f:]{17})',
msg
)
if not dm:
continue
key = dm.group("ip")
rec = out.setdefault(key, {
"last_ts": 0.0, "max_stage": -1,
"stage_label": "", "evidence": [],
})
if ts > rec["last_ts"]:
rec["last_ts"] = ts
if rec["max_stage"] < 0:
rec["max_stage"] = 0
rec["stage_label"] = "stage_0_dhcp"
rec["evidence"].append(f"dhcp:{dm.group('mac')}")
rec["evidence"] = rec["evidence"][-5:]
return out
def parse_samba_hits() -> dict:
"""Return {ip: {"last_ts": epoch, "shares": set[str]}} for clients that
have opened a Samba session in the recent tail. Samba writes per-host log
files: /var/log/samba/log.<ip>. Mere existence + recent mtime = client
has connected. Share names from inside the file are best-effort."""
out: dict = {}
log_dir = config.SAMBA_LOG_DIR
if not os.path.isdir(log_dir):
return out
try:
entries = os.listdir(log_dir)
except OSError:
return out
for name in entries:
if not name.startswith("log."):
continue
ip = name[len("log."):]
# Skip the daemon's own log files (log.smbd, log.nmbd, log.winbindd).
if ip in ("smbd", "nmbd", "winbindd") or "." not in ip:
continue
path = os.path.join(log_dir, name)
try:
st = os.stat(path)
except OSError:
continue
rec = {"last_ts": st.st_mtime, "shares": set()}
text = _tail_bytes(path, SAMBA_TAIL_BYTES)
for share in ("winpeapps", "clonezilla", "enrollment", "image-upload", "blancco-reports"):
if f"[{share}]" in text or f"connect to service {share}" in text:
rec["shares"].add(share)
out[ip] = rec
return out
def infer_sessions(
active_window_s: int = INFERRED_ACTIVE_WINDOW_S,
) -> list[dict]:
"""Build inferred-session records from all log sources. Keyed by MAC when
available, else IP. Only returns sessions with evidence in the active
window."""
leases = parse_leases()
apache = parse_apache_hits()
samba = parse_samba_hits()
tftp = parse_syslog_tftp()
# IP -> lease (MAC, hostname).
by_ip = {lease["ip"]: lease for lease in leases}
cutoff = time.time() - active_window_s
sessions: dict[str, dict] = {}
def _ensure(ip: str) -> dict:
lease = by_ip.get(ip, {})
key = lease.get("mac") or ip
rec = sessions.get(key)
if rec is None:
rec = {
"source": "inferred",
"serial": None,
"mac": lease.get("mac", ""),
"ip": ip,
"hostname_target": lease.get("hostname", ""),
"started_at": None,
"last_updated": None,
"stage_index": 0,
"stage_total": 8,
"current_stage": "",
"status": "in_progress",
"evidence": [],
"log_tail": [],
}
sessions[key] = rec
return rec
def _bump_stage(rec: dict, stage: int, label: str):
if stage > rec["stage_index"]:
rec["stage_index"] = stage
rec["current_stage"] = label
def _touch(rec: dict, ts: float):
iso = _epoch_to_iso(ts)
if rec["started_at"] is None or iso < rec["started_at"]:
rec["started_at"] = iso
if rec["last_updated"] is None or iso > rec["last_updated"]:
rec["last_updated"] = iso
for ip, hit in tftp.items():
if hit["last_ts"] < cutoff:
continue
rec = _ensure(ip)
_touch(rec, hit["last_ts"])
_bump_stage(rec, hit["max_stage"], hit["stage_label"])
rec["evidence"].extend(hit["evidence"])
for ip, hit in apache.items():
if hit["last_ts"] < cutoff:
continue
rec = _ensure(ip)
_touch(rec, hit["last_ts"])
_bump_stage(rec, hit["max_stage"], hit["stage_label"])
for p in hit["paths"]:
rec["evidence"].append(f"http:{p}")
for ip, hit in samba.items():
if hit["last_ts"] < cutoff:
continue
rec = _ensure(ip)
_touch(rec, hit["last_ts"])
# WinPE-stage SMB activity = boot.wim applied + apps stage starting.
_bump_stage(rec, 2, "stage_2_smb_winpe")
for share in sorted(hit["shares"]):
rec["evidence"].append(f"smb:{share}")
# Trim evidence + bake log_tail for display.
for rec in sessions.values():
rec["evidence"] = rec["evidence"][-15:]
rec["log_tail"] = list(rec["evidence"])
return list(sessions.values())
def merge_with_client_sessions(
client_sessions: list[dict],
inferred: Optional[list[dict]] = None,
) -> list[dict]:
"""Return a single list. Real (client-pushed) sessions win over inferred
ones for the same MAC. Inferred sessions are dropped when a real session
with the same MAC already exists, since the real one has strictly more
information."""
if inferred is None:
inferred = infer_sessions()
real_macs = {(s.get("mac") or "").lower() for s in client_sessions if s.get("mac")}
real_ips = {s.get("ip") for s in client_sessions if s.get("ip")}
# Tag real sessions so the template can style them differently from
# inferred ones (and not break older sessions that lack the field).
for s in client_sessions:
s.setdefault("source", "client")
merged = list(client_sessions)
for inf in inferred:
mac = (inf.get("mac") or "").lower()
ip = inf.get("ip")
if mac and mac in real_macs:
continue
if ip and ip in real_ips:
continue
merged.append(inf)
merged.sort(key=lambda s: s.get("last_updated") or "", reverse=True)
return merged

View File

@@ -20,8 +20,16 @@ from typing import Optional
import config import config
# How many recent log lines to keep per session. # How many recent log lines to keep per session in the JSON (dashboard tile
# quick view). The full unbounded log is appended to a sidecar .log file
# next to the .json so the detail page can show everything.
LOG_TAIL_MAX = 50 LOG_TAIL_MAX = 50
# Cap how many stage transitions we record per session (bounds JSON size on
# pathological loops; 30 covers more than any real run uses).
STAGE_HISTORY_MAX = 30
# Detail page caps how many bytes of the sidecar .log it sends to the
# browser, to avoid blowing up the response for a runaway log.
DETAIL_LOG_MAX_BYTES = 1024 * 1024 # 1 MB
# Sessions older than this are considered stale and dropped from the dashboard # Sessions older than this are considered stale and dropped from the dashboard
# "active" list. Still readable individually. # "active" list. Still readable individually.
ACTIVE_WINDOW_HOURS = 6 ACTIVE_WINDOW_HOURS = 6
@@ -40,6 +48,30 @@ def _path_for(serial: str) -> str:
return os.path.join(config.IMAGING_DIR, f"{safe}.json") return os.path.join(config.IMAGING_DIR, f"{safe}.json")
def _log_path_for(serial: str) -> str:
safe = _SAFE_SERIAL.sub("_", serial.strip()) or "unknown"
return os.path.join(config.IMAGING_DIR, f"{safe}.log")
def _append_full_log(serial: str, lines):
"""Best-effort append to the per-serial sidecar log file. Each line is
timestamped. Failures are swallowed (status-tracking is not the
authoritative log source; the .log is a convenience for the detail
page)."""
if not lines:
return
if isinstance(lines, str):
lines = [lines]
try:
_ensure_dir()
with open(_log_path_for(serial), "a") as f:
ts = _now_iso()
for line in lines:
f.write(f"{ts} {line}\n")
except OSError:
pass
def _now_iso() -> str: def _now_iso() -> str:
return datetime.now().astimezone().isoformat(timespec="seconds") return datetime.now().astimezone().isoformat(timespec="seconds")
@@ -100,7 +132,9 @@ def update_session(payload: dict) -> dict:
# Fresh state after a rewind - mint a new started_at. # Fresh state after a rewind - mint a new started_at.
state["started_at"] = _now_iso() state["started_at"] = _now_iso()
# Append any new log lines (preserve old; cap to LOG_TAIL_MAX). # Append any new log lines: capped tail in the JSON for the dashboard
# quick view, and unbounded append to the sidecar .log for the detail
# page.
new_lines = payload.pop("log_lines", None) new_lines = payload.pop("log_lines", None)
if new_lines: if new_lines:
if isinstance(new_lines, str): if isinstance(new_lines, str):
@@ -108,6 +142,33 @@ def update_session(payload: dict) -> dict:
tail = list(state.get("log_tail", [])) tail = list(state.get("log_tail", []))
tail.extend(new_lines) tail.extend(new_lines)
state["log_tail"] = tail[-LOG_TAIL_MAX:] state["log_tail"] = tail[-LOG_TAIL_MAX:]
_append_full_log(serial, new_lines)
# Stage history: record a transition row whenever stage_index increases
# or status changes. Bounded to STAGE_HISTORY_MAX so a bouncing client
# can't blow up the JSON. The dashboard tile only needs current state;
# the detail page renders the timeline from this list.
history = list(state.get("stage_history", []))
try:
new_idx = int(payload.get("stage_index") or 0)
except (TypeError, ValueError):
new_idx = 0
try:
old_idx = int(state.get("stage_index") or 0)
except (TypeError, ValueError):
old_idx = 0
new_status = payload.get("status") or state.get("status") or "in_progress"
old_status = state.get("status") or ""
stage_changed = new_idx > old_idx
status_changed = new_status != old_status and new_status in ("succeeded", "failed")
if stage_changed or status_changed or not history:
history.append({
"ts": _now_iso(),
"stage_index": new_idx or old_idx,
"current_stage": payload.get("current_stage") or state.get("current_stage", ""),
"status": new_status,
})
state["stage_history"] = history[-STAGE_HISTORY_MAX:]
for key, value in payload.items(): for key, value in payload.items():
if value is None or value == "": if value is None or value == "":
@@ -132,8 +193,14 @@ def update_session(payload: dict) -> dict:
return state return state
def list_sessions() -> list[dict]: def list_sessions(include_inferred: bool = True) -> list[dict]:
"""Return all sessions sorted by last_updated desc.""" """Return all sessions sorted by last_updated desc.
When include_inferred is True (default for the dashboard), also pull
server-side log-tail evidence and append synthesized sessions for any
bay that has touched DHCP/TFTP/boot.wim but not yet pushed status.
Real client-pushed sessions always win for the same MAC.
"""
_ensure_dir() _ensure_dir()
out: list[dict] = [] out: list[dict] = []
for name in os.listdir(config.IMAGING_DIR): for name in os.listdir(config.IMAGING_DIR):
@@ -145,6 +212,10 @@ def list_sessions() -> list[dict]:
out.append(json.load(f)) out.append(json.load(f))
except (json.JSONDecodeError, OSError): except (json.JSONDecodeError, OSError):
continue continue
if include_inferred:
from services import imaging_log_tail
out = imaging_log_tail.merge_with_client_sessions(out)
else:
out.sort(key=lambda s: s.get("last_updated", ""), reverse=True) out.sort(key=lambda s: s.get("last_updated", ""), reverse=True)
return out return out
@@ -160,27 +231,58 @@ def get_session(serial: str) -> Optional[dict]:
return None return None
def read_full_log(serial: str, max_bytes: int = DETAIL_LOG_MAX_BYTES) -> tuple[str, bool]:
"""Return (text, truncated). Reads the trailing max_bytes of the sidecar
.log. `truncated` is True when the file was larger than max_bytes and
the leading slice was dropped."""
path = _log_path_for(serial)
try:
size = os.path.getsize(path)
except OSError:
return ("", False)
truncated = size > max_bytes
start = max(0, size - max_bytes)
try:
with open(path, "rb") as f:
f.seek(start)
data = f.read()
except OSError:
return ("", False)
text = data.decode("utf-8", errors="replace")
if truncated:
nl = text.find("\n")
if nl >= 0:
text = text[nl + 1:]
return (text, truncated)
def delete_session(serial: str) -> bool: def delete_session(serial: str) -> bool:
path = _path_for(serial) path = _path_for(serial)
if not os.path.isfile(path): if not os.path.isfile(path):
return False return False
try: try:
os.unlink(path) os.unlink(path)
return True
except OSError: except OSError:
return False return False
# Best-effort sidecar cleanup.
try:
os.unlink(_log_path_for(serial))
except OSError:
pass
return True
def delete_all_sessions() -> int: def delete_all_sessions() -> int:
"""Wipe every per-bay JSON in IMAGING_DIR. Returns count removed.""" """Wipe every per-bay JSON + sidecar .log in IMAGING_DIR. Returns count
of JSON files removed."""
_ensure_dir() _ensure_dir()
removed = 0 removed = 0
for fn in os.listdir(config.IMAGING_DIR): for fn in os.listdir(config.IMAGING_DIR):
if not fn.endswith(".json"): if fn.endswith(".json") or fn.endswith(".log"):
continue
try: try:
os.unlink(os.path.join(config.IMAGING_DIR, fn)) os.unlink(os.path.join(config.IMAGING_DIR, fn))
removed += 1
except OSError: except OSError:
pass continue
if fn.endswith(".json"):
removed += 1
return removed return removed

View File

@@ -34,6 +34,11 @@
for (var i = 0; i < nodes.length; i++) render(nodes[i]); for (var i = 0; i < nodes.length; i++) render(nodes[i]);
} }
// Exposed for callers that swap in new tiles dynamically (e.g. imaging
// dashboard SSE refresh). Idempotent because render() guards on
// dataset.qrRendered.
window.renderAllQRs = scan;
if (document.readyState === 'loading') { if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', scan); document.addEventListener('DOMContentLoaded', scan);
} else { } else {

View File

@@ -0,0 +1,192 @@
{# Per-bay tiles. Rendered standalone by /imaging/tiles for SSE refresh, and
included by imaging.html for first paint. Receives `sessions`. #}
{% set stage_labels = {
1: ('Booting from PXE', 'WinPE loaded - applying Windows image to disk.'),
2: ('Configuring Windows', 'First boot. Running shopfloor setup baseline scripts.'),
3: ('Installing apps', 'Type-specific app installs (eDNC, UDC, NTLARS, etc).'),
4: ('Apps installed', 'Type-specific scripts complete. Preparing for Intune enrollment.'),
5: ('Enrolling in Intune', 'PPKG installing - device joining Azure AD + Intune. ~5-10 min, reboot to follow.'),
6: ('Waiting on first Intune sync','Post-PPKG settle (~120s). Triggering Schedule #3 sync repeatedly.'),
7: ('Registered - assign category',
'Phase 1 (Intune Registration) complete. Click "set category" in the Intune portal to drop the bay into the right config-profile group.'),
8: ('Imaging complete',
'Lockdown applied. Bay rebooted into ShopFloor session. Ready for production.')
} %}
{% set inferred_stage_labels = {
'stage_0_dhcp': ('DHCP lease issued', 'Client got a DHCP lease but has not fetched the bootloader yet.'),
'stage_0_tftp_bios': ('TFTP bootloader (BIOS)','Client fetched undionly.kpxe via TFTP. Booting iPXE.'),
'stage_0_tftp_uefi': ('TFTP bootloader (UEFI)','Client fetched ipxe.efi via TFTP. Booting iPXE.'),
'stage_0_menu': ('Boot menu loaded', 'Client loaded the iPXE menu. Waiting on selection or auto-boot.'),
'stage_0_boot_pre': ('Pre-boot fetch', 'Client downloading WinPE boot files from HTTP.'),
'stage_1_wim_get': ('Downloading boot.wim', 'Client pulling WinPE image. No /imaging/status push yet.'),
'stage_2_smb_winpe': ('WinPE running (no push)','Client has opened SMB shares - WinPE is up but startnet has not pushed status.'),
} %}
{% for s in sessions %}
{% set is_inferred = (s.source == 'inferred') %}
{% 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' %}
{% if is_inferred %}
{% set border = 'warning' %}
{% else %}
{% set border = 'danger' if is_failed else ('success' if is_done else 'primary') %}
{% endif %}
{% if is_inferred %}
{% set friendly = inferred_stage_labels.get(s.current_stage, ('Inferred from logs', s.current_stage or '')) %}
{% else %}
{% set friendly = stage_labels.get(stage_idx, ('Stage ' ~ stage_idx, '')) %}
{% if stage_idx == 1 and s.current_stage and 'bios' in s.current_stage|lower %}
{% set friendly = ('Updating BIOS firmware',
'WinPE detected a firmware update for this model. Do NOT power off until the next reboot. Imaging continues afterward.') %}
{% endif %}
{% if stage_idx == 7 and s.current_stage %}
{% set _cs = s.current_stage|lower %}
{% if 'ready for lockdown' in _cs or 'request lockdown' in _cs %}
{% set friendly = ('Ready - request lockdown',
'Phase 1-4 all complete (Registration, Device Config, Software Deploy, Credentials). Click "ARTS request" to initiate the lockdown workflow.') %}
{% elif 'credentials' in _cs or 'phase 4' in _cs %}
{% set friendly = ('Phase 3 / 4 - DSC + credentials',
'SFLD policy delivered, DSC pulling device-config.yaml + running per-app wrappers. SFLD share creds populating.') %}
{% elif 'sfld policy' in _cs or 'phase 2' in _cs or 'device configuration' in _cs %}
{% set friendly = ('Phase 2 - device configuration',
'Category was assigned in Intune. SFLD ConfigurationProfile (Function + SasToken) has delivered. DSC kicking off next.') %}
{% endif %}
{% endif %}
{% endif %}
{% set card_key = s.serial or ('mac-' ~ (s.mac or 'unknown') ~ '-' ~ (s.ip or '')) %}
<details class="card border-{{ border }} mb-2 shadow-sm imaging-card{% if is_inferred %} imaging-inferred{% endif %}"
data-serial="{{ s.serial or '' }}"
data-key="{{ card_key }}"
data-mac="{{ s.mac or '' }}"
data-ip="{{ s.ip or '' }}"
data-source="{{ s.source or 'client' }}"
data-filter="{{ (s.serial or '')|lower }} {{ (s.hostname_target or '')|lower }} {{ (s.pctype or '')|lower }} {{ (s.machinenumber or '')|lower }} {{ (s.intune_device_id or '')|lower }} {{ friendly[0]|lower }} stage-{{ stage_idx }} {{ (s.status or 'in_progress')|lower }} {{ (s.mac or '')|lower }} {{ (s.ip or '') }} {{ s.source or 'client' }}">
<summary class="card-body py-2" style="cursor:pointer; list-style:none;">
<div class="d-flex flex-wrap gap-3 align-items-center">
{% if s.intune_device_id %}
<div data-qr="{{ s.intune_device_id }}" data-qr-size="96" data-qr-ec="M"
style="line-height:0; flex-shrink:0;"
title="Intune Device ID: {{ s.intune_device_id }}"></div>
{% else %}
<div class="d-flex align-items-center justify-content-center bg-light text-muted small"
style="width:96px; height:96px; border-radius:0.25rem; flex-shrink:0; text-align:center; padding:0.25rem;">
{% if is_inferred %}log-only{% else %}no DeviceId{% endif %}
</div>
{% endif %}
<div class="flex-grow-1" style="min-width:0;">
<div class="d-flex flex-wrap align-items-center gap-2">
<strong class="fs-6">
{% if s.serial %}{{ s.serial }}
{% elif s.hostname_target %}{{ s.hostname_target }}
{% elif s.mac %}{{ s.mac }}
{% else %}{{ s.ip or '(unknown bay)' }}{% endif %}
</strong>
{% if s.hostname_target and s.serial %}<code class="text-muted small">{{ s.hostname_target }}</code>{% endif %}
{% if s.ip %}<code class="text-muted small">{{ s.ip }}</code>{% endif %}
{% if s.pctype %}<span class="badge bg-info text-dark">{{ s.pctype }}</span>{% endif %}
{% if s.machinenumber %}<span class="badge bg-secondary">#{{ s.machinenumber }}</span>{% endif %}
{% if is_inferred %}<span class="badge bg-warning text-dark">log-inferred</span>{% endif %}
<span class="badge bg-{{ border }} ms-auto">{{ s.status or 'in_progress' }}</span>
</div>
<div class="d-flex justify-content-between align-items-baseline mt-1">
<div>
<strong>{{ friendly[0] }}</strong>
{% if not is_inferred %}
<span class="badge bg-secondary ms-1">{{ stage_idx }}/{{ stage_total or '?' }}</span>
{% endif %}
</div>
{% if not is_inferred %}<span class="text-muted small">{{ pct }}%</span>{% endif %}
</div>
{% if not is_inferred %}
<div class="progress mt-1" style="height:0.7rem;">
<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"></div>
</div>
{% endif %}
</div>
</div>
</summary>
<div class="card-body pt-0 pb-3 border-top">
{% if friendly[1] %}<div class="small text-muted mt-2">{{ friendly[1] }}</div>{% endif %}
{% if s.intune_device_id %}
<div class="small mt-2" style="font-size:0.75rem;">
<span class="text-muted">Intune:</span> <code>{{ s.intune_device_id }}</code>
<button type="button" class="btn btn-sm btn-outline-secondary py-0 px-1 copy-btn"
style="font-size:0.65rem; line-height:1; transition: all 0.2s;"
data-copy-text="{{ s.intune_device_id }}">copy</button>
<a class="btn btn-sm btn-outline-primary py-0 px-1"
style="font-size:0.65rem; line-height:1;"
target="_blank" rel="noopener"
href="https://portal.azure.us/?feature.msaljs=false#view/Microsoft_Intune_Devices/DeviceSettingsMenuBlade/~/properties/aadDeviceId/{{ s.intune_device_id }}">set category</a>
<a class="btn btn-sm btn-outline-warning py-0 px-1"
style="font-size:0.65rem; line-height:1;"
target="_blank" rel="noopener"
href="https://arts.dw.geaerospace.net/requests/type">ARTS request</a>
</div>
{% endif %}
<div class="text-muted mt-1" style="font-size:0.7rem;">
<span class="me-3">started <code>{{ s.started_at or '-' }}</code></span>
<span class="me-3">last <code>{{ s.last_updated or '-' }}</code></span>
<span class="me-3">MAC <code>{{ s.mac or '-' }}</code></span>
{% if s.current_stage %}<span style="font-family:monospace;">{{ s.current_stage }}</span>{% endif %}
</div>
{% if s.error %}
<div class="alert alert-danger small py-2 mb-2 mt-3">
<strong>Error:</strong> {{ s.error }}
</div>
{% endif %}
{% if not is_inferred %}
<div class="mt-3 laps-card" data-serial="{{ s.serial }}">
<div class="text-muted small mb-1">LAPS password QR (paste -> scan on bay - persists until cleared)</div>
<div class="d-flex align-items-center gap-2">
<input type="text"
class="form-control form-control-sm laps-input"
style="font-family: monospace; max-width: 22rem;"
placeholder="paste LAPS password from Intune portal here"
autocomplete="off"
value="{{ s.laps_password or '' }}">
<button type="button" class="btn btn-sm btn-primary laps-make-btn">{% if s.laps_password %}Update QR{% else %}Make QR{% endif %}</button>
<button type="button" class="btn btn-sm btn-outline-secondary laps-clear-btn" {% if not s.laps_password %}style="display:none;"{% endif %}>Clear</button>
</div>
<div class="laps-qr-container mt-2"></div>
</div>
{% endif %}
{% if s.log_tail %}
<details class="mt-3">
<summary class="text-muted small">{% if is_inferred %}Evidence{% else %}Log tail{% endif %} ({{ 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 %}
{% if not is_inferred and s.serial %}
<div class="mt-3 d-flex justify-content-between align-items-center">
<a href="{{ url_for('imaging_session_detail', serial=s.serial) }}"
class="btn btn-sm btn-outline-primary">
Details / full log
</a>
<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>
{% endif %}
</div>
</details>
{% endfor %}

View File

@@ -43,8 +43,11 @@
<!-- Images --> <!-- Images -->
<div class="card"> <div class="card">
<div class="card-header d-flex align-items-center"> <div class="card-header d-flex justify-content-between align-items-center">
Deployment Images <span>Deployment Images</span>
<button type="button" class="btn btn-sm btn-success" data-bs-toggle="modal" data-bs-target="#newImageModal">
+ New image type
</button>
</div> </div>
<div class="card-body p-0"> <div class="card-body p-0">
<table class="table table-hover mb-0"> <table class="table table-hover mb-0">
@@ -85,9 +88,21 @@
Config Config
</a> </a>
<a href="{{ url_for('unattend_editor', image_type=img.image_type) }}" <a href="{{ url_for('unattend_editor', image_type=img.image_type) }}"
class="btn btn-sm btn-outline-primary"> class="btn btn-sm btn-outline-primary me-1">
Edit Unattend Unattend
</a> </a>
<button type="button" class="btn btn-sm btn-outline-secondary me-1"
data-bs-toggle="modal" data-bs-target="#cloneImageModal"
data-src-key="{{ img.image_type }}"
data-src-friendly="{{ img.friendly_name }}">
Clone
</button>
<button type="button" class="btn btn-sm btn-outline-danger"
data-bs-toggle="modal" data-bs-target="#deleteImageModal"
data-src-key="{{ img.image_type }}"
data-src-friendly="{{ img.friendly_name }}">
Delete
</button>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
@@ -95,4 +110,132 @@
</table> </table>
</div> </div>
</div> </div>
<!-- New image type modal -->
<div class="modal fade" id="newImageModal" tabindex="-1">
<div class="modal-dialog">
<form method="post" action="{{ url_for('images_new') }}">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Create image type</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Key</label>
<input type="text" class="form-control font-monospace" name="key"
pattern="[a-z][a-z0-9-]{1,63}" required
placeholder="gea-shopfloor-newtype">
<div class="form-text">Lowercase + hyphens. Used as directory name + URL path. 2-64 chars, must start with a letter.</div>
</div>
<div class="mb-3">
<label class="form-label">Friendly name</label>
<input type="text" class="form-control" name="friendly_name" required
placeholder="GE Aerospace Shop Floor (newtype)">
</div>
<div class="alert alert-info small">
Empty image type. Populate via Image Import or Clone afterwards.
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-success">Create</button>
</div>
</div>
</form>
</div>
</div>
<!-- Clone image modal -->
<div class="modal fade" id="cloneImageModal" tabindex="-1">
<div class="modal-dialog">
<form method="post" id="cloneForm">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Clone image type</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>Source: <strong id="cloneSrcFriendly"></strong> <code id="cloneSrcKey"></code></p>
<div class="mb-3">
<label class="form-label">New key</label>
<input type="text" class="form-control font-monospace" name="dst_key"
pattern="[a-z][a-z0-9-]{1,63}" required>
</div>
<div class="mb-3">
<label class="form-label">Friendly name (optional)</label>
<input type="text" class="form-control" name="friendly_name"
placeholder="leave blank for &lsquo;<src> (copy)&rsquo;">
</div>
<div class="alert alert-info small">
Copies Deploy/ tree (Control + Tools + unattend) and preserves
symlinks to shared dirs (Out-of-box Drivers, Operating Systems,
Packages). Disk usage stays low because shared content is not
duplicated.
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-success">Clone</button>
</div>
</div>
</form>
</div>
</div>
<!-- Delete image modal -->
<div class="modal fade" id="deleteImageModal" tabindex="-1">
<div class="modal-dialog">
<form method="post" id="deleteForm">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Delete image type</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>Remove <strong id="deleteSrcFriendly"></strong> <code id="deleteSrcKey"></code> from the registry.</p>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" name="delete_content" value="1" id="deleteContentCheck">
<label class="form-check-label" for="deleteContentCheck">
Also wipe on-disk Deploy/Tools/etc (symlinked shared dirs are unlinked, not followed)
</label>
</div>
<div class="alert alert-warning small">
Removing from registry hides the image from the UI and Ansible
playbook list. Existing PXE-imaged clients are unaffected. Wiping
content is irreversible.
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-danger">Delete</button>
</div>
</div>
</form>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
document.addEventListener('show.bs.modal', function(e) {
var btn = e.relatedTarget;
if (!btn) return;
var srcKey = btn.getAttribute('data-src-key') || '';
var srcFriendly = btn.getAttribute('data-src-friendly') || '';
if (e.target.id === 'cloneImageModal') {
e.target.querySelector('#cloneSrcKey').textContent = srcKey;
e.target.querySelector('#cloneSrcFriendly').textContent = srcFriendly;
document.getElementById('cloneForm').action = '/images/' + encodeURIComponent(srcKey) + '/clone';
} else if (e.target.id === 'deleteImageModal') {
e.target.querySelector('#deleteSrcKey').textContent = srcKey;
e.target.querySelector('#deleteSrcFriendly').textContent = srcFriendly;
document.getElementById('deleteForm').action = '/images/' + encodeURIComponent(srcKey) + '/delete';
}
});
</script>
{% endblock %} {% endblock %}

View File

@@ -95,7 +95,11 @@
<span class="badge bg-secondary ms-1">{{ config.drivers|length }}</span> <span class="badge bg-secondary ms-1">{{ config.drivers|length }}</span>
</span> </span>
<div> <div>
<button type="button" class="btn btn-sm btn-success" id="saveDrivers"> <button type="button" class="btn btn-sm btn-outline-primary"
data-bs-toggle="modal" data-bs-target="#driverUploadModal">
Upload .zip
</button>
<button type="button" class="btn btn-sm btn-success ms-1" id="saveDrivers">
Save Save
</button> </button>
</div> </div>
@@ -153,37 +157,106 @@
{% endif %} {% endif %}
</div> </div>
{# Orphan drivers sub-section #} {# Orphan drivers sub-section: zips on disk that aren't referenced by #}
{# HardwareDriver.json. Each row has an inline Adopt form (family + dest #}
{# inputs -> adds an entry to HardwareDriver.json). Bulk remove deletes #}
{# the selected .zip files from Out-of-box Drivers/. #}
{% if config.orphan_drivers %} {% if config.orphan_drivers %}
<div class="card-footer orphan-section p-0"> <div class="card-footer orphan-section p-0">
<div class="px-3 py-2"> <div class="px-3 py-2 d-flex justify-content-between align-items-center">
<a class="text-decoration-none" data-bs-toggle="collapse" href="#orphanDrivers" role="button"> <a class="text-decoration-none" data-bs-toggle="collapse" href="#orphanDrivers" role="button">
<strong>Unregistered Drivers ({{ config.orphan_drivers|length }})</strong> <strong>Unregistered Drivers ({{ config.orphan_drivers|length }})</strong>
<small class="text-muted ms-1">zip files on disk not in any JSON</small> <small class="text-muted ms-1">.zip files on disk, no JSON entry</small>
</a> </a>
</div> </div>
<div class="collapse" id="orphanDrivers"> <div class="collapse show" id="orphanDrivers">
<table class="table table-sm mb-0"> <form method="POST" action="{{ url_for('images_drivers_orphans_delete', image_type=image_type) }}"
onsubmit="return confirm('Delete the selected unregistered driver .zip(s) from disk? Cannot be undone.');">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<table class="table table-sm mb-0 align-middle">
<thead class="table-light"> <thead class="table-light">
<tr> <tr>
<th style="width:30px"><input type="checkbox" id="orphanSelectAll"></th>
<th>File Name</th> <th>File Name</th>
<th>Relative Path</th> <th>Relative Path</th>
<th style="width:300px">Adopt into HardwareDriver.json</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for orph in config.orphan_drivers %} {% for orph in config.orphan_drivers %}
<tr> <tr>
<td><input type="checkbox" class="orphan-select" name="filename" value="{{ orph.fileName }}"></td>
<td><small>{{ orph.fileName }}</small></td> <td><small>{{ orph.fileName }}</small></td>
<td><small class="text-muted">{{ orph.relPath }}</small></td> <td><small class="text-muted">{{ orph.relPath }}</small></td>
<td>
<form method="POST" action="{{ url_for('images_drivers_adopt', image_type=image_type) }}"
class="d-flex gap-1">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="filename" value="{{ orph.fileName }}">
<input type="text" class="form-control form-control-sm" name="family"
placeholder="family id (e.g. Optiplex_7060)" required style="width:11rem;">
<input type="text" class="form-control form-control-sm" name="destination_dir"
placeholder="destinationDir" required style="width:11rem;">
<button type="submit" class="btn btn-sm btn-success">Adopt</button>
</form>
</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
<div class="px-3 py-2 text-end">
<button type="submit" class="btn btn-sm btn-outline-danger">
Delete selected
</button>
</div>
</form>
</div> </div>
</div> </div>
{% endif %} {% endif %}
</div> </div>
<!-- Driver upload modal -->
<div class="modal fade" id="driverUploadModal" tabindex="-1">
<div class="modal-dialog">
<form method="POST" action="{{ url_for('images_drivers_upload', image_type=image_type) }}" enctype="multipart/form-data">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Upload driver .zip</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Driver .zip file</label>
<input type="file" class="form-control" name="driver_file" accept=".zip" required>
<div class="form-text">Lands in <code>{{ image_type }}/Deploy/Out-of-box Drivers/</code>.</div>
</div>
<div class="mb-3">
<label class="form-label">Family ID (optional)</label>
<input type="text" class="form-control font-monospace" name="family"
placeholder="Optiplex_7060">
<div class="form-text">Matches a HardwareModelSelection.Id. Leave blank to land as orphan + adopt later.</div>
</div>
<div class="mb-3">
<label class="form-label">Destination directory (optional)</label>
<input type="text" class="form-control font-monospace" name="destination_dir"
placeholder="*destinationdir*\Drivers\Optiplex">
<div class="form-text">Where the .zip extracts at deploy time. Required if Family ID is set.</div>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="overwrite" value="1" id="driverOverwriteCheck">
<label class="form-check-label" for="driverOverwriteCheck">Overwrite if same filename exists</label>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Upload</button>
</div>
</div>
</form>
</div>
</div>
{# ==================== SECTION 3: Operating Systems ==================== #} {# ==================== SECTION 3: Operating Systems ==================== #}
<div class="card section-card"> <div class="card section-card">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header d-flex justify-content-between align-items-center">
@@ -262,7 +335,11 @@
<span class="badge bg-secondary ms-1">{{ config.packages|length }}</span> <span class="badge bg-secondary ms-1">{{ config.packages|length }}</span>
</span> </span>
<div> <div>
<button type="button" class="btn btn-sm btn-success" id="savePackages"> <button type="button" class="btn btn-sm btn-outline-primary"
data-bs-toggle="modal" data-bs-target="#packageUploadModal">
Upload file
</button>
<button type="button" class="btn btn-sm btn-success ms-1" id="savePackages">
Save Save
</button> </button>
</div> </div>
@@ -328,4 +405,54 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
<!-- Package upload modal -->
<div class="modal fade" id="packageUploadModal" tabindex="-1">
<div class="modal-dialog">
<form method="POST" action="{{ url_for('images_packages_upload', image_type=image_type) }}" enctype="multipart/form-data">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Upload package</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Package file</label>
<input type="file" class="form-control" name="package_file" required>
<div class="form-text">Lands in <code>{{ image_type }}/Deploy/Packages/</code>.</div>
</div>
<div class="mb-3">
<label class="form-label">Destination directory (optional)</label>
<input type="text" class="form-control font-monospace" name="destination_dir"
placeholder="*destinationdir*\Packages">
<div class="form-text">Setting this also appends an entry to <code>packages.json</code>.</div>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="overwrite" value="1" id="pkgOverwriteCheck">
<label class="form-check-label" for="pkgOverwriteCheck">Overwrite if same filename exists</label>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Upload</button>
</div>
</div>
</form>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
// Orphan drivers: select-all checkbox toggles every row's selector.
document.addEventListener('click', function(e) {
if (e.target && e.target.id === 'orphanSelectAll') {
document.querySelectorAll('.orphan-select').forEach(function(cb) {
cb.checked = e.target.checked;
});
}
});
</script>
{% endblock %} {% endblock %}

View File

@@ -2,17 +2,17 @@
{% block title %}Imaging Progress - PXE Server Manager{% endblock %} {% block title %}Imaging Progress - PXE Server Manager{% endblock %}
{% block extra_head %} {% block extra_head %}
{# JS-driven refresh instead of meta http-equiv so we can cancel it while a #} {# Tile refresh is driven by SSE (/imaging/stream) with a polling fallback. #}
{# LAPS-password QR is showing (otherwise the 5s reload wipes the in-page #} {# Replacing the full-page reload preserves scroll, filter input, expanded #}
{# state every cycle). #} {# tile state, and LAPS QR input text across refreshes. #}
<script> <script>
function scheduleImagingReload() { function scheduleImagingReload() {
window._imagingReloadTimer = setTimeout(function() { location.reload(); }, 15000); // Polling fallback only; SSE is the primary path. Initialized in
// imaging-refresh.js block at the bottom of the page.
} }
function cancelImagingReload() { function cancelImagingReload() {
if (window._imagingReloadTimer) { clearTimeout(window._imagingReloadTimer); window._imagingReloadTimer = null; } if (window._imagingPollTimer) { clearTimeout(window._imagingPollTimer); window._imagingPollTimer = null; }
} }
window.addEventListener('DOMContentLoaded', scheduleImagingReload);
</script> </script>
{% endblock %} {% endblock %}
@@ -21,10 +21,11 @@ window.addEventListener('DOMContentLoaded', scheduleImagingReload);
<div class="d-flex justify-content-between align-items-center mb-2"> <div class="d-flex justify-content-between align-items-center mb-2">
<div> <div>
<h2 class="mb-0">Imaging Progress</h2> <h2 class="mb-0">Imaging Progress</h2>
<small class="text-muted">Auto-refresh 15s. POST updates from imaging clients arrive at <code>/imaging/status</code>.</small> <small class="text-muted">Live via SSE (15s polling fallback). Client pushes -> <code>/imaging/status</code>; log-inferred bays in yellow.</small>
</div> </div>
<div class="d-flex align-items-center gap-2"> <div class="d-flex align-items-center gap-2">
<span class="badge bg-secondary fs-6"><span id="visible-count">{{ sessions|length }}</span>/{{ sessions|length }}</span> <span id="imaging-live-dot" class="status-dot" title="live stream" style="background-color:#6c757d;"></span>
<span class="badge bg-secondary fs-6"><span id="visible-count">{{ sessions|length }}</span>/<span id="total-count">{{ sessions|length }}</span></span>
{% if sessions %} {% if sessions %}
<form method="post" action="{{ url_for('imaging_delete_all') }}" <form method="post" action="{{ url_for('imaging_delete_all') }}"
onsubmit="return confirm('Clear all {{ sessions|length }} imaging session(s)? This wipes every tile from the dashboard. Live re-images will repopulate on next status push.');" onsubmit="return confirm('Clear all {{ sessions|length }} imaging session(s)? This wipes every tile from the dashboard. Live re-images will repopulate on next status push.');"
@@ -38,172 +39,24 @@ window.addEventListener('DOMContentLoaded', scheduleImagingReload);
<div class="mb-3"> <div class="mb-3">
<input id="imaging-search" type="search" class="form-control form-control-sm" <input id="imaging-search" type="search" class="form-control form-control-sm"
placeholder="Filter by serial, hostname, pctype, machine#, Intune id, stage name, stage-N, status - typing pauses auto-refresh" placeholder="Filter by serial, hostname, pctype, machine#, Intune id, MAC, IP, stage name, stage-N, status, source (client|inferred)"
autocomplete="off"> autocomplete="off">
</div> </div>
{% if not sessions %} {% if not sessions %}
<div class="card"> <div id="imaging-empty" class="card">
<div class="card-body text-center text-muted py-5"> <div class="card-body text-center text-muted py-5">
<p class="mb-1">No imaging sessions yet.</p> <p class="mb-1">No imaging sessions yet.</p>
<p class="small mb-0">A PC being imaged will post status here. <p class="small mb-0">A PC being imaged will post status here, or appear
Until then, nothing to show.</p> automatically once it touches DHCP / TFTP / boot.wim.</p>
</div> </div>
</div> </div>
{% endif %} {% endif %}
{% set stage_labels = { <div id="imaging-tiles">
1: ('Booting from PXE', 'WinPE loaded - applying Windows image to disk.'), {% include "_imaging_tiles.html" %}
2: ('Configuring Windows', 'First boot. Running shopfloor setup baseline scripts.'), </div>
3: ('Installing apps', 'Type-specific app installs (eDNC, UDC, NTLARS, etc).'),
4: ('Apps installed', 'Type-specific scripts complete. Preparing for Intune enrollment.'),
5: ('Enrolling in Intune', 'PPKG installing - device joining Azure AD + Intune. ~5-10 min, reboot to follow.'),
6: ('Waiting on first Intune sync','Post-PPKG settle (~120s). Triggering Schedule #3 sync repeatedly.'),
7: ('Registered - assign category',
'Phase 1 (Intune Registration) complete. Click "set category" in the Intune portal to drop the bay into the right config-profile group.'),
8: ('Imaging complete',
'Lockdown applied. Bay rebooted into ShopFloor session. Ready for production.')
} %}
{% 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') %}
{% set friendly = stage_labels.get(stage_idx, ('Stage ' ~ stage_idx, '')) %}
{# Stage 1 sub-phase: if WinPE pushed a BIOS update stage string, #}
{# show a BIOS-specific friendly label. Otherwise default idx=1. #}
{% if stage_idx == 1 and s.current_stage and 'bios' in s.current_stage|lower %}
{% set friendly = ('Updating BIOS firmware',
'WinPE detected a firmware update for this model. Do NOT power off until the next reboot. Imaging continues afterward.') %}
{% endif %}
{# Stage 7 fans out by sub-phase. Monitor pushes different stage #}
{# strings as it crosses each Phase 1-4 boundary. Swap friendly #}
{# label based on which keyword shows up. #}
{% if stage_idx == 7 and s.current_stage %}
{% set _cs = s.current_stage|lower %}
{% if 'ready for lockdown' in _cs or 'request lockdown' in _cs %}
{% set friendly = ('Ready - request lockdown',
'Phase 1-4 all complete (Registration, Device Config, Software Deploy, Credentials). Click "ARTS request" to initiate the lockdown workflow.') %}
{% elif 'credentials' in _cs or 'phase 4' in _cs %}
{% set friendly = ('Phase 3 / 4 - DSC + credentials',
'SFLD policy delivered, DSC pulling device-config.yaml + running per-app wrappers. SFLD share creds populating.') %}
{% elif 'sfld policy' in _cs or 'phase 2' in _cs or 'device configuration' in _cs %}
{% set friendly = ('Phase 2 - device configuration',
'Category was assigned in Intune. SFLD ConfigurationProfile (Function + SasToken) has delivered. DSC kicking off next.') %}
{% endif %}
{% endif %}
<details class="card border-{{ border }} mb-2 shadow-sm imaging-card"
data-serial="{{ s.serial }}"
data-filter="{{ s.serial|lower }} {{ (s.hostname_target or '')|lower }} {{ (s.pctype or '')|lower }} {{ (s.machinenumber or '')|lower }} {{ (s.intune_device_id or '')|lower }} {{ friendly[0]|lower }} stage-{{ stage_idx }} {{ (s.status or 'in_progress')|lower }}">
<summary class="card-body py-2" style="cursor:pointer; list-style:none;">
<div class="d-flex flex-wrap gap-3 align-items-center">
{% if s.intune_device_id %}
<div data-qr="{{ s.intune_device_id }}" data-qr-size="96" data-qr-ec="M"
style="line-height:0; flex-shrink:0;"
title="Intune Device ID: {{ s.intune_device_id }}"></div>
{% else %}
<div class="d-flex align-items-center justify-content-center bg-light text-muted small"
style="width:96px; height:96px; border-radius:0.25rem; flex-shrink:0; text-align:center; padding:0.25rem;">
no DeviceId
</div>
{% endif %}
<div class="flex-grow-1" style="min-width:0;">
<div class="d-flex flex-wrap align-items-center gap-2">
<strong class="fs-6">{{ s.serial or '(no serial)' }}</strong>
{% if s.hostname_target %}<code class="text-muted small">{{ s.hostname_target }}</code>{% endif %}
{% if s.pctype %}<span class="badge bg-info text-dark">{{ s.pctype }}</span>{% endif %}
{% if s.machinenumber %}<span class="badge bg-secondary">#{{ s.machinenumber }}</span>{% endif %}
<span class="badge bg-{{ border }} ms-auto">{{ s.status or 'in_progress' }}</span>
</div>
<div class="d-flex justify-content-between align-items-baseline mt-1">
<div>
<strong>{{ friendly[0] }}</strong>
<span class="badge bg-secondary ms-1">{{ stage_idx }}/{{ stage_total or '?' }}</span>
</div>
<span class="text-muted small">{{ pct }}%</span>
</div>
<div class="progress mt-1" style="height:0.7rem;">
<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"></div>
</div>
</div>
</div>
</summary>
<div class="card-body pt-0 pb-3 border-top">
{% if friendly[1] %}<div class="small text-muted mt-2">{{ friendly[1] }}</div>{% endif %}
{% if s.intune_device_id %}
<div class="small mt-2" style="font-size:0.75rem;">
<span class="text-muted">Intune:</span> <code>{{ s.intune_device_id }}</code>
<button type="button" class="btn btn-sm btn-outline-secondary py-0 px-1 copy-btn"
style="font-size:0.65rem; line-height:1; transition: all 0.2s;"
data-copy-text="{{ s.intune_device_id }}">copy</button>
<a class="btn btn-sm btn-outline-primary py-0 px-1"
style="font-size:0.65rem; line-height:1;"
target="_blank" rel="noopener"
href="https://portal.azure.us/?feature.msaljs=false#view/Microsoft_Intune_Devices/DeviceSettingsMenuBlade/~/properties/aadDeviceId/{{ s.intune_device_id }}">set category</a>
<a class="btn btn-sm btn-outline-warning py-0 px-1"
style="font-size:0.65rem; line-height:1;"
target="_blank" rel="noopener"
href="https://arts.dw.geaerospace.net/requests/type">ARTS request</a>
</div>
{% endif %}
<div class="text-muted mt-1" style="font-size:0.7rem;">
<span class="me-3">started <code>{{ s.started_at or '-' }}</code></span>
<span class="me-3">last <code>{{ s.last_updated or '-' }}</code></span>
<span class="me-3">MAC <code>{{ s.mac or '-' }}</code></span>
{% if s.current_stage %}<span style="font-family:monospace;">{{ s.current_stage }}</span>{% endif %}
</div>
{% if s.error %}
<div class="alert alert-danger small py-2 mb-2 mt-3">
<strong>Error:</strong> {{ s.error }}
</div>
{% endif %}
<div class="mt-3 laps-card" data-serial="{{ s.serial }}">
<div class="text-muted small mb-1">LAPS password QR (paste -> scan on bay - persists until cleared)</div>
<div class="d-flex align-items-center gap-2">
<input type="text"
class="form-control form-control-sm laps-input"
style="font-family: monospace; max-width: 22rem;"
placeholder="paste LAPS password from Intune portal here"
autocomplete="off"
value="{{ s.laps_password or '' }}">
<button type="button" class="btn btn-sm btn-primary laps-make-btn">{% if s.laps_password %}Update QR{% else %}Make QR{% endif %}</button>
<button type="button" class="btn btn-sm btn-outline-secondary laps-clear-btn" {% if not s.laps_password %}style="display:none;"{% endif %}>Clear</button>
</div>
<div class="laps-qr-container mt-2"></div>
</div>
{% if s.log_tail %}
<details class="mt-3">
<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>
</details>
{% endfor %}
<div class="card mt-3"> <div class="card mt-3">
<div class="card-body small text-muted"> <div class="card-body small text-muted">
@@ -230,6 +83,104 @@ Content-Type: application/json
{% block extra_scripts %} {% block extra_scripts %}
<script> <script>
// -------- Live refresh: SSE primary, polling fallback --------
// Rebuilds the #imaging-tiles inner HTML from /imaging/tiles when the
// server signals a state change. Preserves scroll, filter input value,
// and any LAPS input that the operator is actively editing.
(function() {
var TILES_URL = "{{ url_for('imaging_tiles_partial') }}";
var STREAM_URL = "{{ url_for('imaging_stream') }}";
var POLL_MS = 15000;
var lastHash = null;
var sse = null;
var dot = function() { return document.getElementById('imaging-live-dot'); };
function setDot(color, title) {
var d = dot();
if (d) { d.style.backgroundColor = color; d.title = title || ''; }
}
function lapsInputIsDirty() {
// Skip the tile swap if any LAPS input is focused (operator is
// mid-paste) OR has unsaved text that differs from the server-side
// copy. The next refresh after they hit Make-QR will catch up.
var active = document.activeElement;
if (active && active.classList && active.classList.contains('laps-input')) return true;
return false;
}
function refreshTiles(force) {
if (!force && lapsInputIsDirty()) return;
fetch(TILES_URL, { credentials: 'same-origin' })
.then(function(r) { return r.text(); })
.then(function(html) {
var container = document.getElementById('imaging-tiles');
if (!container) return;
container.innerHTML = html;
if (typeof window.imagingPostSwapHooks === 'function') {
window.imagingPostSwapHooks();
}
})
.catch(function(err) { console.error('refreshTiles failed:', err); });
}
function startPolling() {
if (window._imagingPollTimer) return;
window._imagingPollTimer = setInterval(function() {
refreshTiles(false);
}, POLL_MS);
}
function stopPolling() {
if (window._imagingPollTimer) {
clearInterval(window._imagingPollTimer);
window._imagingPollTimer = null;
}
}
function startSSE() {
if (!window.EventSource) {
setDot('#ffc107', 'EventSource unsupported - polling only');
startPolling();
return;
}
try {
sse = new EventSource(STREAM_URL);
} catch (e) {
setDot('#dc3545', 'SSE failed - polling');
startPolling();
return;
}
sse.onopen = function() {
setDot('#198754', 'live stream connected');
stopPolling();
};
sse.onmessage = function(ev) {
var data;
try { data = JSON.parse(ev.data); } catch (e) { return; }
if (!data || data.hash === lastHash) return;
lastHash = data.hash;
refreshTiles(false);
};
sse.onerror = function() {
setDot('#dc3545', 'live stream lost - polling fallback');
try { sse.close(); } catch (e) {}
sse = null;
startPolling();
// Try to reconnect SSE after a backoff.
setTimeout(startSSE, 10000);
};
}
// Expose so external code (LAPS, filter) can trigger an immediate
// refresh after user action.
window.imagingRefreshNow = function() { refreshTiles(true); };
window.addEventListener('DOMContentLoaded', function() {
startSSE();
});
})();
function copyText(text) { function copyText(text) {
// Modern path - only works over HTTPS or localhost // Modern path - only works over HTTPS or localhost
if (navigator.clipboard && window.isSecureContext) { if (navigator.clipboard && window.isSecureContext) {
@@ -362,67 +313,87 @@ document.addEventListener('keydown', function(e) {
if (card) { e.preventDefault(); renderLapsQR(card); } if (card) { e.preventDefault(); renderLapsQR(card); }
} }
}); });
// On page load, any laps-card with a pre-populated input (from // Per-tile hooks that must re-run after every tile-swap. Called on
// server-persisted laps_password) auto-renders its QR without re-POSTing. // DOMContentLoaded for first paint, then by the SSE/polling refresh after
window.addEventListener('DOMContentLoaded', function() { // it replaces the innerHTML of #imaging-tiles.
document.querySelectorAll('.laps-card').forEach(function(card) {
var input = card.querySelector('.laps-input');
if (input && input.value) renderLapsQR(card, { skipPersist: true });
});
});
// Persist tile expanded/collapsed state across page refresh via
// localStorage. Set of expanded serials lives at 'imaging-expanded'.
(function() { (function() {
var KEY = 'imaging-expanded'; var EXPANDED_KEY = 'imaging-expanded';
function loadSet() { function loadExpandedSet() {
try { return new Set(JSON.parse(localStorage.getItem(KEY) || '[]')); } try { return new Set(JSON.parse(localStorage.getItem(EXPANDED_KEY) || '[]')); }
catch (e) { return new Set(); } catch (e) { return new Set(); }
} }
function saveSet(set) { function saveExpandedSet(set) {
try { localStorage.setItem(KEY, JSON.stringify(Array.from(set))); } try { localStorage.setItem(EXPANDED_KEY, JSON.stringify(Array.from(set))); }
catch (e) {} catch (e) {}
} }
window.addEventListener('DOMContentLoaded', function() {
var expanded = loadSet();
document.querySelectorAll('.imaging-card').forEach(function(card) {
var serial = card.getAttribute('data-serial');
if (serial && expanded.has(serial)) card.open = true;
card.addEventListener('toggle', function() {
var s = loadSet();
if (card.open) s.add(serial); else s.delete(serial);
saveSet(s);
});
});
});
})();
// Client-side filter: hide imaging-card elements whose data-filter doesn't function restoreExpandedState() {
// match the search query. Live as user types. Pauses auto-reload while var expanded = loadExpandedSet();
// the input is focused or non-empty so typing isn't interrupted by refresh. document.querySelectorAll('.imaging-card').forEach(function(card) {
window.addEventListener('DOMContentLoaded', function() { var serial = card.getAttribute('data-serial') || card.getAttribute('data-key');
if (serial && expanded.has(serial)) card.open = true;
if (!card._toggleBound) {
card.addEventListener('toggle', function() {
var s = loadExpandedSet();
if (card.open) s.add(serial); else s.delete(serial);
saveExpandedSet(s);
});
card._toggleBound = true;
}
});
}
function autoRenderLapsQRs() {
document.querySelectorAll('.laps-card').forEach(function(card) {
var input = card.querySelector('.laps-input');
var container = card.querySelector('.laps-qr-container');
if (input && input.value && container && !container.innerHTML.trim()) {
renderLapsQR(card, { skipPersist: true });
}
});
}
function renderIntuneQRs() {
// qr-render.js looks for [data-qr] and renders an image. It runs on
// initial DOMContentLoaded but not after a tile-swap. Re-run if the
// hook is exposed; otherwise no-op.
if (typeof window.renderAllQRs === 'function') window.renderAllQRs();
}
function applyFilter() {
var search = document.getElementById('imaging-search'); var search = document.getElementById('imaging-search');
var counter = document.getElementById('visible-count'); var counter = document.getElementById('visible-count');
var total = document.getElementById('total-count');
if (!search) return; if (!search) return;
function applyFilter() {
var q = search.value.trim().toLowerCase(); var q = search.value.trim().toLowerCase();
var visible = 0; var visible = 0, totalN = 0;
document.querySelectorAll('.imaging-card').forEach(function(card) { document.querySelectorAll('.imaging-card').forEach(function(card) {
totalN++;
var hay = card.getAttribute('data-filter') || ''; var hay = card.getAttribute('data-filter') || '';
var match = (q === '') || hay.indexOf(q) !== -1; var match = (q === '') || hay.indexOf(q) !== -1;
card.style.display = match ? '' : 'none'; card.style.display = match ? '' : 'none';
if (match) visible++; if (match) visible++;
}); });
if (counter) counter.textContent = visible; if (counter) counter.textContent = visible;
if (q !== '') { cancelImagingReload(); } if (total) total.textContent = totalN;
else { cancelImagingReload(); scheduleImagingReload(); }
} }
search.addEventListener('input', applyFilter);
search.addEventListener('focus', cancelImagingReload); window.imagingPostSwapHooks = function() {
search.addEventListener('blur', function() { restoreExpandedState();
if (search.value.trim() === '') { cancelImagingReload(); scheduleImagingReload(); } autoRenderLapsQRs();
}); renderIntuneQRs();
applyFilter(); applyFilter();
}); };
window.addEventListener('DOMContentLoaded', function() {
// Search input is rendered outside #imaging-tiles, so its listeners
// only bind once.
var search = document.getElementById('imaging-search');
if (search) {
search.addEventListener('input', applyFilter);
}
window.imagingPostSwapHooks();
});
})();
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,191 @@
{% extends "base.html" %}
{% block title %}{{ session.serial }} - Imaging Session{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h2 class="mb-0">{{ session.serial }}
{% if session.hostname_target %}<small class="text-muted">{{ session.hostname_target }}</small>{% endif %}
</h2>
<small class="text-muted">
{% if session.pctype %}<span class="badge bg-info text-dark">{{ session.pctype }}</span>{% endif %}
{% if session.machinenumber %}<span class="badge bg-secondary">#{{ session.machinenumber }}</span>{% endif %}
<span class="badge bg-{{ 'success' if session.status == 'succeeded' else ('danger' if session.status == 'failed' else 'primary') }}">{{ session.status or 'in_progress' }}</span>
</small>
</div>
<div>
<a href="{{ url_for('imaging_dashboard') }}" class="btn btn-outline-secondary btn-sm">
&laquo; Back to dashboard
</a>
<button type="button" class="btn btn-outline-primary btn-sm copy-summary-btn">
Copy support summary
</button>
</div>
</div>
<div class="row">
<div class="col-lg-5">
<div class="card mb-3">
<div class="card-header">Session metadata</div>
<div class="card-body">
<dl class="row mb-0 small">
<dt class="col-4">Serial</dt><dd class="col-8"><code>{{ session.serial }}</code></dd>
<dt class="col-4">Hostname</dt><dd class="col-8"><code>{{ session.hostname_target or '-' }}</code></dd>
<dt class="col-4">MAC</dt><dd class="col-8"><code>{{ session.mac or '-' }}</code></dd>
<dt class="col-4">PC type</dt><dd class="col-8">{{ session.pctype or '-' }}</dd>
<dt class="col-4">Machine #</dt><dd class="col-8">{{ session.machinenumber or '-' }}</dd>
<dt class="col-4">Intune ID</dt><dd class="col-8">
{% if session.intune_device_id %}
<code style="word-break: break-all;">{{ session.intune_device_id }}</code>
{% else %}-{% endif %}
</dd>
<dt class="col-4">Started</dt><dd class="col-8"><code>{{ session.started_at or '-' }}</code></dd>
<dt class="col-4">Last update</dt><dd class="col-8"><code>{{ session.last_updated or '-' }}</code></dd>
<dt class="col-4">Stage</dt><dd class="col-8">
{{ session.stage_index or 0 }}/{{ session.stage_total or '?' }}
{% if session.current_stage %} - <code class="small">{{ session.current_stage }}</code>{% endif %}
</dd>
{% if session.previous_run_at %}
<dt class="col-4">Prior run</dt><dd class="col-8"><code>{{ session.previous_run_at }}</code></dd>
{% endif %}
{% if session.error %}
<dt class="col-4 text-danger">Error</dt>
<dd class="col-8 text-danger">{{ session.error }}</dd>
{% endif %}
</dl>
</div>
</div>
<div class="card mb-3">
<div class="card-header">Stage timeline</div>
<div class="card-body p-0">
{% if session.stage_history %}
<table class="table table-sm mb-0">
<thead class="table-light">
<tr><th>Time</th><th>Stage</th><th>Status</th><th>Detail</th></tr>
</thead>
<tbody>
{% for h in session.stage_history %}
<tr>
<td class="small"><code>{{ h.ts }}</code></td>
<td><span class="badge bg-secondary">{{ h.stage_index }}</span></td>
<td>
<span class="badge bg-{{ 'success' if h.status == 'succeeded' else ('danger' if h.status == 'failed' else 'primary') }}">
{{ h.status }}
</span>
</td>
<td class="small"><code>{{ h.current_stage or '' }}</code></td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="p-3 text-muted small">
No stage transitions recorded yet. The client only logs a row when
stage_index advances or status flips to succeeded/failed.
</div>
{% endif %}
</div>
</div>
</div>
<div class="col-lg-7">
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<span>Full log
{% if full_log_truncated %}
<span class="badge bg-warning text-dark ms-2">truncated (last 1 MB)</span>
{% endif %}
</span>
<button type="button" class="btn btn-sm btn-outline-secondary copy-log-btn">Copy</button>
</div>
<div class="card-body p-0">
{% if full_log %}
<pre id="full-log" class="bg-light p-2 mb-0 small" style="max-height: 36rem; overflow-y: auto; white-space: pre-wrap;">{{ full_log }}</pre>
{% else %}
<div class="p-3 text-muted small">
No log content. The client has not pushed any <code>log_lines</code>
entries yet, or the sidecar file was cleared.
</div>
{% endif %}
</div>
</div>
{% if session.log_tail %}
<div class="card mb-3">
<div class="card-header">Recent tail ({{ session.log_tail | length }} line{{ 's' if session.log_tail | length != 1 }})</div>
<div class="card-body p-0">
<pre class="bg-light p-2 mb-0 small" style="max-height: 12rem; overflow-y: auto;">{% for line in session.log_tail %}{{ line }}
{% endfor %}</pre>
</div>
</div>
{% endif %}
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
function copyText(text) {
if (navigator.clipboard && window.isSecureContext) {
return navigator.clipboard.writeText(text);
}
return new Promise(function(resolve, reject) {
var ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed'; ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.focus(); ta.select();
try {
var ok = document.execCommand('copy');
document.body.removeChild(ta);
if (ok) resolve(); else reject(new Error('execCommand returned false'));
} catch (err) {
document.body.removeChild(ta);
reject(err);
}
});
}
document.addEventListener('click', function(e) {
if (e.target.classList.contains('copy-log-btn')) {
var pre = document.getElementById('full-log');
if (pre) {
copyText(pre.textContent).then(function() {
e.target.textContent = 'copied!';
setTimeout(function() { e.target.textContent = 'Copy'; }, 1200);
});
}
} else if (e.target.classList.contains('copy-summary-btn')) {
var lines = [];
document.querySelectorAll('dl dt').forEach(function(dt) {
var dd = dt.nextElementSibling;
if (dd) {
lines.push(dt.textContent.trim() + ': ' + dd.textContent.trim().replace(/\s+/g, ' '));
}
});
var rows = document.querySelectorAll('table tbody tr');
if (rows.length) {
lines.push('');
lines.push('Stage timeline:');
rows.forEach(function(tr) {
var cells = tr.querySelectorAll('td');
if (cells.length === 4) {
lines.push(' - ' + cells[0].textContent.trim() + ' stage ' + cells[1].textContent.trim()
+ ' ' + cells[2].textContent.trim() + ' ' + cells[3].textContent.trim());
}
});
}
copyText(lines.join('\n')).then(function() {
e.target.textContent = 'copied!';
setTimeout(function() { e.target.textContent = 'Copy support summary'; }, 1200);
});
}
});
</script>
{% endblock %}