Hardware coverage: flag models the server has no drivers or BIOS for
WinPE now reports each machine's WMI model, manufacturer, Dell system
SKU and BIOS version to /hardware/seen (winpe-status-push.ps1, best
effort, wmic only). The webapp records every model seen and checks it
against what the server has:
- each image's Deploy/Control/HardwareDriver.json, matched exactly as
PESetup's GetDriverByModel does (as reimplemented in
lint-driver-catalogue.py), plus whether the pack's zip is on disk;
- the WinPE BIOS manifest (substring match like check-bios.cmd).
/hardware lists every model with per-image driver status and BIOS
status. A model missing a Win11 pack or a BIOS entry becomes item 5 on
the Technician To-Do (and the banner count) until the pack/BIOS is added
or it is dismissed. Virtual machines are ignored.
Also deploys the repo winpe-status-push.ps1 over the live copy, which
predated the -CurrentStage/-StageIndex parameters startnet.cmd passes
for the BIOS-flash stage, so that status post was failing.
This commit is contained in:
@@ -74,4 +74,36 @@ try {
|
|||||||
Log "ERR $($_.Exception.Message)"
|
Log "ERR $($_.Exception.Message)"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Hardware facts for the webapp's driver/BIOS coverage check (/hardware):
|
||||||
|
# the exact WMI model PESetup matches driver packs on, the Dell system SKU
|
||||||
|
# (what Dell's catalogs key on), and the BIOS version check-bios.cmd sees.
|
||||||
|
function Get-WmicValue {
|
||||||
|
param([string]$Class, [string]$Property)
|
||||||
|
try {
|
||||||
|
$line = (& wmic $Class get $Property /value 2>$null |
|
||||||
|
Where-Object { $_ -match '=' } | Select-Object -First 1)
|
||||||
|
if ($line) { return ($line -split '=', 2)[1].Trim() }
|
||||||
|
} catch { Log "wmic $Class $Property exception: $_" }
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
$hw = @{
|
||||||
|
serial = $serial
|
||||||
|
model = Get-WmicValue 'computersystem' 'Model'
|
||||||
|
manufacturer = Get-WmicValue 'computersystem' 'Manufacturer'
|
||||||
|
sku = Get-WmicValue 'computersystem' 'SystemSKUNumber'
|
||||||
|
bios_version = Get-WmicValue 'bios' 'SMBIOSBIOSVersion'
|
||||||
|
pctype = $PCType
|
||||||
|
}
|
||||||
|
Log "hardware model=$($hw.model) sku=$($hw.sku) bios=$($hw.bios_version)"
|
||||||
|
if ($hw.model) {
|
||||||
|
try {
|
||||||
|
$resp = Invoke-WebRequest -Uri "http://${PxeServer}:${Port}/hardware/seen" -Method POST `
|
||||||
|
-Body ($hw | ConvertTo-Json -Compress) -ContentType 'application/json' `
|
||||||
|
-UseBasicParsing -TimeoutSec $TimeoutSec -ErrorAction Stop
|
||||||
|
Log "hardware OK http=$($resp.StatusCode)"
|
||||||
|
} catch {
|
||||||
|
Log "hardware ERR $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ from werkzeug.utils import secure_filename
|
|||||||
import config
|
import config
|
||||||
from services import (
|
from services import (
|
||||||
blancco_evidence,
|
blancco_evidence,
|
||||||
|
hardware_coverage,
|
||||||
blancco_report,
|
blancco_report,
|
||||||
deploy,
|
deploy,
|
||||||
fs,
|
fs,
|
||||||
@@ -721,6 +722,7 @@ def blancco_servicenow_mark(document_id):
|
|||||||
def blancco_todo():
|
def blancco_todo():
|
||||||
"""Technician to-do: the playbook steps the server cannot do itself."""
|
"""Technician to-do: the playbook steps the server cannot do itself."""
|
||||||
return render_template("reports_todo.html", todo=blancco_evidence.todo(),
|
return render_template("reports_todo.html", todo=blancco_evidence.todo(),
|
||||||
|
hardware=hardware_coverage.open_gaps(),
|
||||||
site=config.BLANCCO_SITE)
|
site=config.BLANCCO_SITE)
|
||||||
|
|
||||||
|
|
||||||
@@ -744,6 +746,37 @@ def blancco_evidence_file(filename):
|
|||||||
return send_file(fpath, as_attachment=True)
|
return send_file(fpath, as_attachment=True)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/hardware/seen", methods=["POST"])
|
||||||
|
def hardware_seen():
|
||||||
|
# CSRF-exempt machine-to-machine endpoint: WinPE (winpe-status-push.ps1)
|
||||||
|
# reports each machine's model, SKU and BIOS version before imaging.
|
||||||
|
p = request.get_json(silent=True) or {}
|
||||||
|
try:
|
||||||
|
hardware_coverage.record(p)
|
||||||
|
except ValueError as ex:
|
||||||
|
return jsonify({"error": str(ex)}), 400
|
||||||
|
return jsonify({"ok": True}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/hardware")
|
||||||
|
def hardware_page():
|
||||||
|
"""Every model WinPE has seen, with driver-pack and BIOS coverage."""
|
||||||
|
return render_template("hardware.html", rows=hardware_coverage.coverage(),
|
||||||
|
images=hardware_coverage.IMAGES)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/hardware/dismiss", methods=["POST"])
|
||||||
|
def hardware_dismiss():
|
||||||
|
model = request.form.get("model", "")
|
||||||
|
done = request.form.get("done", "1") == "1"
|
||||||
|
try:
|
||||||
|
hardware_coverage.dismiss(model, done)
|
||||||
|
except ValueError:
|
||||||
|
abort(400)
|
||||||
|
audit("HARDWARE_DISMISS", f"{model} {'dismissed' if done else 'reopened'}")
|
||||||
|
return redirect(request.form.get("next") or url_for("hardware_page"))
|
||||||
|
|
||||||
|
|
||||||
@app.route("/reports/download/<filename>")
|
@app.route("/reports/download/<filename>")
|
||||||
def blancco_download_report(filename):
|
def blancco_download_report(filename):
|
||||||
filename = secure_filename(filename)
|
filename = secure_filename(filename)
|
||||||
@@ -1531,7 +1564,9 @@ def inject_globals():
|
|||||||
return {
|
return {
|
||||||
"all_image_types": config.IMAGE_TYPES,
|
"all_image_types": config.IMAGE_TYPES,
|
||||||
"all_friendly_names": config.FRIENDLY_NAMES,
|
"all_friendly_names": config.FRIENDLY_NAMES,
|
||||||
"blancco_todo": blancco_evidence.todo_counts(),
|
"blancco_todo": dict(blancco_evidence.todo_counts(),
|
||||||
|
hardware=hardware_coverage.gap_count(),
|
||||||
|
total=blancco_evidence.todo_counts()["total"] + hardware_coverage.gap_count()),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ BLANCCO_SITE = os.environ.get("BLANCCO_SITE", "West Jefferson")
|
|||||||
# Every time the webapp shows (and evidence-pack month boundaries) is in
|
# Every time the webapp shows (and evidence-pack month boundaries) is in
|
||||||
# this zone, DST included. Stored timestamps keep their own UTC offsets.
|
# this zone, DST included. Stored timestamps keep their own UTC offsets.
|
||||||
DISPLAY_TZ = os.environ.get("DISPLAY_TZ", "America/New_York")
|
DISPLAY_TZ = os.environ.get("DISPLAY_TZ", "America/New_York")
|
||||||
|
# Hardware models seen by WinPE, checked against driver packs and BIOS.
|
||||||
|
HARDWARE_STATE = os.environ.get("HARDWARE_STATE", "/var/lib/pxe-webapp/hardware-seen.json")
|
||||||
|
BIOS_DIR = os.environ.get("BIOS_DIR", "/srv/samba/winpeapps/_shared/BIOS")
|
||||||
BLANCCO_STATE = os.environ.get("BLANCCO_STATE", "/var/lib/pxe-webapp/blancco-state.json")
|
BLANCCO_STATE = os.environ.get("BLANCCO_STATE", "/var/lib/pxe-webapp/blancco-state.json")
|
||||||
|
|
||||||
# Log sources used by services/imaging_log_tail.py to infer client progress
|
# Log sources used by services/imaging_log_tail.py to infer client progress
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from flask import abort, request, session
|
|||||||
CSRF_EXEMPT_PATHS = {
|
CSRF_EXEMPT_PATHS = {
|
||||||
"/imaging/status",
|
"/imaging/status",
|
||||||
"/reports/cloud-status",
|
"/reports/cloud-status",
|
||||||
|
"/hardware/seen",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
272
webapp/services/hardware_coverage.py
Normal file
272
webapp/services/hardware_coverage.py
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
"""Which hardware models the PXE server can image, and which it cannot.
|
||||||
|
|
||||||
|
WinPE (playbook/winpe-status-push.ps1) reports each machine's WMI model,
|
||||||
|
manufacturer, Dell system SKU and BIOS version to /hardware/seen before
|
||||||
|
imaging starts. This module records the models seen and checks each one
|
||||||
|
against what the server actually has:
|
||||||
|
|
||||||
|
* drivers: every image's Deploy/Control/HardwareDriver.json, matched the
|
||||||
|
way PESetup's GetDriverByModel matches (scripts/lint-driver-catalogue.py
|
||||||
|
reimplements it and documents why), plus whether the pack's zip is on
|
||||||
|
disk;
|
||||||
|
* BIOS: the WinPE BIOS manifest (models.txt, substring match like
|
||||||
|
check-bios.cmd) and whether the machine is already on that version.
|
||||||
|
|
||||||
|
A model missing drivers for an image, or with no BIOS entry, becomes a
|
||||||
|
technician to-do until it is fixed (the check re-runs on every page load,
|
||||||
|
so adding the pack clears it) or dismissed.
|
||||||
|
|
||||||
|
State: one JSON file, config.HARDWARE_STATE, written atomically:
|
||||||
|
{"models": {model: {manufacturer, skus[], serials[], bios_versions[],
|
||||||
|
pctypes[], first_seen, last_seen, count}},
|
||||||
|
"dismissed": {model: {"ts"}}}
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import PurePosixPath
|
||||||
|
|
||||||
|
import config
|
||||||
|
|
||||||
|
# Images whose HardwareDriver.json is checked, with the Windows they deploy.
|
||||||
|
IMAGES = (
|
||||||
|
("gea-standard", "Win11"),
|
||||||
|
("gea-shopfloor", "Win11 LTSC"),
|
||||||
|
("gea-engineer", "Win10"),
|
||||||
|
)
|
||||||
|
KEEP = 10 # serials / versions remembered per model
|
||||||
|
|
||||||
|
|
||||||
|
# --- PESetup's matcher (see scripts/lint-driver-catalogue.py) ---------------
|
||||||
|
|
||||||
|
def _model_family(model):
|
||||||
|
upper = model.upper()
|
||||||
|
family = ""
|
||||||
|
if "LATITUDE" in upper:
|
||||||
|
family = "Latitude"
|
||||||
|
if "OPTIPLEX" in upper:
|
||||||
|
family = "Optiplex"
|
||||||
|
if "PRECISION" in upper:
|
||||||
|
family = "Precision"
|
||||||
|
return family
|
||||||
|
|
||||||
|
|
||||||
|
def _entry_matches(entry, model):
|
||||||
|
family = _model_family(model)
|
||||||
|
if family and family.lower() not in str(entry.get("family") or "").lower():
|
||||||
|
return False
|
||||||
|
# Not trimmed, case-insensitive substring: what the C# does.
|
||||||
|
return any(t.lower() in model.lower()
|
||||||
|
for t in str(entry.get("modelswminame") or "").split(","))
|
||||||
|
|
||||||
|
|
||||||
|
def _zip_relpath(entry):
|
||||||
|
dest = (str(entry.get("destinationDir") or entry.get("DestinationDir") or "")
|
||||||
|
.replace("*destinationdir*\\", "").replace("*destinationdir*", "")
|
||||||
|
.replace("\\", "/").strip("/"))
|
||||||
|
name = str(entry.get("fileName") or entry.get("FileName") or "")
|
||||||
|
return str(PurePosixPath(dest) / name) if name else None
|
||||||
|
|
||||||
|
|
||||||
|
def _exists_ci(root, relpath):
|
||||||
|
"""Path exists, matching each component case-insensitively (Dell_11/
|
||||||
|
Optiplex vs OptiPlex has split driver trees before)."""
|
||||||
|
cur = root
|
||||||
|
for part in PurePosixPath(relpath).parts:
|
||||||
|
try:
|
||||||
|
names = os.listdir(cur)
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
hit = next((n for n in names if n.lower() == part.lower()), None)
|
||||||
|
if hit is None:
|
||||||
|
return False
|
||||||
|
cur = os.path.join(cur, hit)
|
||||||
|
return os.path.isfile(cur)
|
||||||
|
|
||||||
|
|
||||||
|
_catalog_cache = {"at": 0.0, "value": None}
|
||||||
|
|
||||||
|
|
||||||
|
def _catalogs(ttl=60.0):
|
||||||
|
now = time.monotonic()
|
||||||
|
if _catalog_cache["value"] is None or now - _catalog_cache["at"] > ttl:
|
||||||
|
cats = {}
|
||||||
|
for image, _ in IMAGES:
|
||||||
|
path = os.path.join(config.SAMBA_SHARE, image, "Deploy", "Control", "HardwareDriver.json")
|
||||||
|
try:
|
||||||
|
with open(path, encoding="utf-8-sig") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
cats[image] = data if isinstance(data, list) else []
|
||||||
|
except (OSError, ValueError):
|
||||||
|
cats[image] = None # image not present on this server
|
||||||
|
_catalog_cache.update(at=now, value=cats)
|
||||||
|
return _catalog_cache["value"]
|
||||||
|
|
||||||
|
|
||||||
|
def driver_status(model):
|
||||||
|
"""Per image: {"status": ok|no-entry|zip-missing|no-image, "file": ...}."""
|
||||||
|
out = {}
|
||||||
|
for image, os_label in IMAGES:
|
||||||
|
entries = _catalogs().get(image)
|
||||||
|
if entries is None:
|
||||||
|
out[image] = {"status": "no-image", "os": os_label}
|
||||||
|
continue
|
||||||
|
entry = next((e for e in entries if _entry_matches(e, model)), None)
|
||||||
|
if entry is None:
|
||||||
|
out[image] = {"status": "no-entry", "os": os_label}
|
||||||
|
continue
|
||||||
|
rel = _zip_relpath(entry)
|
||||||
|
root = os.path.join(config.SAMBA_SHARE, image)
|
||||||
|
ok = bool(rel) and _exists_ci(root, rel)
|
||||||
|
out[image] = {"status": "ok" if ok else "zip-missing", "os": os_label,
|
||||||
|
"file": entry.get("fileName") or entry.get("FileName") or ""}
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _ver(v):
|
||||||
|
parts = []
|
||||||
|
for p in str(v or "").replace("A", "").split("."):
|
||||||
|
try:
|
||||||
|
parts.append(int(p))
|
||||||
|
except ValueError:
|
||||||
|
parts.append(0)
|
||||||
|
return tuple(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def bios_status(model, current_version=""):
|
||||||
|
"""{"status": none|update|current|file-missing, "target", "file"}."""
|
||||||
|
manifest = os.path.join(config.BIOS_DIR, "models.txt")
|
||||||
|
try:
|
||||||
|
with open(manifest, encoding="utf-8", errors="replace") as f:
|
||||||
|
lines = f.read().splitlines()
|
||||||
|
except OSError:
|
||||||
|
return {"status": "none", "target": "", "file": ""}
|
||||||
|
for line in lines:
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#") or line.count("|") < 2:
|
||||||
|
continue
|
||||||
|
sub, bfile, target = (x.strip() for x in line.split("|")[:3])
|
||||||
|
if sub and sub.lower() in model.lower():
|
||||||
|
if not os.path.isfile(os.path.join(config.BIOS_DIR, bfile)):
|
||||||
|
return {"status": "file-missing", "target": target, "file": bfile}
|
||||||
|
if current_version and _ver(current_version) >= _ver(target):
|
||||||
|
return {"status": "current", "target": target, "file": bfile}
|
||||||
|
return {"status": "update", "target": target, "file": bfile}
|
||||||
|
return {"status": "none", "target": "", "file": ""}
|
||||||
|
|
||||||
|
|
||||||
|
# --- state ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _load():
|
||||||
|
try:
|
||||||
|
with open(config.HARDWARE_STATE) as f:
|
||||||
|
state = json.load(f)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
state = {}
|
||||||
|
state.setdefault("models", {})
|
||||||
|
state.setdefault("dismissed", {})
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
def _save(state):
|
||||||
|
folder = os.path.dirname(config.HARDWARE_STATE)
|
||||||
|
os.makedirs(folder, exist_ok=True)
|
||||||
|
fd, tmp = tempfile.mkstemp(dir=folder, prefix=".hardware-")
|
||||||
|
with os.fdopen(fd, "w") as f:
|
||||||
|
json.dump(state, f, indent=1, sort_keys=True)
|
||||||
|
os.replace(tmp, config.HARDWARE_STATE)
|
||||||
|
|
||||||
|
|
||||||
|
def _now():
|
||||||
|
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||||
|
|
||||||
|
|
||||||
|
def _push(lst, value):
|
||||||
|
if value and value not in lst:
|
||||||
|
lst.append(value)
|
||||||
|
del lst[:-KEEP]
|
||||||
|
|
||||||
|
|
||||||
|
def record(payload):
|
||||||
|
"""Remember one machine sighting from WinPE."""
|
||||||
|
model = " ".join(str(payload.get("model") or "").split())
|
||||||
|
if not model:
|
||||||
|
raise ValueError("missing model")
|
||||||
|
model = model[:120]
|
||||||
|
state = _load()
|
||||||
|
rec = state["models"].setdefault(model, {
|
||||||
|
"first_seen": _now(), "skus": [], "serials": [], "bios_versions": [],
|
||||||
|
"pctypes": [], "count": 0})
|
||||||
|
rec["manufacturer"] = str(payload.get("manufacturer") or rec.get("manufacturer") or "")[:60]
|
||||||
|
rec["last_seen"] = _now()
|
||||||
|
rec["count"] = int(rec.get("count") or 0) + 1
|
||||||
|
_push(rec["skus"], str(payload.get("sku") or "").strip()[:40])
|
||||||
|
_push(rec["serials"], str(payload.get("serial") or "").strip()[:64])
|
||||||
|
_push(rec["bios_versions"], str(payload.get("bios_version") or "").strip()[:40])
|
||||||
|
_push(rec["pctypes"], str(payload.get("pctype") or "").strip()[:60])
|
||||||
|
rec["last_bios_version"] = str(payload.get("bios_version") or "").strip()[:40]
|
||||||
|
_save(state)
|
||||||
|
invalidate()
|
||||||
|
|
||||||
|
|
||||||
|
def dismiss(model, done=True):
|
||||||
|
state = _load()
|
||||||
|
if model not in state["models"]:
|
||||||
|
raise ValueError("unknown model")
|
||||||
|
if done:
|
||||||
|
state["dismissed"][model] = {"ts": _now()}
|
||||||
|
else:
|
||||||
|
state["dismissed"].pop(model, None)
|
||||||
|
_save(state)
|
||||||
|
invalidate()
|
||||||
|
|
||||||
|
|
||||||
|
# Virtual machines and other non-Dell hardware have no packs by design.
|
||||||
|
_SKIP = ("virtual", "vmware", "qemu", "hyper-v", "kvm")
|
||||||
|
|
||||||
|
|
||||||
|
def coverage(include_ok=True):
|
||||||
|
"""Every model seen, newest first, with driver and BIOS status."""
|
||||||
|
state = _load()
|
||||||
|
rows = []
|
||||||
|
for model, rec in state["models"].items():
|
||||||
|
if any(s in model.lower() for s in _SKIP):
|
||||||
|
continue
|
||||||
|
drivers = driver_status(model)
|
||||||
|
bios = bios_status(model, rec.get("last_bios_version", ""))
|
||||||
|
missing = [img for img, d in drivers.items()
|
||||||
|
if d["status"] in ("no-entry", "zip-missing") and img != "gea-engineer"]
|
||||||
|
gap = bool(missing) or bios["status"] in ("none", "file-missing")
|
||||||
|
row = dict(rec, model=model, drivers=drivers, bios=bios, missing=missing,
|
||||||
|
gap=gap, dismissed=model in state["dismissed"])
|
||||||
|
if include_ok or gap:
|
||||||
|
rows.append(row)
|
||||||
|
rows.sort(key=lambda r: r.get("last_seen") or "", reverse=True)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def open_gaps():
|
||||||
|
return [r for r in coverage(include_ok=False) if not r["dismissed"]]
|
||||||
|
|
||||||
|
|
||||||
|
_count_cache = {"at": 0.0, "value": None}
|
||||||
|
|
||||||
|
|
||||||
|
def gap_count(ttl=60.0):
|
||||||
|
now = time.monotonic()
|
||||||
|
if _count_cache["value"] is None or now - _count_cache["at"] > ttl:
|
||||||
|
try:
|
||||||
|
_count_cache["value"] = len(open_gaps())
|
||||||
|
except Exception:
|
||||||
|
_count_cache["value"] = 0
|
||||||
|
_count_cache["at"] = now
|
||||||
|
return _count_cache["value"]
|
||||||
|
|
||||||
|
|
||||||
|
def invalidate():
|
||||||
|
_count_cache["value"] = None
|
||||||
|
_catalog_cache["value"] = None
|
||||||
@@ -75,6 +75,12 @@
|
|||||||
<i class="bi bi-shield-check"></i> Blancco Reports
|
<i class="bi bi-shield-check"></i> Blancco Reports
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if request.endpoint == 'hardware_page' %}active{% endif %}"
|
||||||
|
href="{{ url_for('hardware_page') }}">
|
||||||
|
<i class="bi bi-cpu"></i> Hardware Coverage
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {% if request.endpoint == 'blancco_todo' %}active{% endif %}"
|
<a class="nav-link {% if request.endpoint == 'blancco_todo' %}active{% endif %}"
|
||||||
href="{{ url_for('blancco_todo') }}">
|
href="{{ url_for('blancco_todo') }}">
|
||||||
@@ -145,6 +151,7 @@
|
|||||||
{% if blancco_todo.evidence %}{% set _ = parts.append(blancco_todo.evidence ~ ' evidence pack' ~ ('s' if blancco_todo.evidence != 1) ~ ' to upload to Box') %}{% endif %}
|
{% if blancco_todo.evidence %}{% set _ = parts.append(blancco_todo.evidence ~ ' evidence pack' ~ ('s' if blancco_todo.evidence != 1) ~ ' to upload to Box') %}{% endif %}
|
||||||
{% if blancco_todo.failed %}{% set _ = parts.append(blancco_todo.failed ~ ' failed wipe' ~ ('s' if blancco_todo.failed != 1) ~ ' needing a Certificate of Destruction') %}{% endif %}
|
{% if blancco_todo.failed %}{% set _ = parts.append(blancco_todo.failed ~ ' failed wipe' ~ ('s' if blancco_todo.failed != 1) ~ ' needing a Certificate of Destruction') %}{% endif %}
|
||||||
{% if blancco_todo.not_in_cloud %}{% set _ = parts.append(blancco_todo.not_in_cloud ~ ' report' ~ ('s' if blancco_todo.not_in_cloud != 1) ~ ' not in the Blancco cloud') %}{% endif %}
|
{% if blancco_todo.not_in_cloud %}{% set _ = parts.append(blancco_todo.not_in_cloud ~ ' report' ~ ('s' if blancco_todo.not_in_cloud != 1) ~ ' not in the Blancco cloud') %}{% endif %}
|
||||||
|
{% if blancco_todo.hardware %}{% set _ = parts.append(blancco_todo.hardware ~ ' new model' ~ ('s' if blancco_todo.hardware != 1) ~ ' missing drivers/BIOS') %}{% endif %}
|
||||||
<span>{{ parts | join(' | ') }}</span>
|
<span>{{ parts | join(' | ') }}</span>
|
||||||
<a href="{{ url_for('blancco_todo') }}" class="ms-auto pxe-btn pxe-btn-secondary btn-row-action">Open to-do</a>
|
<a href="{{ url_for('blancco_todo') }}" class="ms-auto pxe-btn pxe-btn-secondary btn-row-action">Open to-do</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
81
webapp/templates/hardware.html
Normal file
81
webapp/templates/hardware.html
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Hardware Coverage - PXE Server Manager{% endblock %}
|
||||||
|
|
||||||
|
{% macro driver_badge(d) -%}
|
||||||
|
{% if d.status == 'ok' %}<span class="badge badge-success" title="{{ d.file }}">OK</span>
|
||||||
|
{% elif d.status == 'no-entry' %}<span class="badge badge-danger" title="No HardwareDriver.json entry matches this model">Missing</span>
|
||||||
|
{% elif d.status == 'zip-missing' %}<span class="badge badge-danger" title="Entry {{ d.file }} matches, but the zip is not on the server">Zip missing</span>
|
||||||
|
{% else %}<span class="text-muted">-</span>{% endif %}
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
|
{% macro bios_badge(b) -%}
|
||||||
|
{% if b.status == 'current' %}<span class="badge badge-success">{{ b.target }} (current)</span>
|
||||||
|
{% elif b.status == 'update' %}<span class="badge badge-info" title="{{ b.file }}">Flashes to {{ b.target }}</span>
|
||||||
|
{% elif b.status == 'file-missing' %}<span class="badge badge-danger" title="{{ b.file }} not in the BIOS folder">File missing</span>
|
||||||
|
{% else %}<span class="badge badge-danger">No BIOS entry</span>{% endif %}
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-header">
|
||||||
|
<h1><i class="bi bi-cpu"></i> Hardware Coverage</h1>
|
||||||
|
<div class="header-actions">
|
||||||
|
<span class="badge badge-secondary badge-lg">{{ rows | length }} model{{ 's' if rows | length != 1 }} seen</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-muted">
|
||||||
|
Every model that has booted WinPE here, as reported by the machine itself (WMI model, Dell system SKU,
|
||||||
|
BIOS version), checked against each image's driver map and the WinPE BIOS manifest. A model missing a
|
||||||
|
Win11 driver pack or a BIOS entry also shows on the Technician To-Do until it is added or dismissed.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="section-card">
|
||||||
|
{% if rows %}
|
||||||
|
<div class="table-container">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Model (WMI)</th>
|
||||||
|
<th>System SKU</th>
|
||||||
|
<th>BIOS seen</th>
|
||||||
|
{% for image, os_label in images %}<th title="{{ image }}">{{ image | replace('gea-', '') }} <span class="text-muted">({{ os_label }})</span></th>{% endfor %}
|
||||||
|
<th>BIOS update</th>
|
||||||
|
<th>Last seen</th>
|
||||||
|
<th class="text-end"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for r in rows %}
|
||||||
|
<tr title="Serials: {{ r.serials | join(', ') }}{% if r.pctypes %} | Types: {{ r.pctypes | join(', ') }}{% endif %}">
|
||||||
|
<td>
|
||||||
|
<span class="mono">{{ r.model }}</span>
|
||||||
|
{% if r.manufacturer %}<div class="small text-muted">{{ r.manufacturer }}</div>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="mono">{{ r.skus | join(', ') or '-' }}</td>
|
||||||
|
<td class="mono">{{ r.last_bios_version or '-' }}</td>
|
||||||
|
{% for image, os_label in images %}<td>{{ driver_badge(r.drivers[image]) }}</td>{% endfor %}
|
||||||
|
<td>{{ bios_badge(r.bios) }}</td>
|
||||||
|
<td class="mono text-nowrap">{{ r.last_seen | local_dt }}<div class="small text-muted">{{ r.count }} boot{{ 's' if r.count != 1 }}</div></td>
|
||||||
|
<td class="text-end text-nowrap">
|
||||||
|
{% if r.gap %}
|
||||||
|
<form method="post" action="{{ url_for('hardware_dismiss') }}" style="display:inline;">
|
||||||
|
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<input type="hidden" name="model" value="{{ r.model }}">
|
||||||
|
<input type="hidden" name="done" value="{{ '0' if r.dismissed else '1' }}">
|
||||||
|
<button type="submit" class="pxe-btn pxe-btn-ghost btn-row-action">{{ 'Reopen' if r.dismissed else 'Dismiss' }}</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center text-muted py-5">
|
||||||
|
<p class="mb-1">No machines have reported yet.</p>
|
||||||
|
<p class="small">Models appear here the next time a machine boots WinPE from this server.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -117,4 +117,57 @@
|
|||||||
<p class="text-muted mb-0">Every report with a known upload status reached the cloud.</p>
|
<p class="text-muted mb-0">Every report with a known upload status reached the cloud.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
{# 5. Hardware the server cannot image yet #}
|
||||||
|
<div class="section-card">
|
||||||
|
<div class="section-title">5. Add drivers / BIOS for new hardware</div>
|
||||||
|
{% if hardware %}
|
||||||
|
<p class="mb-2">These models booted WinPE here but the server has no Win11 driver pack or no BIOS entry for them.
|
||||||
|
Download the Dell driver pack and BIOS for the exact model below, add them, and they drop off this list.
|
||||||
|
<a href="{{ url_for('hardware_page') }}">Hardware coverage</a> shows every model.</p>
|
||||||
|
<div class="table-container">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead><tr><th>Model (WMI)</th><th>System SKU</th><th>BIOS seen</th><th>Missing</th><th>Last seen</th><th class="text-end">Actions</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for r in hardware %}
|
||||||
|
<tr title="Serials: {{ r.serials | join(', ') }}">
|
||||||
|
<td class="mono">{{ r.model }}</td>
|
||||||
|
<td class="mono">{{ r.skus | join(', ') or '-' }}</td>
|
||||||
|
<td class="mono">{{ r.last_bios_version or '-' }}</td>
|
||||||
|
<td class="small wrap">
|
||||||
|
{% if r.missing %}Driver pack: {{ r.missing | join(', ') }}{% endif %}
|
||||||
|
{% if r.bios.status in ('none', 'file-missing') %}{% if r.missing %}<br>{% endif %}BIOS: {{ 'no manifest entry' if r.bios.status == 'none' else r.bios.file ~ ' not on server' }}{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="mono text-nowrap">{{ r.last_seen | local_dt }}</td>
|
||||||
|
<td class="text-end text-nowrap">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary btn-row-action copy-btn" data-copy-text="{{ r.model }}">copy model</button>
|
||||||
|
<form method="post" action="{{ url_for('hardware_dismiss') }}" style="display:inline;">
|
||||||
|
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<input type="hidden" name="model" value="{{ r.model }}">
|
||||||
|
<input type="hidden" name="next" value="{{ url_for('blancco_todo') }}">
|
||||||
|
<button type="submit" class="pxe-btn pxe-btn-ghost btn-row-action">Dismiss</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted mb-0">Every model seen has a Win11 driver pack and a BIOS entry.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_scripts %}
|
||||||
|
<script>
|
||||||
|
document.querySelectorAll('.copy-btn').forEach(function (b) {
|
||||||
|
b.addEventListener('click', function () {
|
||||||
|
var t = b.getAttribute('data-copy-text') || '';
|
||||||
|
var done = function () { var o = b.textContent; b.textContent = 'copied'; setTimeout(function () { b.textContent = o; }, 1200); };
|
||||||
|
if (navigator.clipboard && window.isSecureContext) { navigator.clipboard.writeText(t).then(done); return; }
|
||||||
|
var ta = document.createElement('textarea'); ta.value = t; document.body.appendChild(ta); ta.select();
|
||||||
|
try { document.execCommand('copy'); done(); } finally { document.body.removeChild(ta); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user