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 shutil
import tempfile import tempfile
import time import time
from datetime import datetime from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
from flask import ( from flask import (
@@ -595,69 +595,95 @@ def clonezilla_delete(filename):
@app.route("/reports") @app.route("/reports")
def blancco_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 = [] reports = []
state = blancco_evidence.load_state() state = blancco_evidence.load_state()
tz = blancco_evidence._tz()
if os.path.isdir(config.BLANCCO_REPORTS): 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) fpath = os.path.join(config.BLANCCO_REPORTS, f)
# .part files are reports still being copied in by Blancco. # .part files are reports still being copied in by Blancco.
if not os.path.isfile(fpath) or f.startswith(".") or f.endswith(".part"): if not os.path.isfile(fpath) or f.startswith(".") or f.endswith(".part"):
continue continue
stat = os.stat(fpath) stat = os.stat(fpath)
ext = os.path.splitext(f)[1].lower() ext = os.path.splitext(f)[1].lower()
# Surface BIOS serial + system model so operators can find a row = {"filename": f, "size": stat.st_size, "type": ext.lstrip(".").upper() or "FILE",
# report for a specific bay without opening each one. Parse "serial": "", "model": "", "state": "", "cloud": "", "document_id": "", "key": "",
# cost is one XML walk per .xml report (~50KB-3MB each; "ticked": None, "drives": [], "fallback": False, "parse_error": ""}
# negligible at fleet sizes). when = None
serial = ""
model = ""
result = ""
cloud = ""
if ext == ".xml": if ext == ".xml":
try: try:
data = blancco_report.parse(fpath) data = blancco_report.parse(fpath)
sysinfo = (data.get("hardware") or {}).get("system") or {} sysinfo = (data.get("hardware") or {}).get("system") or {}
serial = sysinfo.get("serial", "") or "" if isinstance(sysinfo, list):
model = sysinfo.get("model", "") or "" 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 # Overall result per drive: Failed if any drive was not
# wiped. A failed Purge followed by a successful Clear # wiped. A failed Purge followed by a successful Clear
# fallback on the same drive is a successful wipe. # fallback on the same drive is a successful wipe.
result = blancco_report.report_result(data) drives = OrderedDict()
cloud = blancco_evidence.cloud_status( for i, er in enumerate(data.get("erasures") or []):
state, (data.get("meta") or {}).get("document_id", "")) tgt = er.get("target") or {}
except Exception: if isinstance(tgt, list):
pass tgt = tgt[0] if tgt else {}
reports.append({ d = drives.setdefault(blancco_report._drive_key(er, i), {
"filename": f, "model": tgt.get("model", ""), "serial": tgt.get("serial", ""),
"size": stat.st_size, "capacity": tgt.get("capacity", ""), "interface": tgt.get("interface_type", ""),
"modified": stat.st_mtime, "attempts": [], "ok": False})
"type": ext.lstrip(".").upper() or "FILE", ok = (er.get("state") or "").strip().lower() == "successful"
"serial": serial, d["ok"] = d["ok"] or ok
"model": model, d["attempts"].append({"ok": ok, "standard": er.get("erasure_standard_name", ""),
"state": result, "elapsed": er.get("elapsed_time", ""),
"cloud": cloud, "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( return render_template(
"reports.html", "reports.html",
reports=reports, groups=groups,
total=len(reports),
counts=counts,
this_month=datetime.now(tz).strftime("%Y-%m"),
site=config.BLANCCO_SITE, site=config.BLANCCO_SITE,
last_month=blancco_evidence.previous_month(), last_month=blancco_evidence.previous_month(),
image_types=config.IMAGE_TYPES, todo=blancco_evidence.todo_counts(),
friendly_names=config.FRIENDLY_NAMES,
) )
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") @app.route("/reports/export")
def blancco_export_reports(): def blancco_export_reports():
import csv import csv
@@ -752,12 +778,10 @@ def blancco_evidence_pack():
@app.route("/reports/servicenow") @app.route("/reports/servicenow")
def blancco_servicenow(): def blancco_servicenow():
"""What to tick on each asset's Onsite Bin Management RITM (ENT-045/159).""" """Old address of the ServiceNow tick list: it is now the first section
include_done = request.args.get("all") == "1" of the technician to-do."""
days = request.args.get("days", 30, type=int) return redirect(url_for("blancco_todo", days=request.args.get("days"), all=request.args.get("all"),
items = blancco_evidence.servicenow_items(days=max(1, min(days, 366)), include_done=include_done) _anchor="sn"))
return render_template("reports_servicenow.html", items=items,
include_done=include_done, days=days)
@app.route("/reports/servicenow/<document_id>", methods=["POST"]) @app.route("/reports/servicenow/<document_id>", methods=["POST"])
@@ -768,14 +792,48 @@ def blancco_servicenow_mark(document_id):
except ValueError: except ValueError:
abort(400) abort(400)
audit("REPORT_SERVICENOW", f"{document_id} {'done' if done else 'undone'}") 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") @app.route("/reports/todo")
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(), The ServiceNow tick list is its first section (?days=7|30|90|366, ?all=1
hardware=hardware_coverage.open_gaps(), 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) site=config.BLANCCO_SITE)
@@ -787,7 +845,8 @@ def blancco_todo_mark(kind, key):
except ValueError: except ValueError:
abort(400) abort(400)
audit("REPORT_TODO", f"{kind} {key} {'done' if done else 'undone'}") 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>") @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 # The hero summarises per drive: a drive counts as wiped if any of its
# erasures succeeded (Purge failing then Clear succeeding is a pass). # erasures succeeded (Purge failing then Clear succeeding is a pass).
outcomes = blancco_report.drive_outcomes(data) 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, 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"]) @app.route("/reports/delete/<filename>", methods=["POST"])
@@ -1703,6 +1772,10 @@ def tojson_ordered(value):
return json.dumps(value, ensure_ascii=False) 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 @app.context_processor
def inject_globals(): def inject_globals():
return { return {

View File

@@ -197,6 +197,26 @@ def build_evidence_zip(month: str, fileobj) -> int:
return len(reports) 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]: def servicenow_items(days: int = 30, include_done: bool = False) -> list[dict]:
"""Reports to reflect on the Onsite Bin Management RITM, newest first.""" """Reports to reflect on the Onsite Bin Management RITM, newest first."""
since = datetime.now(timezone.utc) - timedelta(days=days) 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): if isinstance(system, list):
system = system[0] if system else {} system = system[0] if system else {}
erasures = data.get("erasures") or [] erasures = data.get("erasures") or []
outcomes = blancco_report.drive_outcomes(data) failed, note = failure_note(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)
items.append({ items.append({
"document_id": r["document_id"], "document_id": r["document_id"],
"key": r["key"], "key": r["key"],
@@ -228,10 +240,7 @@ def servicenow_items(days: int = 30, include_done: bool = False) -> list[dict]:
"serial": system.get("serial", ""), "serial": system.get("serial", ""),
"model": system.get("model", ""), "model": system.get("model", ""),
"failed": bool(failed) or not erasures, "failed": bool(failed) or not erasures,
"note": (f"Blancco wipe failed on {drives or 'drive'}: " "note": note,
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 "",
"cloud": cloud_status(state, r["document_id"]), "cloud": cloud_status(state, r["document_id"]),
"done": done.get("ts") if done else None, "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-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 { font-weight: 600; font-size: 16px; color: var(--text); text-decoration: none; margin-right: auto; }
.img-name:hover { color: var(--accent); } .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" %} {% extends "base.html" %}
{% import '_ui.html' as ui %}
{% block title %}{{ filename }} - Blancco Report{% endblock %} {% block title %}{{ filename }} - Blancco Report{% endblock %}
{% set meta = data.meta or {} %} {% set meta = data.meta or {} %}
@@ -8,201 +9,151 @@
{% set bios = hw.bios or {} %} {% set bios = hw.bios or {} %}
{% set memsum = hw.memory 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) -%} {% macro hbytes(n) -%}
{%- set n = (n | int) -%} {%- set n = (n | int) -%}
{%- if n >= 1099511627776 -%} {%- if n >= 1000000000000 -%}
{{ "%.2f"|format(n / 1099511627776) }} TB {{ "%.2f"|format(n / 1000000000000) }} TB
{%- elif n >= 1073741824 -%} {%- elif n >= 1000000000 -%}
{{ "%.2f"|format(n / 1073741824) }} GB {{ "%.0f"|format(n / 1000000000) }} GB
{%- elif n >= 1048576 -%} {%- elif n >= 1000000 -%}
{{ "%.1f"|format(n / 1048576) }} MB {{ "%.0f"|format(n / 1000000) }} MB
{%- else -%} {%- else -%}
{{ n }} B {{ n }} B
{%- endif -%} {%- endif -%}
{%- endmacro %} {%- endmacro %}
{% block content %} {% block extra_head %}
<style>
<div class="mb-2"> .rv-hero { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(170px, 100%), 1fr)); gap: 12px; }
<a href="{{ url_for('blancco_reports') }}" class="pxe-btn-link" style="padding:0;">&larr; All reports</a> .rv-stat { background: var(--surface); border-radius: 12px; padding: 14px 16px; box-shadow: 0 1px 2px var(--shadow); }
</div> .rv-stat .k { font-size: 12px; color: var(--muted); }
.rv-stat .v { font-size: 18px; font-weight: 600; margin-top: 4px; }
<div class="page-header"> .rv-cols { display: grid; grid-template-columns: minmax(0, 1.6fr) minmax(260px, 1fr); gap: 16px; align-items: start; }
<div> @media (max-width: 1100px) { .rv-cols { grid-template-columns: 1fr; } }
<h1>Drive Erasure Certificate</h1> .rv-drive { padding: 18px 20px; display: flex; flex-direction: column; gap: 12px; }
<code class="mono" style="color:var(--text-light);">{{ filename }}</code> .rv-drive h2 { margin: 0; font-size: 16px; font-weight: 600; overflow-wrap: anywhere; }
</div> .rv-att { border-top: 1px solid var(--line); padding-top: 12px; display: flex; flex-direction: column; gap: 8px; }
<div class="header-actions"> .rv-steps { display: flex; gap: 3px; }
<a href="{{ url_for('blancco_download_report', filename=filename) }}" .rv-steps i { flex: 1; height: 8px; border-radius: 2px; background: var(--ok); }
class="pxe-btn pxe-btn-ghost"> .rv-steps i.bad { background: var(--fail); }
<i class="bi bi-download"></i> Download XML .rv-steps i.skip { background: var(--track-empty); }
</a> .kv { margin: 0; display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 6px 16px; font-size: 13px; }
</div> .kv dt { color: var(--muted); font-weight: 400; } .kv dd { margin: 0; overflow-wrap: anywhere; }
</div> .rv-side section { padding: 16px 18px; }
.rv-side h3 { margin: 0 0 10px; font-size: 14px; font-weight: 600; }
{# Hero summary: state badge + standard + drive count + erasure datetime #} .rv-table { width: 100%; border-collapse: collapse; font-size: 13px; }
{% set first = (data.erasures or [{}])[0] %} .rv-table th { text-align: left; font-weight: 400; color: var(--muted); padding: 4px 8px 4px 0; }
{% set overall_state = result or 'Unknown' %} .rv-table td { padding: 4px 8px 4px 0; border-top: 1px solid var(--line); }
{% set overall_tone = 'success' if overall_state == 'Successful' else 'danger' if result else 'secondary' %} </style>
{% set overall_stat = 'success' if overall_state == 'Successful' else 'danger' if result else '' %} {% endblock %}
{% set wiped = outcomes.values() | selectattr('ok') | list %}
{% set standards = wiped | map(attribute='standard') | reject('equalto', '') | unique | list %} {% block content %}
<div class="dashboard-grid mb-4"> {% set n_drives = outcomes | length %}
<div class="stat-card {{ overall_stat }}"> {% set n_ok = outcomes.values() | selectattr('ok') | list | length %}
<div class="label">Overall result</div> {% set standards = outcomes.values() | selectattr('ok') | map(attribute='standard') | reject('equalto', '') | unique | list %}
<div class="value" style="font-size:1.5rem;"> {% set first = (data.erasures or [{}])[0] %}
<span class="badge badge-lg bg-{{ overall_tone }}">{{ overall_state }}</span>
</div> <header class="ui-head">
</div> <div>
<div class="stat-card"> <a href="{{ url_for('blancco_reports') }}" class="eyebrow-t" style="text-decoration: none">Blancco reports{% if when %} / {{ when.strftime('%B %Y') }}{% endif %}</a>
<div class="label">Erasure standard</div> <h1 style="display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px 14px">
<div class="value" style="font-size:1.05rem;">{{ standards | join(', ') if standards else (first.erasure_standard_name or '-') }}</div> <span class="mono">{{ sysinfo.serial or filename }}</span>
</div> <span style="font-size: 18px; font-weight: 400">{{ sysinfo.model }}</span>
<div class="stat-card"> {% if result == 'Successful' %}{{ ui.chip('Successful', 'ok') }}{% elif result == 'Failed' %}{{ ui.chip('Failed', 'fail') }}{% else %}{{ ui.chip('No erasure recorded', 'off') }}{% endif %}
<div class="label">Drives erased</div> </h1>
<div class="value">{{ wiped | length }} of {{ outcomes | length }}</div> </div>
</div> <div class="actions">
<div class="stat-card"> <a href="{{ url_for('blancco_download_report', filename=filename) }}" class="ui-btn">Download XML</a>
<div class="label">Elapsed time</div> {% if note %}<button type="button" class="ui-btn pri copy-btn" data-copy-text="{{ note }}">Copy ServiceNow note</button>{% endif %}
<div class="value mono" style="font-size:1.5rem;">{{ first.elapsed_time or '-' }}</div> </div>
</div> </header>
<div class="stat-card">
<div class="label">Erased at</div> <div class="ui-stack">
<div class="value mono" style="font-size:1rem;">{{ first.timestamp or meta.date or '-' }}</div> {% if failed and not destroyed %}
</div> <div class="notice"><span aria-hidden="true" style="font-weight: 700">!</span><span>
</div> <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>
<div class="content-grid mb-3"> {% elif failed and destroyed %}
<div class="section-card"> <div class="notice info">Certificate of Destruction recorded {{ destroyed.ts | local_dt }}.</div>
<div class="section-title">Customer</div> {% endif %}
<dl class="row mb-0 small">
<dt class="col-sm-5">Business name</dt><dd class="col-sm-7">{{ company.business_name or '-' }}</dd> <div class="rv-hero">
<dt class="col-sm-5">Location</dt> <dd class="col-sm-7">{{ company.business_location or '-' }}</dd> <div class="rv-stat"><div class="k">Result</div>
<dt class="col-sm-5">License holder</dt><dd class="col-sm-7">{{ company.customer_license or '-' }}</dd> <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>
{% for k, v in company.items() %} <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>
{% if k not in ['business_name','business_location','customer_license'] %} <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>
<dt class="col-sm-5">{{ k|replace('_',' ')|title }}</dt><dd class="col-sm-7">{{ v }}</dd> <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>
{% endif %} <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>
{% endfor %} </div>
</dl>
</div> <div class="rv-cols">
<div class="section-card"> <div style="display: flex; flex-direction: column; gap: 14px">
<div class="section-title">System</div> {% set groups = {} %}
<dl class="row mb-0 small"> {% for er in data.erasures %}{% set t = er.target or {} %}{% set _ = groups.setdefault(t.serial or ('#' ~ loop.index0), []).append(er) %}{% endfor %}
<dt class="col-sm-5">Manufacturer</dt> <dd class="col-sm-7">{{ sysinfo.manufacturer or '-' }}</dd> {% for key, ers in groups.items() %}
<dt class="col-sm-5">Model</dt> <dd class="col-sm-7">{{ sysinfo.model or '-' }}</dd> {% set t = ers[0].target or {} %}
<dt class="col-sm-5">Serial</dt> <dd class="col-sm-7"><code class="mono">{{ sysinfo.serial or '-' }}</code></dd> {% set ok = ers | selectattr('state', 'equalto', 'Successful') | list | length > 0 %}
<dt class="col-sm-5">Chassis</dt> <dd class="col-sm-7">{{ sysinfo.chassis_type or '-' }}</dd> <section class="panel rv-drive" aria-labelledby="dr{{ loop.index }}">
<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> <div style="display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px; align-items: center">
<dt class="col-sm-5">BIOS vendor</dt> <dd class="col-sm-7">{{ bios.vendor or '-' }} {{ bios.version or '' }}</dd> <h2 id="dr{{ loop.index }}">{{ t.vendor or '' }} {{ t.model or 'Drive' }} <span class="mono muted" style="font-weight: 400">{{ t.serial }}</span></h2>
</dl> {{ ui.chip('Erased' if ok else 'Not erased', 'ok' if ok else 'fail') }}
</div> </div>
</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 %}
{# Per-drive cards: target details + step-by-step timeline #} {% set steps = (er.steps or {}).step %}
{% for er in data.erasures %} {% if steps is not iterable or steps is string or steps is mapping %}{% set steps = [steps] if steps else [] %}{% endif %}
{% set t = er.target or {} %} {% set good = (er.state or '') | lower == 'successful' %}
{% set state = er.state or 'Unknown' %} <div class="rv-att">
{% set tone = 'success' if state == 'Successful' else 'danger' if state else 'secondary' %} <div style="display: flex; flex-wrap: wrap; gap: 6px 12px; align-items: baseline">
{% set steps = (er.steps or {}).step %} <strong>Attempt {{ loop.index }}</strong><span>{{ er.erasure_standard_name or '-' }}</span><span class="sub mono">{{ er.elapsed_time or '' }}</span>
{% if steps is not iterable or steps is string or steps is mapping %} <span style="margin-left: auto">{{ ui.chip(er.state or 'Unknown', 'ok' if good else 'fail') }}</span>
{% set steps = [steps] if steps else [] %} </div>
{% endif %} {% if steps %}
<div class="card"> <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>
<div class="card-header"> {% endif %}
<div> {% if not good %}<div class="errbox">{{ blancco_failure_reason(er) }}</div>{% endif %}
<strong>Drive #{{ er.erasure_id or loop.index }}:</strong> <details><summary class="disc"><span class="chev" aria-hidden="true">&#9662;</span>Steps and error counts</summary>
{{ t.vendor or '' }} {{ t.model or '(unknown drive)' }} {% if steps %}
<code class="mono ms-2" style="color:var(--text-light);">{{ t.serial or '-' }}</code> <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>
</div> {% for st in steps %}<tr><td>{{ st.number or loop.index }}</td><td>{{ st.type or '-' }}</td><td class="muted">{{ st.pattern or '-' }}</td>
<span class="badge bg-{{ tone }}">{{ state }}</span> <td>{{ st.state or '-' }}</td><td class="mono">{{ st.elapsed_time or '-' }}</td><td>{{ st.errors or 0 }}</td></tr>{% endfor %}
</div> </tbody></table>
<div class="card-body"> {% endif %}
<div class="row g-3 mb-3"> <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>
<div class="col-md-3"> <dt>Sectors</dt><dd>{{ er.processed_sectors or '-' }} of {{ t.sectors or '-' }}</dd>
<div class="eyebrow">Interface</div> <dt>Started</dt><dd class="mono">{{ er.start_time or '-' }}</dd><dt>Ended</dt><dd class="mono">{{ er.end_time or '-' }}</dd></dl>
<div>{{ t.interface_type or '-' }}</div> </details>
</div> </div>
<div class="col-md-3"> {% endfor %}
<div class="eyebrow">Capacity</div> </section>
<div>{{ hbytes(t.capacity or 0) }}</div> {% else %}
</div> <div class="notice">This report has no erasure entries.</div>
<div class="col-md-2"> {% endfor %}
<div class="eyebrow">Health</div> </div>
<div>{{ t.health or '-' }}</div>
</div> <div class="panel rv-side" style="display: flex; flex-direction: column">
<div class="col-md-2"> <section><h3>System</h3><dl class="kv">
<div class="eyebrow">Errors</div> <dt>Manufacturer</dt><dd>{{ sysinfo.manufacturer or '-' }}</dd><dt>Model</dt><dd>{{ sysinfo.model or '-' }}</dd>
<div>{{ er.total_errors or 0 }}</div> <dt>Serial</dt><dd class="mono">{{ sysinfo.serial or '-' }}</dd><dt>Chassis</dt><dd>{{ sysinfo.chassis_type or '-' }}</dd>
</div> <dt>BIOS</dt><dd>{{ bios.vendor or '-' }} {{ bios.version or '' }}</dd>
<div class="col-md-2"> <dt>Boot</dt><dd>{{ sysinfo.bios_mode or '-' }}{% if sysinfo.secure_boot_state %}, Secure Boot {{ sysinfo.secure_boot_state }}{% endif %}</dd></dl></section>
<div class="eyebrow">Sectors</div> <section style="border-top: 1px solid var(--line)"><h3>Customer</h3><dl class="kv">
<div class="small">{{ er.processed_sectors or '-' }} / {{ t.sectors or '-' }}</div> <dt>Business</dt><dd>{{ company.business_name or '-' }}</dd><dt>Location</dt><dd>{{ company.business_location or '-' }}</dd>
</div> <dt>Licence</dt><dd class="mono">{{ company.customer_license or '-' }}</dd>
</div> {% 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>
{% if steps %} <dl class="kv" style="padding: 0 18px 16px">
<div class="table-container"> <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>
<table class="table mb-0"> <dt>Product</dt><dd>{{ meta.product_name }} {{ meta.product_version }}{% if meta.product_revision %} ({{ meta.product_revision }}){% endif %}</dd>
<thead> <dt>Generated</dt><dd class="mono">{{ meta.date or '-' }}</dd>
<tr> {% if data.license_consumption_ids %}<dt>Licence IDs</dt><dd class="mono">{{ data.license_consumption_ids | join(', ') }}</dd>{% endif %}
<th style="width:3rem;">#</th> <dt>File</dt><dd class="mono">{{ filename }}</dd>
<th>Step</th> <dt>Integrity</dt><dd><span class="trunc mono" style="max-width: 100%">{{ meta.integrity or '-' }}</span>
<th>Pattern</th> {% 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>
<th>State</th> </dl>
<th>Elapsed</th> </details>
<th>Errors</th> </div>
</tr> </div>
</thead> </div>
<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>
{% endblock %} {% endblock %}

View File

@@ -1,142 +1,180 @@
{% extends "base.html" %} {% 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 %} {% block content %}
<div class="page-header"> {% call ui.head('Blancco reports', 'Operate', total ~ ' report' ~ ('s' if total != 1) ~ ' on the share. Dates are the erasure date inside each report.') %}
<h1><i class="bi bi-shield-check"></i> Blancco Erasure Reports</h1> <details class="more">
<div class="header-actions"> <summary class="ui-btn">Export</summary>
<span class="badge badge-secondary badge-lg">{{ reports|length }} report{{ 's' if reports|length != 1 }}</span> <form class="menu" method="get" action="{{ url_for('blancco_export_reports') }}" style="width: min(320px, calc(100vw - 32px)); padding: 14px; gap: 10px">
</div> <label for="exportRange" class="sub">Reports from the last</label>
</div> <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>
<div class="section-card"> </select>
<div class="section-title">Export Reports</div> <div style="display: flex; gap: 8px">
<form method="get" action="{{ url_for('blancco_export_reports') }}" class="d-flex flex-wrap align-items-center gap-2"> <button type="submit" name="format" value="csv" class="ui-btn" title="One row per erased drive">Summary CSV</button>
<label for="exportRange" class="mb-0">Reports from the last</label> <button type="submit" name="format" value="zip" class="ui-btn" title="Original XML/PDF reports plus summary.csv">Reports ZIP</button>
<select id="exportRange" name="range" class="form-select form-select-sm w-auto"> </div>
<option value="1m">1 month</option> <div class="sub" style="white-space: normal">Dated by the timestamp inside each report.</div>
<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>
</form> </form>
<a href="{{ url_for('blancco_servicenow') }}" class="pxe-btn pxe-btn-secondary" title="What to tick on each Onsite Bin Management RITM"> </details>
<i class="bi bi-list-check"></i> ServiceNow tick list (ENT-045/159) <details class="more">
</a> <summary class="ui-btn">Evidence pack</summary>
</div> <form class="menu" method="get" action="{{ url_for('blancco_evidence_pack') }}" style="width: min(320px, calc(100vw - 32px)); padding: 14px; gap: 10px">
<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> <label for="evidenceMonth" class="sub">ENT-056/158 evidence pack for</label>
</div> <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"> <section class="panel ui-list" aria-labelledby="rl-h">
<div class="section-title">Drive Erasure Certificates</div> <h2 id="rl-h" class="vh">Reports by month</h2>
{% if reports %} <div style="display: flex; flex-wrap: wrap; align-items: center; gap: 10px; padding: 16px 24px">
<div class="table-container"> <div role="search" style="flex: 1 1 220px; min-width: 160px; display: flex">
<table class="data-table"> <label for="rq" class="vh">Search by system serial, model, drive serial or file name</label>
<thead> <input id="rq" type="search" class="field" style="flex: 1; min-width: 0" placeholder="System serial, model, drive serial or file">
<tr> </div>
<th>Serial</th> <div role="group" aria-label="Show only" id="rfilters" style="display: flex; gap: 6px; flex-wrap: wrap">
<th>Model</th> <button type="button" class="fbtn" data-f="" aria-pressed="true">All</button>
<th>Date</th> <button type="button" class="fbtn" data-f="failed" aria-pressed="false">Failed {{ counts.failed }}</button>
<th>Result</th> <button type="button" class="fbtn" data-f="cloud" aria-pressed="false">Not in cloud {{ counts.cloud }}</button>
<th title="Whether Blancco reported uploading this report to the Blancco cloud">Cloud</th> <button type="button" class="fbtn" data-f="tick" aria-pressed="false">To tick {{ counts.tick }}</button>
<th class="text-end">Actions</th> </div>
</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>
</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 %} {% else %}
<div class="text-center text-muted py-5"> <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>
<p class="mt-2"><i class="bi bi-inbox" style="font-size:1.75rem;"></i></p> {% endfor %}
<p class="mb-1">No erasure reports yet.</p> <div class="hidden-bar" id="rnomatch" hidden><span>No report matches.</span></div>
<p class="small">Reports will appear here after Blancco Drive Eraser completes a wipe.</p> </section>
</div>
{% endif %}
</div>
<div class="pxe-card mt-3"> <!-- Delete confirmation -->
<h3 class="pxe-card-title"><i class="bi bi-hdd-network"></i> Report Storage</h3> <div class="modal fade" id="deleteModal" tabindex="-1" aria-labelledby="deleteTitle">
<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">
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">
<form id="deleteForm" method="post"> <form id="deleteForm" method="post">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title">Confirm Delete</h5> <h3 class="modal-title" id="deleteTitle">Delete report</h3>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<p>Are you sure you want to delete <strong id="deleteFilename"></strong>?</p> <p>Delete <strong id="deleteFilename" class="mono"></strong> from the share?</p>
<p class="text-muted mb-0">Erasure reports may be needed for compliance audits. This action cannot be undone.</p> <p class="muted mb-0">Erasure reports are compliance evidence. This cannot be undone.</p>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="pxe-btn pxe-btn-ghost" data-bs-dismiss="modal">Cancel</button> <button type="button" class="ui-btn pri" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="pxe-btn pxe-btn-danger">Delete</button> <button type="submit" class="ui-btn danger">Delete</button>
</div> </div>
</form> </form>
</div> </div>
@@ -146,11 +184,38 @@
{% block extra_scripts %} {% block extra_scripts %}
<script> <script>
document.getElementById('deleteModal').addEventListener('show.bs.modal', function (event) { (function () {
var btn = event.relatedTarget; var modal = document.getElementById('deleteModal');
var filename = btn.getAttribute('data-filename'); modal.addEventListener('show.bs.modal', function (e) {
document.getElementById('deleteFilename').textContent = filename; var filename = e.relatedTarget ? e.relatedTarget.getAttribute('data-filename') : '';
document.getElementById('deleteForm').action = "{{ url_for('blancco_delete_report', filename='__F__') }}".replace('__F__', encodeURIComponent(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> </script>
{% endblock %} {% 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" %} {% 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 %} {% block content %}
<div class="page-header"> {% 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.') %}
<h1><i class="bi bi-clipboard-check"></i> Technician To-Do</h1> {{ ui.chip(total ~ ' open', 'neutral' if total else 'ok') }}
<div class="header-actions"> {% endcall %}
<span class="badge badge-{{ 'warning' if todo.total else 'success' }} badge-lg">{{ todo.total }} open</span>
</div>
</div>
<p class="text-muted"> <div class="ui-stack">
Blancco wipes, reports, evidence packs and cloud uploads are automatic. These are the <nav class="td-tabs" aria-label="To-do lists">
CMMC playbook steps that still need a technician ({{ site }}). Mark each one done here once finished. <a class="td-tab" href="#sn"><span class="n">{{ todo.servicenow }}</span><span class="l">ServiceNow ticks</span></a>
</p> <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 #} <section class="panel ui-list" aria-label="To-do lists">
<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>
{# 2. Evidence packs #} {# ---- 1. ServiceNow ---- #}
<div class="section-card"> <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 class="section-title">2. Upload the monthly evidence pack to Box (ENT-056 / ENT-158)</div> <div style="margin-left: auto; display: flex; gap: 6px; flex-wrap: wrap" role="group" aria-label="Period">
{% if todo.evidence %} {% 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 %}
<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> <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 class="table-container"> </div></div>
<table class="data-table"> {% for it in sn_items %}
<thead><tr><th>Month</th><th>File</th><th class="text-end">Actions</th></tr></thead> <div class="ui-row{{ ' done' if it.done }}">
<tbody> <details>
{% for p in todo.evidence %} <summary class="tgrid">
<tr> <span class="twhen mono">{{ it.when.strftime('%d %b') }}</span>
<td class="mono">{{ p.month }}</td> <span class="mono">{{ it.serial or '-' }}</span>
<td class="mono">{{ p.file }}</td> <span class="trunc c-model">{{ it.model or it.file }}</span>
<td class="text-end text-nowrap"> <span>{{ ui.chip('Tick: Blancco Wipe failed', 'fail') if it.failed else ui.chip('Tick: Wiped', 'ok') }}</span>
<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> <span class="chev" aria-hidden="true">&#9662;</span>
<form method="post" action="{{ url_for('blancco_todo_mark', kind='evidence_uploaded', key=p.month) }}" style="display:inline;"> </summary>
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}"> <div class="tbody">
<button type="submit" class="pxe-btn pxe-btn-success btn-row-action">Mark uploaded</button> {% if it.failed %}
</form> <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>
</td> <div class="sub">Tick "Blancco Wipe failed" and paste this note into the RITM. The drive is also under Drives to destroy.</div>
</tr> {% else %}
{% endfor %} <div>Open the RITM for <span class="mono">{{ it.serial }}</span>, tick "Wiped", then press Done.</div>
</tbody> {% endif %}
</table> <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> </div>
{% else %} {% else %}
<p class="text-muted mb-0">No pack waiting. The next one is saved on the 1st of next month.</p> <div class="empty">Nothing to tick in the last {{ days }} days.</div>
{% endif %} {% endfor %}
</div>
{# 3. Failed wipes #} {# ---- 2. Drives to destroy ---- #}
<div class="section-card"> <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>
<div class="section-title">3. Destroy drives that failed to wipe (ENT-158 / ENT-159)</div> {% for it in todo.failed %}
{% if todo.failed %} <div class="ui-row">
<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> <details>
<div class="table-container"> <summary class="tgrid">
<table class="data-table"> <span class="twhen mono">{{ it.when.strftime('%d %b') }}</span>
<thead><tr><th>Serial</th><th>Model</th><th>Wiped</th><th>Failure</th><th class="text-end">Actions</th></tr></thead> <span class="mono">{{ it.serial or '-' }}</span>
<tbody> <span class="trunc c-model">{{ it.model or it.file }}</span>
{% for it in todo.failed %} <span>{{ ui.chip('Drive to destroy', 'fail') }}</span>
<tr title="{{ it.file }}"> <span class="chev" aria-hidden="true">&#9662;</span>
<td class="mono">{{ it.serial or '-' }}</td> </summary>
<td>{{ it.model or '-' }}</td> <div class="tbody">
<td class="mono text-nowrap">{{ it.when | local_dt }}</td> <div class="errbox">{{ it.note }}</div>
<td class="small wrap">{{ it.note }}</td> <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>
<td class="text-end text-nowrap"> </div>
<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> </details>
<form method="post" action="{{ url_for('blancco_todo_mark', kind='destroyed', key=it.key) }}" style="display:inline;"> <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>
<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>
</div> </div>
{% else %} {% else %}
<p class="text-muted mb-0">No failed wipes waiting for destruction.</p> <div class="empty">No failed wipe waiting for destruction.</div>
{% endif %} {% endfor %}
</div> {% 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 #} {# ---- 3. Evidence packs ---- #}
<div class="section-card"> <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>
<div class="section-title">4. Reports that did not reach the Blancco cloud (ENT-056)</div> {% for p in todo.evidence %}
{% if todo.not_in_cloud %} <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>
<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="act"><a class="ui-btn" href="{{ url_for('blancco_evidence_file', filename=p.file) }}">Download</a>
<div class="table-container"> {{ attest('evidence_uploaded', p.month, 'Mark uploaded', 'Record that ' ~ p.file ~ ' is uploaded to Box? This is logged in the audit log.') }}</div></div>
<table class="data-table"> {% else %}
<thead><tr><th>Serial</th><th>Model</th><th>Wiped</th><th class="text-end">Actions</th></tr></thead> <div class="empty">No pack waiting. The next one is built on the 1st.</div>
<tbody> {% endfor %}
{% for it in todo.not_in_cloud %} {% for r in recent.evidence_uploaded %}
<tr title="{{ it.file }}"> <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>
<td class="mono">{{ it.serial or '-' }}</td> <div class="act">{{ undo_form('evidence_uploaded', r.key, 'evidence pack ' ~ r.key ~ ' uploaded') }}</div></div>
<td>{{ it.model or '-' }}</td> {% endfor %}
<td class="mono text-nowrap">{{ it.when | local_dt }}</td>
<td class="text-end text-nowrap"> {# ---- 4. Not in the cloud ---- #}
<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> <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>
<form method="post" action="{{ url_for('blancco_todo_mark', kind='cloud_resolved', key=it.key) }}" style="display:inline;"> {% for it in todo.not_in_cloud %}
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}"> <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>
<button type="submit" class="pxe-btn pxe-btn-success btn-row-action">Resolved</button> <div class="act"><a class="ui-btn" href="{{ url_for('blancco_download_report', filename=it.file) }}">Download XML</a>
</form> {{ 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>
</td> {% else %}
</tr> <div class="empty">Every report with a known upload status reached the cloud.</div>
{% endfor %} {% endfor %}
</tbody> {% for r in recent.cloud_resolved %}
</table> <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> </div>
{% else %} {% else %}
<p class="text-muted mb-0">Every report with a known upload status reached the cloud.</p> <div class="empty">Every model seen has a Win11 driver pack and a BIOS entry.</div>
{% endif %} {% endfor %}
</div> </section>
{# 5. Hardware the server cannot image yet #}
<div class="section-card"> <details class="panel" style="border-radius: 10px">
<div class="section-title">5. Add drivers / BIOS for new hardware</div> <summary class="disc" style="padding: 0 16px; min-height: 44px"><span class="chev" aria-hidden="true">&#9662;</span>What the attestation buttons record</summary>
{% if hardware %} <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
<p class="mb-2">These models booted WinPE here but the server has no Win11 driver pack or no BIOS entry for them. 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
Download the Dell driver pack and BIOS for the exact model below, add them, and they drop off this list. your IP and the time, and can be undone from this page for 24 hours.</p>
<a href="{{ url_for('hardware_page') }}">Hardware coverage</a> shows every model.</p> </details>
<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> </div>
{% endblock %} {% 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 %}