Webapp redesign phase 5: Blancco reports, report and to-do

Reports (/reports):
- Grouped by month of the erasure date inside each report (not the file
  date), newest first; older months fold. Each month header shows the
  evidence pack state (builds on the 1st, to upload with Download, or
  uploaded).
- One line per report: erasure date, system serial, model, drives,
  result ('Successful, fallback' when a failed attempt fell back to
  another standard), cloud and ServiceNow state. The row body lists every
  drive and erasure attempt with the failure reason.
- Search by system serial, model, drive serial or file; filters for
  failed, not in cloud and to tick. 'To tick' uses the to-do's 30-day
  window; older unticked reports read 'Not ticked'.
- Export and the evidence pack download moved into header popovers.

Report (/reports/view/<file>):
- Header with serial, model and the per-drive result; a notice names
  the drive to destroy until its COD is recorded; Copy ServiceNow note.
- One card per drive with every attempt, a step track and the failure
  reason; steps and error counts fold. System, customer and provenance
  (integrity signature folded, copyable) on the side. Sizes in decimal
  units, as on the list.
- blancco_evidence.failure_note() is shared with the ServiceNow list.

To-do (/reports/todo):
- The ServiceNow tick list is now its first section (period and show
  ticked filters); /reports/servicenow redirects there.
- Five counters; one line per item with details in the row body.
- COD recorded, Mark uploaded and Resolved confirm first (the attribute
  quoting is safe for JSON) and can be undone for 24 hours from the
  page. The header total includes hardware gaps, matching the sidebar.
This commit is contained in:
cproudlock
2026-10-14 14:00:54 -04:00
parent cc1e76f3ab
commit a6f95fcca1
7 changed files with 695 additions and 645 deletions

View File

@@ -23,7 +23,7 @@ import re
import shutil
import tempfile
import time
from datetime import datetime
from datetime import datetime, timedelta, timezone
from pathlib import Path
from flask import (
@@ -595,69 +595,95 @@ def clonezilla_delete(filename):
@app.route("/reports")
def blancco_reports():
"""Every report on the share, newest erasure first, grouped by month.
Rows are dated by the timestamp inside the report (the file date resets
when a report is copied) and carry per-drive outcomes for the row body."""
from collections import OrderedDict
reports = []
state = blancco_evidence.load_state()
tz = blancco_evidence._tz()
if os.path.isdir(config.BLANCCO_REPORTS):
for f in sorted(os.listdir(config.BLANCCO_REPORTS), reverse=True):
for f in os.listdir(config.BLANCCO_REPORTS):
fpath = os.path.join(config.BLANCCO_REPORTS, f)
# .part files are reports still being copied in by Blancco.
if not os.path.isfile(fpath) or f.startswith(".") or f.endswith(".part"):
continue
stat = os.stat(fpath)
ext = os.path.splitext(f)[1].lower()
# Surface BIOS serial + system model so operators can find a
# report for a specific bay without opening each one. Parse
# cost is one XML walk per .xml report (~50KB-3MB each;
# negligible at fleet sizes).
serial = ""
model = ""
result = ""
cloud = ""
row = {"filename": f, "size": stat.st_size, "type": ext.lstrip(".").upper() or "FILE",
"serial": "", "model": "", "state": "", "cloud": "", "document_id": "", "key": "",
"ticked": None, "drives": [], "fallback": False, "parse_error": ""}
when = None
if ext == ".xml":
try:
data = blancco_report.parse(fpath)
sysinfo = (data.get("hardware") or {}).get("system") or {}
serial = sysinfo.get("serial", "") or ""
model = sysinfo.get("model", "") or ""
if isinstance(sysinfo, list):
sysinfo = sysinfo[0] if sysinfo else {}
doc = (data.get("meta") or {}).get("document_id", "") or ""
key = blancco_evidence.report_key(doc, f)
# Overall result per drive: Failed if any drive was not
# wiped. A failed Purge followed by a successful Clear
# fallback on the same drive is a successful wipe.
result = blancco_report.report_result(data)
cloud = blancco_evidence.cloud_status(
state, (data.get("meta") or {}).get("document_id", ""))
except Exception:
pass
reports.append({
"filename": f,
"size": stat.st_size,
"modified": stat.st_mtime,
"type": ext.lstrip(".").upper() or "FILE",
"serial": serial,
"model": model,
"state": result,
"cloud": cloud,
})
drives = OrderedDict()
for i, er in enumerate(data.get("erasures") or []):
tgt = er.get("target") or {}
if isinstance(tgt, list):
tgt = tgt[0] if tgt else {}
d = drives.setdefault(blancco_report._drive_key(er, i), {
"model": tgt.get("model", ""), "serial": tgt.get("serial", ""),
"capacity": tgt.get("capacity", ""), "interface": tgt.get("interface_type", ""),
"attempts": [], "ok": False})
ok = (er.get("state") or "").strip().lower() == "successful"
d["ok"] = d["ok"] or ok
d["attempts"].append({"ok": ok, "standard": er.get("erasure_standard_name", ""),
"elapsed": er.get("elapsed_time", ""),
"why": blancco_report.failure_reason(er)})
row.update(serial=sysinfo.get("serial", "") or "", model=sysinfo.get("model", "") or "",
state=blancco_report.report_result(data), document_id=doc, key=key,
cloud=blancco_evidence.cloud_status(state, doc),
ticked=(state["servicenow"].get(key) or {}).get("ts"),
drives=list(drives.values()),
fallback=any(d["ok"] and any(not a["ok"] for a in d["attempts"]) for d in drives.values()))
when = blancco_report.report_datetime(data)
except Exception as ex:
row["parse_error"] = str(ex)[:200]
when = (when or datetime.fromtimestamp(stat.st_mtime, timezone.utc)).astimezone(tz)
row["when"] = when
reports.append(row)
reports.sort(key=lambda r: r["when"], reverse=True)
# Same window as the to-do: reports older than 30 days are no longer
# asked for in ServiceNow (they show "Not ticked", not "To tick").
cutoff = datetime.now(tz) - timedelta(days=30)
for r in reports:
r["to_tick"] = bool(r["key"] and not r["ticked"] and r["when"] >= cutoff)
months = OrderedDict()
for r in reports:
months.setdefault(r["when"].strftime("%Y-%m"), []).append(r)
evidence_dir = os.path.join(config.BLANCCO_REPORTS, "evidence")
uploaded = state.get("evidence_uploaded") or {}
groups = []
for m, rows in months.items():
name = blancco_evidence.evidence_name(m) + ".zip"
groups.append({"month": m, "label": rows[0]["when"].strftime("%B %Y"), "rows": rows,
"failed": sum(1 for r in rows if r["state"] == "Failed"),
"pack": name if os.path.isfile(os.path.join(evidence_dir, name)) else "",
"uploaded": m in uploaded})
counts = {"failed": sum(1 for r in reports if r["state"] == "Failed"),
"cloud": sum(1 for r in reports if r["cloud"] == "failed"),
"tick": sum(1 for r in reports if r["to_tick"])}
return render_template(
"reports.html",
reports=reports,
groups=groups,
total=len(reports),
counts=counts,
this_month=datetime.now(tz).strftime("%Y-%m"),
site=config.BLANCCO_SITE,
last_month=blancco_evidence.previous_month(),
image_types=config.IMAGE_TYPES,
friendly_names=config.FRIENDLY_NAMES,
todo=blancco_evidence.todo_counts(),
)
REPORT_EXPORT_RANGES = {"1m": 1, "3m": 3, "6m": 6, "12m": 12}
def _months_ago(now, months):
# Same day-of-month N months back, clamped (Mar 31 - 1m -> Feb 28/29).
import calendar
y, m = divmod(now.year * 12 + now.month - 1 - months, 12)
day = min(now.day, calendar.monthrange(y, m + 1)[1])
return now.replace(year=y, month=m + 1, day=day)
@app.route("/reports/export")
def blancco_export_reports():
import csv
@@ -752,12 +778,10 @@ def blancco_evidence_pack():
@app.route("/reports/servicenow")
def blancco_servicenow():
"""What to tick on each asset's Onsite Bin Management RITM (ENT-045/159)."""
include_done = request.args.get("all") == "1"
days = request.args.get("days", 30, type=int)
items = blancco_evidence.servicenow_items(days=max(1, min(days, 366)), include_done=include_done)
return render_template("reports_servicenow.html", items=items,
include_done=include_done, days=days)
"""Old address of the ServiceNow tick list: it is now the first section
of the technician to-do."""
return redirect(url_for("blancco_todo", days=request.args.get("days"), all=request.args.get("all"),
_anchor="sn"))
@app.route("/reports/servicenow/<document_id>", methods=["POST"])
@@ -768,14 +792,48 @@ def blancco_servicenow_mark(document_id):
except ValueError:
abort(400)
audit("REPORT_SERVICENOW", f"{document_id} {'done' if done else 'undone'}")
return redirect(url_for("blancco_servicenow", all=request.args.get("all")))
return redirect(url_for("blancco_todo", days=request.args.get("days"), all=request.args.get("all"),
_anchor="sn"))
def _recently_marked(state, year_items, hours=24):
"""Attestations marked in the last `hours`, per kind, so the to-do can
offer Undo on them: {kind: [{"key", "ts", "label"}]}."""
from datetime import timedelta as _td
cutoff = datetime.now(timezone.utc) - _td(hours=hours)
by_key = {i["key"]: i for i in year_items}
out = {}
for kind in blancco_evidence.TODO_KINDS:
rows = []
for key, rec in (state.get(kind) or {}).items():
try:
ts = datetime.fromisoformat(str((rec or {}).get("ts")))
except ValueError:
continue
if ts < cutoff:
continue
it = by_key.get(key)
label = (f"{it['serial'] or '-'} {it['model']}".strip() if it else key)
rows.append({"key": key, "ts": rec.get("ts"), "label": label})
out[kind] = sorted(rows, key=lambda r: r["ts"], reverse=True)
return out
@app.route("/reports/todo")
def blancco_todo():
"""Technician to-do: the playbook steps the server cannot do itself."""
return render_template("reports_todo.html", todo=blancco_evidence.todo(),
hardware=hardware_coverage.open_gaps(),
"""Technician to-do: the playbook steps the server cannot do itself.
The ServiceNow tick list is its first section (?days=7|30|90|366, ?all=1
also shows reports already ticked)."""
include_done = request.args.get("all") == "1"
days = max(1, min(request.args.get("days", 30, type=int), 366))
todo = blancco_evidence.todo()
hardware = hardware_coverage.open_gaps()
year = blancco_evidence.servicenow_items(days=366, include_done=True)
return render_template("reports_todo.html", todo=todo, hardware=hardware,
sn_items=blancco_evidence.servicenow_items(days=days, include_done=include_done),
days=days, include_done=include_done,
recent=_recently_marked(blancco_evidence.load_state(), year),
total=todo["total"] + len(hardware),
site=config.BLANCCO_SITE)
@@ -787,7 +845,8 @@ def blancco_todo_mark(kind, key):
except ValueError:
abort(400)
audit("REPORT_TODO", f"{kind} {key} {'done' if done else 'undone'}")
return redirect(url_for("blancco_todo"))
anchor = {"destroyed": "fail", "evidence_uploaded": "ev", "cloud_resolved": "cloud"}.get(kind)
return redirect(url_for("blancco_todo", _anchor=anchor))
@app.route("/reports/evidence/file/<filename>")
@@ -863,8 +922,18 @@ def blancco_view_report(filename):
# The hero summarises per drive: a drive counts as wiped if any of its
# erasures succeeded (Purge failing then Clear succeeding is a pass).
outcomes = blancco_report.drive_outcomes(data)
state = blancco_evidence.load_state()
doc = (data.get("meta") or {}).get("document_id", "") or ""
key = blancco_evidence.report_key(doc, filename)
failed, note = blancco_evidence.failure_note(data)
when = blancco_report.report_datetime(data)
return render_template("report_view.html", filename=filename, data=data,
result=blancco_report.report_result(data), outcomes=outcomes)
result=blancco_report.report_result(data), outcomes=outcomes,
cloud=blancco_evidence.cloud_status(state, doc), key=key,
ticked=(state["servicenow"].get(key) or {}).get("ts"),
destroyed=(state.get("destroyed") or {}).get(key),
failed=failed, note=note,
when=when.astimezone(blancco_evidence._tz()) if when else None)
@app.route("/reports/delete/<filename>", methods=["POST"])
@@ -1703,6 +1772,10 @@ def tojson_ordered(value):
return json.dumps(value, ensure_ascii=False)
# Report page: why an erasure failed, straight from the report.
app.jinja_env.globals["blancco_failure_reason"] = blancco_report.failure_reason
@app.context_processor
def inject_globals():
return {

View File

@@ -197,6 +197,26 @@ def build_evidence_zip(month: str, fileobj) -> int:
return len(reports)
def failure_note(data: dict):
"""(failed erasures, ServiceNow note) for a parsed report. A drive is
failed only if none of its erasures succeeded (a Purge failure followed
by a successful Clear fallback is a good wipe). The note is "" when every
drive was wiped, and also covers a report with no erasure at all."""
erasures = data.get("erasures") or []
outcomes = blancco_report.drive_outcomes(data)
failed = [e for i, e in enumerate(erasures)
if not outcomes[blancco_report._drive_key(e, i)]["ok"]]
if erasures and not failed:
return failed, ""
reasons = [blancco_report.failure_reason(e) for e in failed]
drives = ", ".join(
f"{(e.get('target') or {}).get('model', '')} {(e.get('target') or {}).get('serial', '')}".strip()
for e in failed)
return failed, (f"Blancco wipe failed on {drives or 'drive'}: "
f"{'; '.join(x.rstrip('. ') for x in reasons if x) or 'no erasure recorded'}. "
"Drive to be destroyed; record Certificate of Destruction.")
def servicenow_items(days: int = 30, include_done: bool = False) -> list[dict]:
"""Reports to reflect on the Onsite Bin Management RITM, newest first."""
since = datetime.now(timezone.utc) - timedelta(days=days)
@@ -211,15 +231,7 @@ def servicenow_items(days: int = 30, include_done: bool = False) -> list[dict]:
if isinstance(system, list):
system = system[0] if system else {}
erasures = data.get("erasures") or []
outcomes = blancco_report.drive_outcomes(data)
# A drive is failed only if none of its erasures succeeded (a Purge
# failure followed by a successful Clear fallback is a good wipe).
failed = [e for i, e in enumerate(erasures)
if not outcomes[blancco_report._drive_key(e, i)]["ok"]]
reasons = [blancco_report.failure_reason(e) for e in failed]
drives = ", ".join(
f"{(e.get('target') or {}).get('model', '')} {(e.get('target') or {}).get('serial', '')}".strip()
for e in failed)
failed, note = failure_note(data)
items.append({
"document_id": r["document_id"],
"key": r["key"],
@@ -228,10 +240,7 @@ def servicenow_items(days: int = 30, include_done: bool = False) -> list[dict]:
"serial": system.get("serial", ""),
"model": system.get("model", ""),
"failed": bool(failed) or not erasures,
"note": (f"Blancco wipe failed on {drives or 'drive'}: "
f"{'; '.join(x.rstrip('. ') for x in reasons if x) or 'no erasure recorded'}. "
"Drive to be destroyed; record Certificate of Destruction.")
if (failed or not erasures) else "",
"note": note,
"cloud": cloud_status(state, r["document_id"]),
"done": done.get("ts") if done else None,
})

View File

@@ -277,3 +277,6 @@ details.more .menu form { margin: 0; }
.img-top { display: flex; align-items: center; gap: 8px; }
.img-name { font-weight: 600; font-size: 16px; color: var(--text); text-decoration: none; margin-right: auto; }
.img-name:hover { color: var(--accent); }
/* The legacy .mono rule sets 13px; inside headings mono text keeps the heading size. */
h1 .mono, h2 .mono, .ui-head h1 .mono { font-size: inherit; }

View File

@@ -1,4 +1,5 @@
{% extends "base.html" %}
{% import '_ui.html' as ui %}
{% block title %}{{ filename }} - Blancco Report{% endblock %}
{% set meta = data.meta or {} %}
@@ -8,201 +9,151 @@
{% set bios = hw.bios or {} %}
{% set memsum = hw.memory or {} %}
{# Bytes to human-readable GB / TB string, no decimals at MB scale #}
{# Bytes to GB / TB in decimal units, as drive makers and the reports list use. #}
{% macro hbytes(n) -%}
{%- set n = (n | int) -%}
{%- if n >= 1099511627776 -%}
{{ "%.2f"|format(n / 1099511627776) }} TB
{%- elif n >= 1073741824 -%}
{{ "%.2f"|format(n / 1073741824) }} GB
{%- elif n >= 1048576 -%}
{{ "%.1f"|format(n / 1048576) }} MB
{%- if n >= 1000000000000 -%}
{{ "%.2f"|format(n / 1000000000000) }} TB
{%- elif n >= 1000000000 -%}
{{ "%.0f"|format(n / 1000000000) }} GB
{%- elif n >= 1000000 -%}
{{ "%.0f"|format(n / 1000000) }} MB
{%- else -%}
{{ n }} B
{%- endif -%}
{%- endmacro %}
{% block content %}
<div class="mb-2">
<a href="{{ url_for('blancco_reports') }}" class="pxe-btn-link" style="padding:0;">&larr; All reports</a>
</div>
<div class="page-header">
<div>
<h1>Drive Erasure Certificate</h1>
<code class="mono" style="color:var(--text-light);">{{ filename }}</code>
</div>
<div class="header-actions">
<a href="{{ url_for('blancco_download_report', filename=filename) }}"
class="pxe-btn pxe-btn-ghost">
<i class="bi bi-download"></i> Download XML
</a>
</div>
</div>
{# Hero summary: state badge + standard + drive count + erasure datetime #}
{% set first = (data.erasures or [{}])[0] %}
{% set overall_state = result or 'Unknown' %}
{% set overall_tone = 'success' if overall_state == 'Successful' else 'danger' if result else 'secondary' %}
{% set overall_stat = 'success' if overall_state == 'Successful' else 'danger' if result else '' %}
{% set wiped = outcomes.values() | selectattr('ok') | list %}
{% set standards = wiped | map(attribute='standard') | reject('equalto', '') | unique | list %}
<div class="dashboard-grid mb-4">
<div class="stat-card {{ overall_stat }}">
<div class="label">Overall result</div>
<div class="value" style="font-size:1.5rem;">
<span class="badge badge-lg bg-{{ overall_tone }}">{{ overall_state }}</span>
</div>
</div>
<div class="stat-card">
<div class="label">Erasure standard</div>
<div class="value" style="font-size:1.05rem;">{{ standards | join(', ') if standards else (first.erasure_standard_name or '-') }}</div>
</div>
<div class="stat-card">
<div class="label">Drives erased</div>
<div class="value">{{ wiped | length }} of {{ outcomes | length }}</div>
</div>
<div class="stat-card">
<div class="label">Elapsed time</div>
<div class="value mono" style="font-size:1.5rem;">{{ first.elapsed_time or '-' }}</div>
</div>
<div class="stat-card">
<div class="label">Erased at</div>
<div class="value mono" style="font-size:1rem;">{{ first.timestamp or meta.date or '-' }}</div>
</div>
</div>
<div class="content-grid mb-3">
<div class="section-card">
<div class="section-title">Customer</div>
<dl class="row mb-0 small">
<dt class="col-sm-5">Business name</dt><dd class="col-sm-7">{{ company.business_name or '-' }}</dd>
<dt class="col-sm-5">Location</dt> <dd class="col-sm-7">{{ company.business_location or '-' }}</dd>
<dt class="col-sm-5">License holder</dt><dd class="col-sm-7">{{ company.customer_license or '-' }}</dd>
{% for k, v in company.items() %}
{% if k not in ['business_name','business_location','customer_license'] %}
<dt class="col-sm-5">{{ k|replace('_',' ')|title }}</dt><dd class="col-sm-7">{{ v }}</dd>
{% endif %}
{% endfor %}
</dl>
</div>
<div class="section-card">
<div class="section-title">System</div>
<dl class="row mb-0 small">
<dt class="col-sm-5">Manufacturer</dt> <dd class="col-sm-7">{{ sysinfo.manufacturer or '-' }}</dd>
<dt class="col-sm-5">Model</dt> <dd class="col-sm-7">{{ sysinfo.model or '-' }}</dd>
<dt class="col-sm-5">Serial</dt> <dd class="col-sm-7"><code class="mono">{{ sysinfo.serial or '-' }}</code></dd>
<dt class="col-sm-5">Chassis</dt> <dd class="col-sm-7">{{ sysinfo.chassis_type or '-' }}</dd>
<dt class="col-sm-5">BIOS mode</dt> <dd class="col-sm-7">{{ sysinfo.bios_mode or '-' }} {% if sysinfo.secure_boot_state %}<span class="badge badge-secondary">SecureBoot {{ sysinfo.secure_boot_state }}</span>{% endif %}</dd>
<dt class="col-sm-5">BIOS vendor</dt> <dd class="col-sm-7">{{ bios.vendor or '-' }} {{ bios.version or '' }}</dd>
</dl>
</div>
</div>
{# Per-drive cards: target details + step-by-step timeline #}
{% for er in data.erasures %}
{% set t = er.target or {} %}
{% set state = er.state or 'Unknown' %}
{% set tone = 'success' if state == 'Successful' else 'danger' if state else 'secondary' %}
{% set steps = (er.steps or {}).step %}
{% if steps is not iterable or steps is string or steps is mapping %}
{% set steps = [steps] if steps else [] %}
{% endif %}
<div class="card">
<div class="card-header">
<div>
<strong>Drive #{{ er.erasure_id or loop.index }}:</strong>
{{ t.vendor or '' }} {{ t.model or '(unknown drive)' }}
<code class="mono ms-2" style="color:var(--text-light);">{{ t.serial or '-' }}</code>
</div>
<span class="badge bg-{{ tone }}">{{ state }}</span>
</div>
<div class="card-body">
<div class="row g-3 mb-3">
<div class="col-md-3">
<div class="eyebrow">Interface</div>
<div>{{ t.interface_type or '-' }}</div>
</div>
<div class="col-md-3">
<div class="eyebrow">Capacity</div>
<div>{{ hbytes(t.capacity or 0) }}</div>
</div>
<div class="col-md-2">
<div class="eyebrow">Health</div>
<div>{{ t.health or '-' }}</div>
</div>
<div class="col-md-2">
<div class="eyebrow">Errors</div>
<div>{{ er.total_errors or 0 }}</div>
</div>
<div class="col-md-2">
<div class="eyebrow">Sectors</div>
<div class="small">{{ er.processed_sectors or '-' }} / {{ t.sectors or '-' }}</div>
</div>
</div>
{% if steps %}
<div class="table-container">
<table class="table mb-0">
<thead>
<tr>
<th style="width:3rem;">#</th>
<th>Step</th>
<th>Pattern</th>
<th>State</th>
<th>Elapsed</th>
<th>Errors</th>
</tr>
</thead>
<tbody>
{% for s in steps %}
<tr>
<td>{{ s.number or loop.index }}</td>
<td>{{ s.type or '-' }}</td>
<td class="small" style="color:var(--text-light);">{{ s.pattern or '-' }}</td>
<td>
<span class="badge bg-{{ 'success' if s.state == 'completed' else 'danger' if s.state else 'secondary' }}">
{{ s.state or '-' }}
</span>
</td>
<td><code class="mono">{{ s.elapsed_time or '-' }}</code></td>
<td>{{ s.errors or 0 }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div>
</div>
{% endfor %}
<div class="card">
<div class="card-header">Document signing &amp; provenance</div>
<div class="card-body">
<dl class="row mb-0 small">
<dt class="col-sm-3">Document ID</dt>
<dd class="col-sm-9"><code class="mono text-break">{{ meta.document_id or '-' }}</code></dd>
<dt class="col-sm-3">Product</dt>
<dd class="col-sm-9">{{ meta.product_name }} {{ meta.product_version }} (rev {{ meta.product_revision }})</dd>
<dt class="col-sm-3">Generated</dt>
<dd class="col-sm-9"><code class="mono">{{ meta.date or '-' }}</code></dd>
{% if data.license_consumption_ids %}
<dt class="col-sm-3">License consumption ID{{ 's' if data.license_consumption_ids|length > 1 }}</dt>
<dd class="col-sm-9">
{% for lc in data.license_consumption_ids %}<code class="mono d-block">{{ lc }}</code>{% endfor %}
</dd>
{% endif %}
<dt class="col-sm-3">Integrity hash</dt>
<dd class="col-sm-9">
<details>
<summary style="color:var(--text-light);cursor:pointer;">Show base64 signature ({{ meta.integrity|length if meta.integrity else 0 }} chars)</summary>
<code class="mono text-break d-block mt-2">{{ meta.integrity }}</code>
</details>
</dd>
</dl>
</div>
</div>
{% block extra_head %}
<style>
.rv-hero { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(170px, 100%), 1fr)); gap: 12px; }
.rv-stat { background: var(--surface); border-radius: 12px; padding: 14px 16px; box-shadow: 0 1px 2px var(--shadow); }
.rv-stat .k { font-size: 12px; color: var(--muted); }
.rv-stat .v { font-size: 18px; font-weight: 600; margin-top: 4px; }
.rv-cols { display: grid; grid-template-columns: minmax(0, 1.6fr) minmax(260px, 1fr); gap: 16px; align-items: start; }
@media (max-width: 1100px) { .rv-cols { grid-template-columns: 1fr; } }
.rv-drive { padding: 18px 20px; display: flex; flex-direction: column; gap: 12px; }
.rv-drive h2 { margin: 0; font-size: 16px; font-weight: 600; overflow-wrap: anywhere; }
.rv-att { border-top: 1px solid var(--line); padding-top: 12px; display: flex; flex-direction: column; gap: 8px; }
.rv-steps { display: flex; gap: 3px; }
.rv-steps i { flex: 1; height: 8px; border-radius: 2px; background: var(--ok); }
.rv-steps i.bad { background: var(--fail); }
.rv-steps i.skip { background: var(--track-empty); }
.kv { margin: 0; display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 6px 16px; font-size: 13px; }
.kv dt { color: var(--muted); font-weight: 400; } .kv dd { margin: 0; overflow-wrap: anywhere; }
.rv-side section { padding: 16px 18px; }
.rv-side h3 { margin: 0 0 10px; font-size: 14px; font-weight: 600; }
.rv-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.rv-table th { text-align: left; font-weight: 400; color: var(--muted); padding: 4px 8px 4px 0; }
.rv-table td { padding: 4px 8px 4px 0; border-top: 1px solid var(--line); }
</style>
{% endblock %}
{% block content %}
{% set n_drives = outcomes | length %}
{% set n_ok = outcomes.values() | selectattr('ok') | list | length %}
{% set standards = outcomes.values() | selectattr('ok') | map(attribute='standard') | reject('equalto', '') | unique | list %}
{% set first = (data.erasures or [{}])[0] %}
<header class="ui-head">
<div>
<a href="{{ url_for('blancco_reports') }}" class="eyebrow-t" style="text-decoration: none">Blancco reports{% if when %} / {{ when.strftime('%B %Y') }}{% endif %}</a>
<h1 style="display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px 14px">
<span class="mono">{{ sysinfo.serial or filename }}</span>
<span style="font-size: 18px; font-weight: 400">{{ sysinfo.model }}</span>
{% if result == 'Successful' %}{{ ui.chip('Successful', 'ok') }}{% elif result == 'Failed' %}{{ ui.chip('Failed', 'fail') }}{% else %}{{ ui.chip('No erasure recorded', 'off') }}{% endif %}
</h1>
</div>
<div class="actions">
<a href="{{ url_for('blancco_download_report', filename=filename) }}" class="ui-btn">Download XML</a>
{% if note %}<button type="button" class="ui-btn pri copy-btn" data-copy-text="{{ note }}">Copy ServiceNow note</button>{% endif %}
</div>
</header>
<div class="ui-stack">
{% if failed and not destroyed %}
<div class="notice"><span aria-hidden="true" style="font-weight: 700">!</span><span>
<strong>{% for e in failed %}{{ (e.target or {}).model }} {{ (e.target or {}).serial }}{{ ', ' if not loop.last }}{% endfor %} {{ 'was' if failed | length == 1 else 'were' }} not erased.</strong>
Remove and destroy it, record the Certificate of Destruction on the RITM, then mark it on the <a href="{{ url_for('blancco_todo') }}#fail">to-do</a>.</span></div>
{% elif failed and destroyed %}
<div class="notice info">Certificate of Destruction recorded {{ destroyed.ts | local_dt }}.</div>
{% endif %}
<div class="rv-hero">
<div class="rv-stat"><div class="k">Result</div>
<div class="v" style="{{ 'color: var(--fail-text)' if result == 'Failed' }}">{% if n_drives %}{{ n_ok }} of {{ n_drives }} drive{{ 's' if n_drives != 1 }} wiped{% else %}No erasure{% endif %}</div></div>
<div class="rv-stat"><div class="k">Erased</div><div class="v">{{ when.strftime('%d %b %Y') if when else '-' }}</div><div class="sub">{{ when.strftime('%-I:%M %p %Z') if when else (meta.date or '') }}</div></div>
<div class="rv-stat"><div class="k">Standard</div><div class="v" style="font-size: 15px">{{ standards | join(', ') if standards else (first.erasure_standard_name or '-') }}</div></div>
<div class="rv-stat"><div class="k">Blancco cloud</div><div class="v">{{ 'Sent' if cloud == 'sent' else ('Not sent' if cloud == 'failed' else 'No status') }}</div></div>
<div class="rv-stat"><div class="k">ServiceNow</div><div class="v">{{ 'Ticked' if ticked else 'To tick' }}</div><div class="sub">{{ '"Blancco Wipe failed"' if note else '"Wiped"' }}{% if ticked %}, {{ ticked | local_dt }}{% endif %}</div></div>
</div>
<div class="rv-cols">
<div style="display: flex; flex-direction: column; gap: 14px">
{% set groups = {} %}
{% for er in data.erasures %}{% set t = er.target or {} %}{% set _ = groups.setdefault(t.serial or ('#' ~ loop.index0), []).append(er) %}{% endfor %}
{% for key, ers in groups.items() %}
{% set t = ers[0].target or {} %}
{% set ok = ers | selectattr('state', 'equalto', 'Successful') | list | length > 0 %}
<section class="panel rv-drive" aria-labelledby="dr{{ loop.index }}">
<div style="display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px; align-items: center">
<h2 id="dr{{ loop.index }}">{{ t.vendor or '' }} {{ t.model or 'Drive' }} <span class="mono muted" style="font-weight: 400">{{ t.serial }}</span></h2>
{{ ui.chip('Erased' if ok else 'Not erased', 'ok' if ok else 'fail') }}
</div>
<div class="sub">{{ hbytes(t.capacity or 0) }}{% if t.interface_type %} | {{ t.interface_type }}{% endif %}{% if t.health %} | health {{ t.health }}{% endif %}</div>
{% for er in ers %}
{% set steps = (er.steps or {}).step %}
{% if steps is not iterable or steps is string or steps is mapping %}{% set steps = [steps] if steps else [] %}{% endif %}
{% set good = (er.state or '') | lower == 'successful' %}
<div class="rv-att">
<div style="display: flex; flex-wrap: wrap; gap: 6px 12px; align-items: baseline">
<strong>Attempt {{ loop.index }}</strong><span>{{ er.erasure_standard_name or '-' }}</span><span class="sub mono">{{ er.elapsed_time or '' }}</span>
<span style="margin-left: auto">{{ ui.chip(er.state or 'Unknown', 'ok' if good else 'fail') }}</span>
</div>
{% if steps %}
<div class="rv-steps" role="img" aria-label="{{ steps | length }} steps">{% for st in steps %}<i class="{{ '' if st.state == 'completed' else ('bad' if st.state else 'skip') }}" title="{{ st.type }} {{ st.state }}"></i>{% endfor %}</div>
{% endif %}
{% if not good %}<div class="errbox">{{ blancco_failure_reason(er) }}</div>{% endif %}
<details><summary class="disc"><span class="chev" aria-hidden="true">&#9662;</span>Steps and error counts</summary>
{% if steps %}
<table class="rv-table"><thead><tr><th>#</th><th>Step</th><th>Pattern</th><th>State</th><th>Elapsed</th><th>Errors</th></tr></thead><tbody>
{% for st in steps %}<tr><td>{{ st.number or loop.index }}</td><td>{{ st.type or '-' }}</td><td class="muted">{{ st.pattern or '-' }}</td>
<td>{{ st.state or '-' }}</td><td class="mono">{{ st.elapsed_time or '-' }}</td><td>{{ st.errors or 0 }}</td></tr>{% endfor %}
</tbody></table>
{% endif %}
<dl class="kv" style="margin-top: 8px"><dt>Errors</dt><dd>total {{ er.total_errors or 0 }}, read {{ er.read_errors or 0 }}, mismatch {{ er.mismatch_errors or 0 }}</dd>
<dt>Sectors</dt><dd>{{ er.processed_sectors or '-' }} of {{ t.sectors or '-' }}</dd>
<dt>Started</dt><dd class="mono">{{ er.start_time or '-' }}</dd><dt>Ended</dt><dd class="mono">{{ er.end_time or '-' }}</dd></dl>
</details>
</div>
{% endfor %}
</section>
{% else %}
<div class="notice">This report has no erasure entries.</div>
{% endfor %}
</div>
<div class="panel rv-side" style="display: flex; flex-direction: column">
<section><h3>System</h3><dl class="kv">
<dt>Manufacturer</dt><dd>{{ sysinfo.manufacturer or '-' }}</dd><dt>Model</dt><dd>{{ sysinfo.model or '-' }}</dd>
<dt>Serial</dt><dd class="mono">{{ sysinfo.serial or '-' }}</dd><dt>Chassis</dt><dd>{{ sysinfo.chassis_type or '-' }}</dd>
<dt>BIOS</dt><dd>{{ bios.vendor or '-' }} {{ bios.version or '' }}</dd>
<dt>Boot</dt><dd>{{ sysinfo.bios_mode or '-' }}{% if sysinfo.secure_boot_state %}, Secure Boot {{ sysinfo.secure_boot_state }}{% endif %}</dd></dl></section>
<section style="border-top: 1px solid var(--line)"><h3>Customer</h3><dl class="kv">
<dt>Business</dt><dd>{{ company.business_name or '-' }}</dd><dt>Location</dt><dd>{{ company.business_location or '-' }}</dd>
<dt>Licence</dt><dd class="mono">{{ company.customer_license or '-' }}</dd>
{% for k, v in company.items() if k not in ['business_name', 'business_location', 'customer_license'] %}<dt>{{ k | replace('_', ' ') | capitalize }}</dt><dd>{{ v }}</dd>{% endfor %}</dl></section>
<details style="border-top: 1px solid var(--line)"><summary class="disc" style="padding: 0 18px; min-height: 44px"><span class="chev" aria-hidden="true">&#9662;</span>Provenance</summary>
<dl class="kv" style="padding: 0 18px 16px">
<dt>Document</dt><dd><span class="mono">{{ meta.document_id or '-' }}</span>{% if meta.document_id %} <button type="button" class="ui-btn sm ghost copy-btn" data-copy-text="{{ meta.document_id }}">Copy</button>{% endif %}</dd>
<dt>Product</dt><dd>{{ meta.product_name }} {{ meta.product_version }}{% if meta.product_revision %} ({{ meta.product_revision }}){% endif %}</dd>
<dt>Generated</dt><dd class="mono">{{ meta.date or '-' }}</dd>
{% if data.license_consumption_ids %}<dt>Licence IDs</dt><dd class="mono">{{ data.license_consumption_ids | join(', ') }}</dd>{% endif %}
<dt>File</dt><dd class="mono">{{ filename }}</dd>
<dt>Integrity</dt><dd><span class="trunc mono" style="max-width: 100%">{{ meta.integrity or '-' }}</span>
{% if meta.integrity %}<button type="button" class="ui-btn sm ghost copy-btn" data-copy-text="{{ meta.integrity }}">Copy signature ({{ meta.integrity | length }} chars)</button>{% endif %}</dd>
</dl>
</details>
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,142 +1,180 @@
{% extends "base.html" %}
{% block title %}Blancco Reports - PXE Server Manager{% endblock %}
{% import '_ui.html' as ui %}
{% block title %}Blancco reports - PXE Manager{% endblock %}
{# Blancco reports: one line per report, grouped by month (the erasure date
inside each report). Each row expands to its drives and every erasure
attempt; a failed Purge followed by a successful Clear on the same drive
counts as wiped and shows as "Successful, fallback". #}
{% block extra_head %}
<style>
.rgrid { display: grid; grid-template-columns: 116px 110px minmax(0, 1fr) 64px 160px 92px 96px 14px; align-items: center;
gap: 0 14px; padding: 11px 120px 11px 24px; font-size: 14px; min-height: 24px; }
.rhead { font-size: 12px; letter-spacing: .04em; text-transform: uppercase; color: var(--muted); padding-top: 6px; padding-bottom: 6px; }
.rwhen { font-size: 13px; color: var(--muted); white-space: nowrap; }
.month { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; padding: 14px 24px 10px; border-top: 1px solid var(--line-2); background: var(--surface-2); }
.month > summary { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; width: 100%; }
.month h3 { margin: 0; font-size: 15px; font-weight: 600; }
.month .sp { margin-left: auto; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.month .sp form { margin: 0; }
details.mgroup > summary { list-style: none; }
.rbody { padding: 2px 24px 16px 150px; font-size: 13px; display: flex; flex-direction: column; gap: 10px; }
.drive { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px 16px; align-items: start; padding: 10px 12px; border: 1px solid var(--line-2); border-radius: 8px; }
.drive > div:first-child { min-width: 0; overflow-wrap: anywhere; }
.drive .attempts { grid-column: 1 / -1; display: flex; flex-direction: column; gap: 4px; }
.att { display: flex; flex-wrap: wrap; gap: 4px 10px; align-items: baseline; }
.att .why { color: var(--fail-text); }
.rmeta { display: flex; flex-wrap: wrap; gap: 4px 18px; color: var(--muted); font-size: 12px; }
.rmeta .mono { overflow-wrap: anywhere; }
.rl-empty { padding: 28px 24px; text-align: center; color: var(--muted); border-top: 1px solid var(--line); }
@container (max-width: 1000px) {
.rgrid { grid-template-columns: 100px 104px minmax(0, 1fr) 160px 14px; }
.rgrid .c-drv, .rgrid .c-cloud, .rgrid .c-sn { display: none; }
.rbody { padding-left: 24px; }
}
@container (max-width: 620px) {
.rgrid { grid-template-columns: minmax(0, 1fr) auto 14px; padding: 11px 16px; gap: 2px 10px; }
.rgrid .rwhen { grid-column: 1 / -1; }
.rgrid .c-model { grid-column: 1 / -1; grid-row: 3; }
.rhead { display: none; }
.ui-row > .act { position: static; display: flex; justify-content: flex-end; padding: 0 16px 10px; }
.rbody { padding: 2px 16px 14px; }
.month { padding: 12px 16px 8px; }
}
</style>
{% endblock %}
{% block content %}
<div class="page-header">
<h1><i class="bi bi-shield-check"></i> Blancco Erasure Reports</h1>
<div class="header-actions">
<span class="badge badge-secondary badge-lg">{{ reports|length }} report{{ 's' if reports|length != 1 }}</span>
</div>
</div>
<div class="section-card">
<div class="section-title">Export Reports</div>
<form method="get" action="{{ url_for('blancco_export_reports') }}" class="d-flex flex-wrap align-items-center gap-2">
<label for="exportRange" class="mb-0">Reports from the last</label>
<select id="exportRange" name="range" class="form-select form-select-sm w-auto">
<option value="1m">1 month</option>
<option value="3m">3 months</option>
<option value="6m">6 months</option>
<option value="12m">12 months</option>
</select>
<button type="submit" name="format" value="csv" class="pxe-btn pxe-btn-secondary" title="One row per erased drive">
<i class="bi bi-filetype-csv"></i> Summary CSV
</button>
<button type="submit" name="format" value="zip" class="pxe-btn pxe-btn-secondary" title="Original XML/PDF reports plus summary.csv">
<i class="bi bi-file-zip"></i> Reports ZIP
</button>
</form>
<p class="small text-muted mt-2 mb-0">Dated by the timestamp inside each report, not the file date.</p>
</div>
<div class="section-card">
<div class="section-title">CMMC Evidence - {{ site }}</div>
<div class="d-flex flex-wrap align-items-center gap-3">
<form method="get" action="{{ url_for('blancco_evidence_pack') }}" class="d-flex flex-wrap align-items-center gap-2">
<label for="evidenceMonth" class="mb-0">Evidence pack (ENT-056/158) for</label>
<input id="evidenceMonth" type="month" name="month" value="{{ last_month }}" class="form-control form-control-sm w-auto" required>
<button type="submit" class="pxe-btn pxe-btn-secondary" title="summary.csv plus every report's XML and PDF for that month">
<i class="bi bi-file-zip"></i> Download
</button>
{% call ui.head('Blancco reports', 'Operate', total ~ ' report' ~ ('s' if total != 1) ~ ' on the share. Dates are the erasure date inside each report.') %}
<details class="more">
<summary class="ui-btn">Export</summary>
<form class="menu" method="get" action="{{ url_for('blancco_export_reports') }}" style="width: min(320px, calc(100vw - 32px)); padding: 14px; gap: 10px">
<label for="exportRange" class="sub">Reports from the last</label>
<select id="exportRange" name="range" class="field">
<option value="1m">1 month</option><option value="3m">3 months</option><option value="6m">6 months</option><option value="12m">12 months</option>
</select>
<div style="display: flex; gap: 8px">
<button type="submit" name="format" value="csv" class="ui-btn" title="One row per erased drive">Summary CSV</button>
<button type="submit" name="format" value="zip" class="ui-btn" title="Original XML/PDF reports plus summary.csv">Reports ZIP</button>
</div>
<div class="sub" style="white-space: normal">Dated by the timestamp inside each report.</div>
</form>
<a href="{{ url_for('blancco_servicenow') }}" class="pxe-btn pxe-btn-secondary" title="What to tick on each Onsite Bin Management RITM">
<i class="bi bi-list-check"></i> ServiceNow tick list (ENT-045/159)
</a>
</div>
<p class="small text-muted mt-2 mb-0">The evidence pack for last month is also saved automatically to <span class="mono">evidence\</span> on the reports share on the 1st. Certificates of record are in the Blancco Management Portal.</p>
</div>
</details>
<details class="more">
<summary class="ui-btn">Evidence pack</summary>
<form class="menu" method="get" action="{{ url_for('blancco_evidence_pack') }}" style="width: min(320px, calc(100vw - 32px)); padding: 14px; gap: 10px">
<label for="evidenceMonth" class="sub">ENT-056/158 evidence pack for</label>
<input id="evidenceMonth" type="month" name="month" value="{{ last_month }}" class="field" required>
<button type="submit" class="ui-btn pri">Download</button>
<div class="sub" style="white-space: normal">Built automatically into <span class="mono">evidence\</span> on the reports share on the 1st.</div>
</form>
</details>
<a class="ui-btn pri" href="{{ url_for('blancco_todo') }}#sn">ServiceNow tick list <span class="chip c-neutral" style="font-size: 11px; padding: 1px 6px">{{ todo.servicenow }}</span></a>
{% endcall %}
<div class="section-card">
<div class="section-title">Drive Erasure Certificates</div>
{% if reports %}
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>Serial</th>
<th>Model</th>
<th>Date</th>
<th>Result</th>
<th title="Whether Blancco reported uploading this report to the Blancco cloud">Cloud</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
{% for r in reports %}
<tr title="{{ r.filename }}">
<td>{% if r.serial %}<span class="mono">{{ r.serial }}</span>{% else %}<span class="text-muted">-</span>{% endif %}</td>
<td>{{ r.model or '-' }}</td>
<td>{{ r.modified | timestamp_fmt }}</td>
<td>
{% if r.state == 'Successful' %}<span class="badge badge-success">Successful</span>
{% elif r.state == 'Failed' %}<span class="badge badge-danger">Failed</span>
{% elif r.state %}<span class="badge badge-secondary">{{ r.state }}</span>
{% else %}<span class="text-muted">-</span>{% endif %}
</td>
<td>
{% if r.cloud == 'sent' %}<span class="badge badge-success">Sent</span>
{% elif r.cloud == 'failed' %}<span class="badge badge-danger" title="Resend from Blancco, or upload the XML to the Blancco portal">Not sent</span>
{% else %}<span class="text-muted" title="No upload status recorded (older report, or saved manually)">-</span>{% endif %}
</td>
<td class="actions text-end text-nowrap">
{% if r.filename.lower().endswith('.xml') %}
<a href="{{ url_for('blancco_view_report', filename=r.filename) }}"
class="pxe-btn pxe-btn-success btn-row-action" title="View formatted report">
<i class="bi bi-eye"></i> View
</a>
{% endif %}
<a href="{{ url_for('blancco_download_report', filename=r.filename) }}"
class="pxe-btn pxe-btn-secondary btn-row-action" title="Download">
<i class="bi bi-download"></i> Download
</a>
<button type="button" class="pxe-btn pxe-btn-danger btn-row-action"
data-bs-toggle="modal" data-bs-target="#deleteModal"
data-filename="{{ r.filename }}" title="Delete">
<i class="bi bi-trash"></i> Delete
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<section class="panel ui-list" aria-labelledby="rl-h">
<h2 id="rl-h" class="vh">Reports by month</h2>
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 10px; padding: 16px 24px">
<div role="search" style="flex: 1 1 220px; min-width: 160px; display: flex">
<label for="rq" class="vh">Search by system serial, model, drive serial or file name</label>
<input id="rq" type="search" class="field" style="flex: 1; min-width: 0" placeholder="System serial, model, drive serial or file">
</div>
<div role="group" aria-label="Show only" id="rfilters" style="display: flex; gap: 6px; flex-wrap: wrap">
<button type="button" class="fbtn" data-f="" aria-pressed="true">All</button>
<button type="button" class="fbtn" data-f="failed" aria-pressed="false">Failed {{ counts.failed }}</button>
<button type="button" class="fbtn" data-f="cloud" aria-pressed="false">Not in cloud {{ counts.cloud }}</button>
<button type="button" class="fbtn" data-f="tick" aria-pressed="false">To tick {{ counts.tick }}</button>
</div>
</div>
<div class="rgrid rhead" aria-hidden="true"><span>Erased</span><span>System serial</span><span>Model</span><span class="c-drv">Drives</span><span>Result</span><span class="c-cloud">Cloud</span><span class="c-sn">ServiceNow</span><span></span></div>
{% for g in groups %}
{% set open_month = loop.index <= 2 %}
<details class="mgroup" {% if open_month %}open{% endif %} data-month="{{ g.month }}">
<summary class="month">
<h3>{{ g.label }}</h3>
<span class="sub">{{ g.rows | length }} report{{ 's' if g.rows | length != 1 }}{% if g.failed %} | {{ g.failed }} failed{% endif %}</span>
<span class="sp">
{% if g.month == this_month %}<span class="sub">Evidence pack builds on the 1st</span>
{% elif g.uploaded %}{{ ui.chip('Evidence pack uploaded', 'ok') }}
{% elif g.pack %}{{ ui.chip('Evidence pack to upload', 'act') }}
<a class="ui-btn sm" href="{{ url_for('blancco_evidence_file', filename=g.pack) }}">Download pack</a>
{% endif %}
<span class="chev" aria-hidden="true">&#9662;</span>
</span>
</summary>
{% for r in g.rows %}
{% set failed = r.state == 'Failed' %}
<div class="ui-row rrow" data-f="{{ 'failed ' if failed }}{{ 'cloud ' if r.cloud == 'failed' }}{{ 'tick' if r.to_tick }}"
data-q="{{ (r.serial ~ ' ' ~ r.model ~ ' ' ~ r.filename ~ ' ' ~ (r.drives | map(attribute='serial') | join(' ')) ~ ' ' ~ (r.drives | map(attribute='model') | join(' '))) | lower }}">
<details>
<summary class="rgrid">
<span class="rwhen mono">{{ r.when.strftime('%d %b %-I:%M %p') }}</span>
<span class="mono">{{ r.serial or '-' }}</span>
<span class="tip c-model" data-tip="{{ r.model }}"><span class="trunc">{{ r.model or r.filename }}</span></span>
<span class="c-drv sub">{% if r.drives %}{{ r.drives | length }} drive{{ 's' if r.drives | length != 1 }}{% else %}{{ r.type }}{% endif %}</span>
<span>{% if r.state == 'Successful' %}{{ ui.chip('Successful, fallback' if r.fallback else 'Successful', 'ok') }}
{% elif failed %}{{ ui.chip('Failed', 'fail') }}
{% elif r.parse_error %}{{ ui.chip('Unreadable', 'err') }}
{% else %}<span class="sub">no erasure</span>{% endif %}</span>
<span class="c-cloud">{% if r.cloud == 'sent' %}{{ ui.chip('Sent', 'ok') }}{% elif r.cloud == 'failed' %}{{ ui.chip('Not sent', 'err') }}{% else %}<span class="sub">no status</span>{% endif %}</span>
<span class="c-sn">{% if r.ticked %}{{ ui.chip('Ticked', 'neutral') }}{% elif r.to_tick %}{{ ui.chip('To tick', 'act') }}{% elif r.key %}{{ ui.chip('Not ticked', 'off') }}{% endif %}</span>
<span class="chev" aria-hidden="true">&#9662;</span>
</summary>
<div class="rbody">
{% for d in r.drives %}
<div class="drive">
<div><strong>{{ d.model or 'Drive' }}</strong> <span class="mono">{{ d.serial }}</span></div>
<div class="sub">{% if d.capacity %}{{ ((d.capacity | int) / 1e9) | round(0) | int }} GB{% endif %}{% if d.interface %} | {{ d.interface }}{% endif %}</div>
<div class="attempts">
{% for a in d.attempts %}
<div class="att">{{ ui.chip('Erased' if a.ok else 'Failed', 'ok' if a.ok else 'fail') }}<span>{{ a.standard }}</span><span class="sub">{{ a.elapsed }}</span>{% if a.why %}<span class="why">{{ a.why }}</span>{% endif %}</div>
{% endfor %}
</div>
</div>
{% endfor %}
{% if r.fallback %}<p class="sub" style="margin: 0">Counts as successful: every drive has a successful erasure; a failed attempt fell back to another standard.</p>{% endif %}
{% if failed %}<div class="notice"><span aria-hidden="true" style="font-weight: 700">!</span><span>A drive must be destroyed. This report is on the <a href="{{ url_for('blancco_todo') }}">to-do</a> for a Certificate of Destruction{% if r.cloud == 'failed' %} and for the Blancco cloud{% endif %}.</span></div>{% endif %}
{% if r.parse_error %}<div class="errbox">Could not read this report: {{ r.parse_error }}</div>{% endif %}
<div class="rmeta"><span>File <span class="mono">{{ r.filename }}</span></span>{% if r.document_id %}<span>Document <span class="mono">{{ r.document_id }}</span></span>{% endif %}<span>{{ (r.size or 0) | filesizeformat }}</span></div>
</div>
</details>
<div class="act">
{% if r.filename.lower().endswith('.xml') %}<a class="ui-btn" href="{{ url_for('blancco_view_report', filename=r.filename) }}">View</a>{% endif %}
{% call ui.more(r.serial or r.filename) %}
<a href="{{ url_for('blancco_download_report', filename=r.filename) }}">Download {{ r.type }}</a>
<button type="button" class="copy-btn" data-copy-text="{{ r.filename }}">Copy file name</button>
<div class="sep"></div>
<button type="button" class="del" data-bs-toggle="modal" data-bs-target="#deleteModal" data-filename="{{ r.filename }}">Delete report...</button>
{% endcall %}
</div>
</div>
{% endfor %}
</details>
{% else %}
<div class="text-center text-muted py-5">
<p class="mt-2"><i class="bi bi-inbox" style="font-size:1.75rem;"></i></p>
<p class="mb-1">No erasure reports yet.</p>
<p class="small">Reports will appear here after Blancco Drive Eraser completes a wipe.</p>
</div>
{% endif %}
</div>
<div class="rl-empty">No erasure reports yet. They appear here after Blancco Drive Eraser completes a wipe and saves its report to <span class="mono">\\172.16.9.1\blancco-reports</span>.</div>
{% endfor %}
<div class="hidden-bar" id="rnomatch" hidden><span>No report matches.</span></div>
</section>
<div class="pxe-card mt-3">
<h3 class="pxe-card-title"><i class="bi bi-hdd-network"></i> Report Storage</h3>
<p class="mb-1">
Blancco Drive Eraser saves erasure certificates to the network share
<span class="mono">\\172.16.9.1\blancco-reports</span>.
</p>
<p class="mb-0 text-muted">
Reports are generated automatically after each drive wipe and contain proof of erasure for compliance and audit purposes.
</p>
</div>
<!-- Delete Confirmation Modal -->
<div class="modal fade" id="deleteModal" tabindex="-1">
<!-- Delete confirmation -->
<div class="modal fade" id="deleteModal" tabindex="-1" aria-labelledby="deleteTitle">
<div class="modal-dialog">
<div class="modal-content">
<form id="deleteForm" method="post">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="modal-header">
<h5 class="modal-title">Confirm Delete</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
<h3 class="modal-title" id="deleteTitle">Delete report</h3>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>Are you sure you want to delete <strong id="deleteFilename"></strong>?</p>
<p class="text-muted mb-0">Erasure reports may be needed for compliance audits. This action cannot be undone.</p>
<p>Delete <strong id="deleteFilename" class="mono"></strong> from the share?</p>
<p class="muted mb-0">Erasure reports are compliance evidence. This cannot be undone.</p>
</div>
<div class="modal-footer">
<button type="button" class="pxe-btn pxe-btn-ghost" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="pxe-btn pxe-btn-danger">Delete</button>
<button type="button" class="ui-btn pri" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="ui-btn danger">Delete</button>
</div>
</form>
</div>
@@ -146,11 +184,38 @@
{% block extra_scripts %}
<script>
document.getElementById('deleteModal').addEventListener('show.bs.modal', function (event) {
var btn = event.relatedTarget;
var filename = btn.getAttribute('data-filename');
document.getElementById('deleteFilename').textContent = filename;
document.getElementById('deleteForm').action = "{{ url_for('blancco_delete_report', filename='__F__') }}".replace('__F__', encodeURIComponent(filename));
});
(function () {
var modal = document.getElementById('deleteModal');
modal.addEventListener('show.bs.modal', function (e) {
var filename = e.relatedTarget ? e.relatedTarget.getAttribute('data-filename') : '';
document.getElementById('deleteFilename').textContent = filename;
document.getElementById('deleteForm').action = "{{ url_for('blancco_delete_report', filename='__F__') }}".replace('__F__', encodeURIComponent(filename));
});
// Search and filters: rows hide; a month with no visible row hides; a
// search or filter opens the months so matches are seen.
var q = document.getElementById('rq'), f = '';
function apply() {
var needle = q.value.trim().toLowerCase(), any = false;
document.querySelectorAll('details.mgroup').forEach(function (g) {
var shown = 0;
g.querySelectorAll('.rrow').forEach(function (r) {
var ok = (!needle || r.getAttribute('data-q').indexOf(needle) >= 0) && (!f || r.getAttribute('data-f').indexOf(f) >= 0);
r.hidden = !ok; if (ok) shown++;
});
g.hidden = shown === 0;
if ((needle || f) && shown) g.open = true;
if (shown) any = true;
});
document.getElementById('rnomatch').hidden = any;
}
q.addEventListener('input', apply);
document.querySelectorAll('#rfilters .fbtn').forEach(function (b) {
b.addEventListener('click', function () {
f = b.getAttribute('data-f') || '';
document.querySelectorAll('#rfilters .fbtn').forEach(function (x) { x.setAttribute('aria-pressed', x === b ? 'true' : 'false'); });
apply();
});
});
})();
</script>
{% endblock %}

View File

@@ -1,104 +0,0 @@
{% extends "base.html" %}
{% block title %}ServiceNow Tick List - PXE Server Manager{% endblock %}
{% block content %}
<div class="page-header">
<h1><i class="bi bi-list-check"></i> ServiceNow Tick List</h1>
<div class="header-actions">
<a href="{{ url_for('blancco_reports') }}" class="pxe-btn pxe-btn-ghost"><i class="bi bi-arrow-left"></i> Erasure reports</a>
</div>
</div>
<div class="section-card">
<div class="section-title">Onsite Bin Management RITM (ENT-045 / ENT-159)</div>
<p class="mb-2">
For each wiped asset, open its Onsite Bin Management RITM and tick
<strong>Wiped</strong>, or <strong>Blancco Wipe failed</strong> with the destruction note below.
Then click <strong>Done</strong> here so it drops off the list.
</p>
<form method="get" class="d-flex flex-wrap align-items-center gap-2 mb-0">
<label for="days" class="mb-0">Last</label>
<select id="days" name="days" class="form-select form-select-sm w-auto" onchange="this.form.submit()">
{% for d in (7, 30, 90, 366) %}<option value="{{ d }}" {% if d == days %}selected{% endif %}>{{ d }} days</option>{% endfor %}
</select>
<label class="mb-0 ms-2"><input type="checkbox" name="all" value="1" {% if include_done %}checked{% endif %} onchange="this.form.submit()"> show done</label>
</form>
</div>
<div class="section-card">
{% if items %}
<div class="table-container">
<table class="data-table">
<thead>
<tr>
<th>Serial</th>
<th>Model</th>
<th>Wiped</th>
<th>Tick in ServiceNow</th>
<th>Destruction note</th>
<th>Cloud</th>
<th class="text-end">Done</th>
</tr>
</thead>
<tbody>
{% for it in items %}
<tr title="{{ it.file }}">
<td class="text-nowrap">
<span class="mono">{{ it.serial or '-' }}</span>
{% if it.serial %}<button type="button" class="btn btn-sm btn-outline-secondary btn-row-action copy-btn" data-copy-text="{{ it.serial }}">copy</button>{% endif %}
</td>
<td>{{ it.model or '-' }}</td>
<td class="mono text-nowrap">{{ it.when | local_dt }}</td>
<td>
{% if it.failed %}<span class="badge badge-danger">Blancco Wipe failed</span>
{% else %}<span class="badge badge-success">Wiped</span>{% endif %}
</td>
<td class="wrap">
{% if it.note %}
<div class="small">{{ it.note }}</div>
<button type="button" class="btn btn-sm btn-outline-secondary btn-row-action copy-btn mt-1" data-copy-text="{{ it.note }}">copy note</button>
{% else %}<span class="text-muted">-</span>{% endif %}
</td>
<td>
{% if it.cloud == 'sent' %}<span class="badge badge-success">Sent</span>
{% elif it.cloud == 'failed' %}<span class="badge badge-danger">Not sent</span>
{% else %}<span class="text-muted">-</span>{% endif %}
</td>
<td class="text-end text-nowrap">
<form method="post" action="{{ url_for('blancco_servicenow_mark', document_id=it.key, all='1' if include_done else None) }}" style="display:inline;">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
{% if it.done %}
<input type="hidden" name="done" value="0">
<button type="submit" class="pxe-btn pxe-btn-ghost btn-row-action" title="Ticked {{ it.done }}">Undo</button>
{% else %}
<input type="hidden" name="done" value="1">
<button type="submit" class="pxe-btn pxe-btn-success btn-row-action">Done</button>
{% endif %}
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center text-muted py-5">
<p class="mb-1">Nothing left to tick for the last {{ days }} days.</p>
</div>
{% 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 %}

View File

@@ -1,173 +1,226 @@
{% extends "base.html" %}
{% block title %}Technician To-Do - PXE Server Manager{% endblock %}
{% import '_ui.html' as ui %}
{% block title %}Technician to-do - PXE Manager{% endblock %}
{# Technician to-do: the CMMC playbook steps the server cannot do itself.
Five lists; the ServiceNow tick list is the first. One failed wipe can be
in three of them (tick, destroy, cloud). COD recorded, Mark uploaded and
Resolved are compliance records: each asks to confirm, is audited, and
can be undone from the same list for 24 hours. #}
{% block extra_head %}
<style>
.td-tabs { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(150px, 100%), 1fr)); gap: 8px; }
.td-tab { display: flex; flex-direction: column; gap: 2px; padding: 12px 14px; border-radius: 10px; background: var(--surface);
border: 1px solid var(--line-2); color: var(--text); text-decoration: none; box-shadow: 0 1px 2px var(--shadow); }
.td-tab:hover { color: var(--text); border-color: var(--control); }
.td-tab .n { font-size: 22px; font-weight: 600; }
.td-tab .l { font-size: 12px; color: var(--muted); }
.sect { padding: 16px 24px 10px; display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; border-top: 1px solid var(--line-2); scroll-margin-top: 16px; }
.sect:first-child { border-top: 0; }
.sect h2 { margin: 0; font-size: 15px; font-weight: 600; }
.tgrid { display: grid; grid-template-columns: 96px 104px minmax(0, 1fr) 200px 14px; align-items: center; gap: 0 14px;
padding: 11px 210px 11px 24px; font-size: 14px; min-height: 24px; }
.tgrid.nochev { grid-template-columns: 96px 104px minmax(0, 1fr) 200px; }
.tgrid.hw { grid-template-columns: minmax(0, 1fr) minmax(0, 260px) 110px 14px; }
.twhen { font-size: 13px; color: var(--muted); white-space: nowrap; }
.ui-row.done { background: var(--surface-2); }
.ui-row.done .tgrid { color: var(--muted); }
.tbody { padding: 2px 24px 14px 134px; font-size: 13px; display: flex; flex-direction: column; gap: 8px; }
.note { display: flex; gap: 10px; align-items: flex-start; padding: 10px 12px; border-radius: 8px; background: var(--surface-2); font-size: 13px; line-height: 1.5; }
.kv { margin: 0; display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 4px 14px; }
.kv dt { color: var(--muted); font-weight: 400; } .kv dd { margin: 0; overflow-wrap: anywhere; }
.empty { padding: 4px 24px 14px; font-size: 13px; color: var(--muted); }
.ui-row > .act form { margin: 0; }
@container (max-width: 860px) {
.tgrid, .tgrid.nochev, .tgrid.hw { grid-template-columns: minmax(0, 1fr) auto 14px !important; padding: 11px 16px; gap: 2px 10px; }
.tgrid .twhen { grid-column: 1 / -1; }
.tgrid .c-model { grid-column: 1 / -1; grid-row: 3; }
.tgrid .trunc { white-space: normal; overflow-wrap: anywhere; }
.ui-row > .act { position: static; display: flex; justify-content: flex-end; padding: 0 16px 10px; }
.tbody { padding: 2px 16px 14px; }
.sect { padding: 14px 16px 8px; }
}
</style>
{% endblock %}
{% macro undo_form(kind, key, label) %}
<form method="post" action="{{ url_for('blancco_todo_mark', kind=kind, key=key) }}">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="done" value="0">
<button type="submit" class="ui-btn ghost" aria-label="Undo: {{ label }}">Undo</button>
</form>
{% endmacro %}
{% macro attest(kind, key, label, confirm_text) %}
<form method="post" action="{{ url_for('blancco_todo_mark', kind=kind, key=key) }}" onsubmit='return confirm({{ confirm_text | tojson }});'>
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="ui-btn attest">{{ label }}...</button>
</form>
{% endmacro %}
{% block content %}
<div class="page-header">
<h1><i class="bi bi-clipboard-check"></i> Technician To-Do</h1>
<div class="header-actions">
<span class="badge badge-{{ 'warning' if todo.total else 'success' }} badge-lg">{{ todo.total }} open</span>
</div>
</div>
{% call ui.head('Technician to-do', 'Operate', 'The steps the server cannot do for you (' ~ site ~ '). One failed wipe can appear in up to three lists.') %}
{{ ui.chip(total ~ ' open', 'neutral' if total else 'ok') }}
{% endcall %}
<p class="text-muted">
Blancco wipes, reports, evidence packs and cloud uploads are automatic. These are the
CMMC playbook steps that still need a technician ({{ site }}). Mark each one done here once finished.
</p>
<div class="ui-stack">
<nav class="td-tabs" aria-label="To-do lists">
<a class="td-tab" href="#sn"><span class="n">{{ todo.servicenow }}</span><span class="l">ServiceNow ticks</span></a>
<a class="td-tab" href="#fail"><span class="n" style="{{ 'color: var(--fail-text)' if todo.failed }}">{{ todo.failed | length }}</span><span class="l">Drives to destroy</span></a>
<a class="td-tab" href="#ev"><span class="n">{{ todo.evidence | length }}</span><span class="l">Evidence packs to upload</span></a>
<a class="td-tab" href="#cloud"><span class="n">{{ todo.not_in_cloud | length }}</span><span class="l">Not in Blancco cloud</span></a>
<a class="td-tab" href="#hw"><span class="n">{{ hardware | length }}</span><span class="l">Hardware gaps</span></a>
</nav>
{# 1. ServiceNow #}
<div class="section-card">
<div class="section-title">1. Tick wipes on the Onsite Bin Management RITM (ENT-045 / ENT-159)</div>
{% if todo.servicenow %}
<p class="mb-2"><strong>{{ todo.servicenow }}</strong> wipe{{ 's' if todo.servicenow != 1 }} from the last 30 days still to tick in ServiceNow.</p>
<a href="{{ url_for('blancco_servicenow') }}" class="pxe-btn pxe-btn-primary"><i class="bi bi-list-check"></i> Open the tick list</a>
{% else %}
<p class="text-muted mb-0">All caught up.</p>
{% endif %}
</div>
<section class="panel ui-list" aria-label="To-do lists">
{# 2. Evidence packs #}
<div class="section-card">
<div class="section-title">2. Upload the monthly evidence pack to Box (ENT-056 / ENT-158)</div>
{% if todo.evidence %}
<p class="mb-2">Saved automatically on the 1st. Download it, upload it to the site's ENT-056 / ENT-158 evidence folders in Box, then mark it uploaded.</p>
<div class="table-container">
<table class="data-table">
<thead><tr><th>Month</th><th>File</th><th class="text-end">Actions</th></tr></thead>
<tbody>
{% for p in todo.evidence %}
<tr>
<td class="mono">{{ p.month }}</td>
<td class="mono">{{ p.file }}</td>
<td class="text-end text-nowrap">
<a href="{{ url_for('blancco_evidence_file', filename=p.file) }}" class="pxe-btn pxe-btn-secondary btn-row-action"><i class="bi bi-download"></i> Download</a>
<form method="post" action="{{ url_for('blancco_todo_mark', kind='evidence_uploaded', key=p.month) }}" style="display:inline;">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="pxe-btn pxe-btn-success btn-row-action">Mark uploaded</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{# ---- 1. ServiceNow ---- #}
<div class="sect" id="sn"><h2>ServiceNow ticks</h2><span class="sub">Tick each asset's Onsite Bin Management RITM (ENT-045 / ENT-159).</span>
<div style="margin-left: auto; display: flex; gap: 6px; flex-wrap: wrap" role="group" aria-label="Period">
{% for d in (7, 30, 90) %}<a class="fbtn" href="{{ url_for('blancco_todo', days=d, all='1' if include_done else None, _anchor='sn') }}"{% if days == d %} aria-current="true"{% endif %}>{{ d }} days</a>{% endfor %}
<a class="fbtn" href="{{ url_for('blancco_todo', days=days, all=None if include_done else '1', _anchor='sn') }}"{% if include_done %} aria-current="true"{% endif %}>Show ticked</a>
</div></div>
{% for it in sn_items %}
<div class="ui-row{{ ' done' if it.done }}">
<details>
<summary class="tgrid">
<span class="twhen mono">{{ it.when.strftime('%d %b') }}</span>
<span class="mono">{{ it.serial or '-' }}</span>
<span class="trunc c-model">{{ it.model or it.file }}</span>
<span>{{ ui.chip('Tick: Blancco Wipe failed', 'fail') if it.failed else ui.chip('Tick: Wiped', 'ok') }}</span>
<span class="chev" aria-hidden="true">&#9662;</span>
</summary>
<div class="tbody">
{% if it.failed %}
<div class="note"><span style="flex: 1">{{ it.note }}</span><button type="button" class="ui-btn sm ghost copy-btn" data-copy-text="{{ it.note }}">Copy note</button></div>
<div class="sub">Tick "Blancco Wipe failed" and paste this note into the RITM. The drive is also under Drives to destroy.</div>
{% else %}
<div>Open the RITM for <span class="mono">{{ it.serial }}</span>, tick "Wiped", then press Done.</div>
{% endif %}
<div class="sub" style="display: flex; flex-wrap: wrap; gap: 8px; align-items: center">
{% if it.serial %}<button type="button" class="ui-btn sm ghost copy-btn" data-copy-text="{{ it.serial }}">Copy serial</button>{% endif %}
<a href="{{ url_for('blancco_view_report', filename=it.file) }}">Open report</a>
{% if it.cloud == 'failed' %}{{ ui.chip('Not in Blancco cloud', 'err') }}{% endif %}
</div>
</div>
</details>
<div class="act">
{% if it.done %}
<span class="sub">Ticked {{ it.done | local_dt }}</span>
<form method="post" action="{{ url_for('blancco_servicenow_mark', document_id=it.key, days=days, all='1' if include_done else None) }}">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}"><input type="hidden" name="done" value="0">
<button type="submit" class="ui-btn ghost" aria-label="Undo tick for {{ it.serial }}">Undo</button></form>
{% else %}
<form method="post" action="{{ url_for('blancco_servicenow_mark', document_id=it.key, days=days, all='1' if include_done else None) }}">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}"><input type="hidden" name="done" value="1">
<button type="submit" class="ui-btn pri" aria-label="Mark {{ it.serial }} ticked in ServiceNow">Done</button></form>
{% endif %}
</div>
</div>
{% else %}
<p class="text-muted mb-0">No pack waiting. The next one is saved on the 1st of next month.</p>
{% endif %}
</div>
<div class="empty">Nothing to tick in the last {{ days }} days.</div>
{% endfor %}
{# 3. Failed wipes #}
<div class="section-card">
<div class="section-title">3. Destroy drives that failed to wipe (ENT-158 / ENT-159)</div>
{% if todo.failed %}
<p class="mb-2">Remove the drive, destroy it per site procedure, record the Certificate of Destruction and the destruction note on the RITM, then mark it here.</p>
<div class="table-container">
<table class="data-table">
<thead><tr><th>Serial</th><th>Model</th><th>Wiped</th><th>Failure</th><th class="text-end">Actions</th></tr></thead>
<tbody>
{% for it in todo.failed %}
<tr title="{{ it.file }}">
<td class="mono">{{ it.serial or '-' }}</td>
<td>{{ it.model or '-' }}</td>
<td class="mono text-nowrap">{{ it.when | local_dt }}</td>
<td class="small wrap">{{ it.note }}</td>
<td class="text-end text-nowrap">
<a href="{{ url_for('blancco_view_report', filename=it.file) }}" class="pxe-btn pxe-btn-secondary btn-row-action"><i class="bi bi-eye"></i> Report</a>
<form method="post" action="{{ url_for('blancco_todo_mark', kind='destroyed', key=it.key) }}" style="display:inline;">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="pxe-btn pxe-btn-success btn-row-action">COD recorded</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{# ---- 2. Drives to destroy ---- #}
<div class="sect" id="fail"><h2>Drives to destroy</h2><span class="sub">Failed wipes in the last 12 months without a recorded Certificate of Destruction (ENT-158 / ENT-159).</span></div>
{% for it in todo.failed %}
<div class="ui-row">
<details>
<summary class="tgrid">
<span class="twhen mono">{{ it.when.strftime('%d %b') }}</span>
<span class="mono">{{ it.serial or '-' }}</span>
<span class="trunc c-model">{{ it.model or it.file }}</span>
<span>{{ ui.chip('Drive to destroy', 'fail') }}</span>
<span class="chev" aria-hidden="true">&#9662;</span>
</summary>
<div class="tbody">
<div class="errbox">{{ it.note }}</div>
<div class="sub">Remove the drive, destroy it per site procedure, record the Certificate of Destruction on the RITM, then mark it. <a href="{{ url_for('blancco_view_report', filename=it.file) }}">Open report</a></div>
</div>
</details>
<div class="act">{{ attest('destroyed', it.key, 'COD recorded', 'Record that the drive in ' ~ (it.serial or it.file) ~ ' was destroyed and its Certificate of Destruction is on the RITM? This is logged in the audit log.') }}</div>
</div>
{% else %}
<p class="text-muted mb-0">No failed wipes waiting for destruction.</p>
{% endif %}
</div>
<div class="empty">No failed wipe waiting for destruction.</div>
{% endfor %}
{% for r in recent.destroyed %}
<div class="ui-row done"><div class="tgrid nochev"><span class="twhen">Marked</span><span class="mono" style="grid-column: span 2">{{ r.label }}</span><span class="sub">COD recorded {{ r.ts | local_dt }}</span></div>
<div class="act">{{ undo_form('destroyed', r.key, 'COD recorded for ' ~ r.label) }}</div></div>
{% endfor %}
{# 4. Not in the cloud #}
<div class="section-card">
<div class="section-title">4. Reports that did not reach the Blancco cloud (ENT-056)</div>
{% if todo.not_in_cloud %}
<p class="mb-2">Auditors pull certificates from the Blancco Management Portal. Upload the report's XML there by hand, then mark it resolved.</p>
<div class="table-container">
<table class="data-table">
<thead><tr><th>Serial</th><th>Model</th><th>Wiped</th><th class="text-end">Actions</th></tr></thead>
<tbody>
{% for it in todo.not_in_cloud %}
<tr title="{{ it.file }}">
<td class="mono">{{ it.serial or '-' }}</td>
<td>{{ it.model or '-' }}</td>
<td class="mono text-nowrap">{{ it.when | local_dt }}</td>
<td class="text-end text-nowrap">
<a href="{{ url_for('blancco_download_report', filename=it.file) }}" class="pxe-btn pxe-btn-secondary btn-row-action"><i class="bi bi-download"></i> XML</a>
<form method="post" action="{{ url_for('blancco_todo_mark', kind='cloud_resolved', key=it.key) }}" style="display:inline;">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="pxe-btn pxe-btn-success btn-row-action">Resolved</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{# ---- 3. Evidence packs ---- #}
<div class="sect" id="ev"><h2>Evidence packs to upload</h2><span class="sub">Built on the 1st of each month. Upload to the site's ENT-056 / ENT-158 folders in Box, then mark.</span></div>
{% for p in todo.evidence %}
<div class="ui-row"><div class="tgrid nochev"><span class="twhen mono">{{ p.month }}</span><span class="mono trunc" style="grid-column: span 2">{{ p.file }}</span><span></span></div>
<div class="act"><a class="ui-btn" href="{{ url_for('blancco_evidence_file', filename=p.file) }}">Download</a>
{{ attest('evidence_uploaded', p.month, 'Mark uploaded', 'Record that ' ~ p.file ~ ' is uploaded to Box? This is logged in the audit log.') }}</div></div>
{% else %}
<div class="empty">No pack waiting. The next one is built on the 1st.</div>
{% endfor %}
{% for r in recent.evidence_uploaded %}
<div class="ui-row done"><div class="tgrid nochev"><span class="twhen mono">{{ r.key }}</span><span style="grid-column: span 2">Evidence pack</span><span class="sub">Uploaded {{ r.ts | local_dt }}</span></div>
<div class="act">{{ undo_form('evidence_uploaded', r.key, 'evidence pack ' ~ r.key ~ ' uploaded') }}</div></div>
{% endfor %}
{# ---- 4. Not in the cloud ---- #}
<div class="sect" id="cloud"><h2>Not in Blancco cloud</h2><span class="sub">Blancco reported the upload failed. Upload the XML in the Blancco Management Portal, then mark (ENT-056).</span></div>
{% for it in todo.not_in_cloud %}
<div class="ui-row"><div class="tgrid nochev"><span class="twhen mono">{{ it.when.strftime('%d %b') }}</span><span class="mono">{{ it.serial or '-' }}</span><span class="trunc c-model">{{ it.model or it.file }}</span><span>{{ ui.chip('Upload failed', 'err') }}</span></div>
<div class="act"><a class="ui-btn" href="{{ url_for('blancco_download_report', filename=it.file) }}">Download XML</a>
{{ attest('cloud_resolved', it.key, 'Resolved', 'Record that the report for ' ~ (it.serial or it.file) ~ ' is uploaded to the Blancco Management Portal? This is logged in the audit log.') }}</div></div>
{% else %}
<div class="empty">Every report with a known upload status reached the cloud.</div>
{% endfor %}
{% for r in recent.cloud_resolved %}
<div class="ui-row done"><div class="tgrid nochev"><span class="twhen">Marked</span><span class="mono" style="grid-column: span 2">{{ r.label }}</span><span class="sub">Resolved {{ r.ts | local_dt }}</span></div>
<div class="act">{{ undo_form('cloud_resolved', r.key, 'cloud upload for ' ~ r.label) }}</div></div>
{% endfor %}
{# ---- 5. Hardware gaps ---- #}
<div class="sect" id="hw"><h2>Hardware gaps</h2><span class="sub">Models WinPE has seen here without a Win11 driver pack or a BIOS entry. <a href="{{ url_for('hardware_page') }}">Hardware coverage</a> shows every model.</span></div>
{% for r in hardware %}
{% set what = [] %}
{% if r.missing %}{% set _ = what.append('Driver pack: ' ~ (r.missing | join(', '))) %}{% endif %}
{% if r.bios.status in ('none', 'file-missing') %}{% set _ = what.append('BIOS: ' ~ ('no manifest entry' if r.bios.status == 'none' else r.bios.file ~ ' not on server')) %}{% endif %}
<div class="ui-row">
<details>
<summary class="tgrid hw">
<span class="trunc mono c-model">{{ r.model }}</span>
<span class="sub trunc">{{ what | join('; ') }}</span>
<span class="twhen">seen {{ r.last_seen | local_dt }}</span>
<span class="chev" aria-hidden="true">&#9662;</span>
</summary>
<div class="tbody"><dl class="kv">
<dt>Model (WMI)</dt><dd class="mono">{{ r.model }} <button type="button" class="ui-btn sm ghost copy-btn" data-copy-text="{{ r.model }}">Copy</button></dd>
<dt>System SKU</dt><dd class="mono">{{ r.skus | join(', ') or '-' }}</dd>
<dt>BIOS seen</dt><dd class="mono">{{ r.last_bios_version or '-' }}</dd>
<dt>Missing</dt><dd>{{ what | join('; ') }}</dd>
<dt>Serials</dt><dd class="mono">{{ r.serials | join(', ') or '-' }}</dd>
</dl></div>
</details>
<div class="act">
<a class="ui-btn" href="{{ url_for('dell_updates.page') }}">Find Dell pack</a>
{% call ui.more(r.model) %}
<form method="post" action="{{ url_for('hardware_dismiss') }}">
<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">Dismiss</button>
</form>
{% endcall %}
</div>
</div>
{% else %}
<p class="text-muted mb-0">Every report with a known upload status reached the cloud.</p>
{% endif %}
</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 class="empty">Every model seen has a Win11 driver pack and a BIOS entry.</div>
{% endfor %}
</section>
<details class="panel" style="border-radius: 10px">
<summary class="disc" style="padding: 0 16px; min-height: 44px"><span class="chev" aria-hidden="true">&#9662;</span>What the attestation buttons record</summary>
<p style="margin: 0; padding: 0 16px 14px 42px; font-size: 13px; line-height: 1.55; color: var(--text-2)">COD recorded, Mark uploaded and
Resolved are compliance records (CMMC ENT-045, ENT-056, ENT-158, ENT-159). Each asks you to confirm, is written to the audit log with
your IP and the time, and can be undone from this page for 24 hours.</p>
</details>
</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 %}