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:
cproudlock
2026-10-09 09:23:52 -04:00
parent e43ab2a652
commit b691a82530
8 changed files with 485 additions and 1 deletions

View File

@@ -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

View File

@@ -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()),
} }

View File

@@ -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

View File

@@ -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",
} }

View 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

View File

@@ -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>

View 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 %}

View File

@@ -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 %}