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:
273
webapp/app.py
273
webapp/app.py
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
254
webapp/services/image_registry.py
Normal file
254
webapp/services/image_registry.py
Normal 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")
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
|||||||
376
webapp/services/imaging_log_tail.py
Normal file
376
webapp/services/imaging_log_tail.py
Normal 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
|
||||||
@@ -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,7 +212,11 @@ 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
|
||||||
out.sort(key=lambda s: s.get("last_updated", ""), reverse=True)
|
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)
|
||||||
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))
|
except OSError:
|
||||||
removed += 1
|
continue
|
||||||
except OSError:
|
if fn.endswith(".json"):
|
||||||
pass
|
removed += 1
|
||||||
return removed
|
return removed
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
192
webapp/templates/_imaging_tiles.html
Normal file
192
webapp/templates/_imaging_tiles.html
Normal 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 %}
|
||||||
@@ -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 ‘<src> (copy)’">
|
||||||
|
</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 %}
|
||||||
|
|||||||
@@ -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) }}"
|
||||||
<thead class="table-light">
|
onsubmit="return confirm('Delete the selected unregistered driver .zip(s) from disk? Cannot be undone.');">
|
||||||
<tr>
|
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||||
<th>File Name</th>
|
<table class="table table-sm mb-0 align-middle">
|
||||||
<th>Relative Path</th>
|
<thead class="table-light">
|
||||||
</tr>
|
<tr>
|
||||||
</thead>
|
<th style="width:30px"><input type="checkbox" id="orphanSelectAll"></th>
|
||||||
<tbody>
|
<th>File Name</th>
|
||||||
{% for orph in config.orphan_drivers %}
|
<th>Relative Path</th>
|
||||||
<tr>
|
<th style="width:300px">Adopt into HardwareDriver.json</th>
|
||||||
<td><small>{{ orph.fileName }}</small></td>
|
</tr>
|
||||||
<td><small class="text-muted">{{ orph.relPath }}</small></td>
|
</thead>
|
||||||
</tr>
|
<tbody>
|
||||||
{% endfor %}
|
{% for orph in config.orphan_drivers %}
|
||||||
</tbody>
|
<tr>
|
||||||
</table>
|
<td><input type="checkbox" class="orphan-select" name="filename" value="{{ orph.fileName }}"></td>
|
||||||
|
<td><small>{{ orph.fileName }}</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>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</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 %}
|
||||||
|
|||||||
@@ -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.
|
|
||||||
window.addEventListener('DOMContentLoaded', function() {
|
|
||||||
var search = document.getElementById('imaging-search');
|
|
||||||
var counter = document.getElementById('visible-count');
|
|
||||||
if (!search) return;
|
|
||||||
function applyFilter() {
|
|
||||||
var q = search.value.trim().toLowerCase();
|
|
||||||
var visible = 0;
|
|
||||||
document.querySelectorAll('.imaging-card').forEach(function(card) {
|
document.querySelectorAll('.imaging-card').forEach(function(card) {
|
||||||
|
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 counter = document.getElementById('visible-count');
|
||||||
|
var total = document.getElementById('total-count');
|
||||||
|
if (!search) return;
|
||||||
|
var q = search.value.trim().toLowerCase();
|
||||||
|
var visible = 0, totalN = 0;
|
||||||
|
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();
|
||||||
|
};
|
||||||
|
|
||||||
|
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();
|
||||||
});
|
});
|
||||||
applyFilter();
|
})();
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
191
webapp/templates/imaging_detail.html
Normal file
191
webapp/templates/imaging_detail.html
Normal 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">
|
||||||
|
« 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 %}
|
||||||
Reference in New Issue
Block a user