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:
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")
|
||||
Reference in New Issue
Block a user