From a6f95fcca117ab6a60f3b8b5cd9715cc1be890aa Mon Sep 17 00:00:00 2001 From: cproudlock Date: Wed, 14 Oct 2026 14:00:54 -0400 Subject: [PATCH] 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/): - 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. --- webapp/app.py | 179 +++++++---- webapp/services/blancco_evidence.py | 35 ++- webapp/static/pxe-ui.css | 3 + webapp/templates/report_view.html | 331 +++++++++----------- webapp/templates/reports.html | 321 ++++++++++++-------- webapp/templates/reports_servicenow.html | 104 ------- webapp/templates/reports_todo.html | 367 +++++++++++++---------- 7 files changed, 695 insertions(+), 645 deletions(-) delete mode 100644 webapp/templates/reports_servicenow.html diff --git a/webapp/app.py b/webapp/app.py index 2d3ed1c..dc0318f 100644 --- a/webapp/app.py +++ b/webapp/app.py @@ -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/", 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/") @@ -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/", 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 { diff --git a/webapp/services/blancco_evidence.py b/webapp/services/blancco_evidence.py index 35a0b6b..696ad07 100644 --- a/webapp/services/blancco_evidence.py +++ b/webapp/services/blancco_evidence.py @@ -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, }) diff --git a/webapp/static/pxe-ui.css b/webapp/static/pxe-ui.css index 8ec9679..58dcc9b 100644 --- a/webapp/static/pxe-ui.css +++ b/webapp/static/pxe-ui.css @@ -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; } diff --git a/webapp/templates/report_view.html b/webapp/templates/report_view.html index 4450047..ea46516 100644 --- a/webapp/templates/report_view.html +++ b/webapp/templates/report_view.html @@ -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 %} - - - - - -{# 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 %} -
-
-
Overall result
-
- {{ overall_state }} -
-
-
-
Erasure standard
-
{{ standards | join(', ') if standards else (first.erasure_standard_name or '-') }}
-
-
-
Drives erased
-
{{ wiped | length }} of {{ outcomes | length }}
-
-
-
Elapsed time
-
{{ first.elapsed_time or '-' }}
-
-
-
Erased at
-
{{ first.timestamp or meta.date or '-' }}
-
-
- -
-
-
Customer
-
-
Business name
{{ company.business_name or '-' }}
-
Location
{{ company.business_location or '-' }}
-
License holder
{{ company.customer_license or '-' }}
- {% for k, v in company.items() %} - {% if k not in ['business_name','business_location','customer_license'] %} -
{{ k|replace('_',' ')|title }}
{{ v }}
- {% endif %} - {% endfor %} -
-
-
-
System
-
-
Manufacturer
{{ sysinfo.manufacturer or '-' }}
-
Model
{{ sysinfo.model or '-' }}
-
Serial
{{ sysinfo.serial or '-' }}
-
Chassis
{{ sysinfo.chassis_type or '-' }}
-
BIOS mode
{{ sysinfo.bios_mode or '-' }} {% if sysinfo.secure_boot_state %}SecureBoot {{ sysinfo.secure_boot_state }}{% endif %}
-
BIOS vendor
{{ bios.vendor or '-' }} {{ bios.version or '' }}
-
-
-
- -{# 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 %} -
-
-
- Drive #{{ er.erasure_id or loop.index }}: - {{ t.vendor or '' }} {{ t.model or '(unknown drive)' }} - {{ t.serial or '-' }} -
- {{ state }} -
-
-
-
-
Interface
-
{{ t.interface_type or '-' }}
-
-
-
Capacity
-
{{ hbytes(t.capacity or 0) }}
-
-
-
Health
-
{{ t.health or '-' }}
-
-
-
Errors
-
{{ er.total_errors or 0 }}
-
-
-
Sectors
-
{{ er.processed_sectors or '-' }} / {{ t.sectors or '-' }}
-
-
- - {% if steps %} -
- - - - - - - - - - - - - {% for s in steps %} - - - - - - - - - {% endfor %} - -
#StepPatternStateElapsedErrors
{{ s.number or loop.index }}{{ s.type or '-' }}{{ s.pattern or '-' }} - - {{ s.state or '-' }} - - {{ s.elapsed_time or '-' }}{{ s.errors or 0 }}
-
- {% endif %} -
-
-{% endfor %} - -
-
Document signing & provenance
-
-
-
Document ID
-
{{ meta.document_id or '-' }}
-
Product
-
{{ meta.product_name }} {{ meta.product_version }} (rev {{ meta.product_revision }})
-
Generated
-
{{ meta.date or '-' }}
- {% if data.license_consumption_ids %} -
License consumption ID{{ 's' if data.license_consumption_ids|length > 1 }}
-
- {% for lc in data.license_consumption_ids %}{{ lc }}{% endfor %} -
- {% endif %} -
Integrity hash
-
-
- Show base64 signature ({{ meta.integrity|length if meta.integrity else 0 }} chars) - {{ meta.integrity }} -
-
-
-
-
- +{% block extra_head %} + +{% 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] %} + +
+
+ Blancco reports{% if when %} / {{ when.strftime('%B %Y') }}{% endif %} +

+ {{ sysinfo.serial or filename }} + {{ sysinfo.model }} + {% if result == 'Successful' %}{{ ui.chip('Successful', 'ok') }}{% elif result == 'Failed' %}{{ ui.chip('Failed', 'fail') }}{% else %}{{ ui.chip('No erasure recorded', 'off') }}{% endif %} +

+
+
+ Download XML + {% if note %}{% endif %} +
+
+ +
+{% if failed and not destroyed %} +
+ {% 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. + Remove and destroy it, record the Certificate of Destruction on the RITM, then mark it on the to-do.
+{% elif failed and destroyed %} +
Certificate of Destruction recorded {{ destroyed.ts | local_dt }}.
+{% endif %} + +
+
Result
+
{% if n_drives %}{{ n_ok }} of {{ n_drives }} drive{{ 's' if n_drives != 1 }} wiped{% else %}No erasure{% endif %}
+
Erased
{{ when.strftime('%d %b %Y') if when else '-' }}
{{ when.strftime('%-I:%M %p %Z') if when else (meta.date or '') }}
+
Standard
{{ standards | join(', ') if standards else (first.erasure_standard_name or '-') }}
+
Blancco cloud
{{ 'Sent' if cloud == 'sent' else ('Not sent' if cloud == 'failed' else 'No status') }}
+
ServiceNow
{{ 'Ticked' if ticked else 'To tick' }}
{{ '"Blancco Wipe failed"' if note else '"Wiped"' }}{% if ticked %}, {{ ticked | local_dt }}{% endif %}
+
+ +
+
+ {% 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 %} +
+
+

{{ t.vendor or '' }} {{ t.model or 'Drive' }} {{ t.serial }}

+ {{ ui.chip('Erased' if ok else 'Not erased', 'ok' if ok else 'fail') }} +
+
{{ hbytes(t.capacity or 0) }}{% if t.interface_type %} | {{ t.interface_type }}{% endif %}{% if t.health %} | health {{ t.health }}{% endif %}
+ {% 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' %} +
+
+ Attempt {{ loop.index }}{{ er.erasure_standard_name or '-' }}{{ er.elapsed_time or '' }} + {{ ui.chip(er.state or 'Unknown', 'ok' if good else 'fail') }} +
+ {% if steps %} + + {% endif %} + {% if not good %}
{{ blancco_failure_reason(er) }}
{% endif %} +
Steps and error counts + {% if steps %} + + {% for st in steps %} + {% endfor %} +
#StepPatternStateElapsedErrors
{{ st.number or loop.index }}{{ st.type or '-' }}{{ st.pattern or '-' }}{{ st.state or '-' }}{{ st.elapsed_time or '-' }}{{ st.errors or 0 }}
+ {% endif %} +
Errors
total {{ er.total_errors or 0 }}, read {{ er.read_errors or 0 }}, mismatch {{ er.mismatch_errors or 0 }}
+
Sectors
{{ er.processed_sectors or '-' }} of {{ t.sectors or '-' }}
+
Started
{{ er.start_time or '-' }}
Ended
{{ er.end_time or '-' }}
+
+
+ {% endfor %} +
+ {% else %} +
This report has no erasure entries.
+ {% endfor %} +
+ +
+

System

+
Manufacturer
{{ sysinfo.manufacturer or '-' }}
Model
{{ sysinfo.model or '-' }}
+
Serial
{{ sysinfo.serial or '-' }}
Chassis
{{ sysinfo.chassis_type or '-' }}
+
BIOS
{{ bios.vendor or '-' }} {{ bios.version or '' }}
+
Boot
{{ sysinfo.bios_mode or '-' }}{% if sysinfo.secure_boot_state %}, Secure Boot {{ sysinfo.secure_boot_state }}{% endif %}
+

Customer

+
Business
{{ company.business_name or '-' }}
Location
{{ company.business_location or '-' }}
+
Licence
{{ company.customer_license or '-' }}
+ {% for k, v in company.items() if k not in ['business_name', 'business_location', 'customer_license'] %}
{{ k | replace('_', ' ') | capitalize }}
{{ v }}
{% endfor %}
+
Provenance +
+
Document
{{ meta.document_id or '-' }}{% if meta.document_id %} {% endif %}
+
Product
{{ meta.product_name }} {{ meta.product_version }}{% if meta.product_revision %} ({{ meta.product_revision }}){% endif %}
+
Generated
{{ meta.date or '-' }}
+ {% if data.license_consumption_ids %}
Licence IDs
{{ data.license_consumption_ids | join(', ') }}
{% endif %} +
File
{{ filename }}
+
Integrity
{{ meta.integrity or '-' }} + {% if meta.integrity %}{% endif %}
+
+
+
+
+
{% endblock %} diff --git a/webapp/templates/reports.html b/webapp/templates/reports.html index 5e92f8d..c17b11d 100644 --- a/webapp/templates/reports.html +++ b/webapp/templates/reports.html @@ -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 %} + +{% endblock %} {% block content %} - - -
-
Export Reports
-
- - - - -
-

Dated by the timestamp inside each report, not the file date.

-
- -
-
CMMC Evidence - {{ site }}
-
-
- - - +{% call ui.head('Blancco reports', 'Operate', total ~ ' report' ~ ('s' if total != 1) ~ ' on the share. Dates are the erasure date inside each report.') %} +
+ Export + + + +
+ + +
+
Dated by the timestamp inside each report.
- - ServiceNow tick list (ENT-045/159) - -
-

The evidence pack for last month is also saved automatically to evidence\ on the reports share on the 1st. Certificates of record are in the Blancco Management Portal.

-
+ +
+ Evidence pack + +
+ ServiceNow tick list {{ todo.servicenow }} +{% endcall %} -
-
Drive Erasure Certificates
- {% if reports %} -
- - - - - - - - - - - - - {% for r in reports %} - - - - - - - - - {% endfor %} - -
SerialModelDateResultCloudActions
{% if r.serial %}{{ r.serial }}{% else %}-{% endif %}{{ r.model or '-' }}{{ r.modified | timestamp_fmt }} - {% if r.state == 'Successful' %}Successful - {% elif r.state == 'Failed' %}Failed - {% elif r.state %}{{ r.state }} - {% else %}-{% endif %} - - {% if r.cloud == 'sent' %}Sent - {% elif r.cloud == 'failed' %}Not sent - {% else %}-{% endif %} - - {% if r.filename.lower().endswith('.xml') %} - - View - - {% endif %} - - Download - - -
+
+

Reports by month

+
+
+ + +
+
+ + + + +
+ + + {% for g in groups %} + {% set open_month = loop.index <= 2 %} +
+ +

{{ g.label }}

+ {{ g.rows | length }} report{{ 's' if g.rows | length != 1 }}{% if g.failed %} | {{ g.failed }} failed{% endif %} + + {% if g.month == this_month %}Evidence pack builds on the 1st + {% elif g.uploaded %}{{ ui.chip('Evidence pack uploaded', 'ok') }} + {% elif g.pack %}{{ ui.chip('Evidence pack to upload', 'act') }} + Download pack + {% endif %} + + +
+ {% for r in g.rows %} + {% set failed = r.state == 'Failed' %} +
+
+ + {{ r.when.strftime('%d %b %-I:%M %p') }} + {{ r.serial or '-' }} + {{ r.model or r.filename }} + {% if r.drives %}{{ r.drives | length }} drive{{ 's' if r.drives | length != 1 }}{% else %}{{ r.type }}{% endif %} + {% 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 %}no erasure{% endif %} + {% if r.cloud == 'sent' %}{{ ui.chip('Sent', 'ok') }}{% elif r.cloud == 'failed' %}{{ ui.chip('Not sent', 'err') }}{% else %}no status{% endif %} + {% 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 %} + + +
+ {% for d in r.drives %} +
+
{{ d.model or 'Drive' }} {{ d.serial }}
+
{% if d.capacity %}{{ ((d.capacity | int) / 1e9) | round(0) | int }} GB{% endif %}{% if d.interface %} | {{ d.interface }}{% endif %}
+
+ {% for a in d.attempts %} +
{{ ui.chip('Erased' if a.ok else 'Failed', 'ok' if a.ok else 'fail') }}{{ a.standard }}{{ a.elapsed }}{% if a.why %}{{ a.why }}{% endif %}
+ {% endfor %} +
+
+ {% endfor %} + {% if r.fallback %}

Counts as successful: every drive has a successful erasure; a failed attempt fell back to another standard.

{% endif %} + {% if failed %}
A drive must be destroyed. This report is on the to-do for a Certificate of Destruction{% if r.cloud == 'failed' %} and for the Blancco cloud{% endif %}.
{% endif %} + {% if r.parse_error %}
Could not read this report: {{ r.parse_error }}
{% endif %} +
File {{ r.filename }}{% if r.document_id %}Document {{ r.document_id }}{% endif %}{{ (r.size or 0) | filesizeformat }}
+
+
+
+ {% if r.filename.lower().endswith('.xml') %}View{% endif %} + {% call ui.more(r.serial or r.filename) %} + Download {{ r.type }} + +
+ + {% endcall %} +
+
+ {% endfor %} +
{% else %} -
-

-

No erasure reports yet.

-

Reports will appear here after Blancco Drive Eraser completes a wipe.

-
- {% endif %} -
+
No erasure reports yet. They appear here after Blancco Drive Eraser completes a wipe and saves its report to \\172.16.9.1\blancco-reports.
+ {% endfor %} + + -
-

Report Storage

-

- Blancco Drive Eraser saves erasure certificates to the network share - \\172.16.9.1\blancco-reports. -

-

- Reports are generated automatically after each drive wipe and contain proof of erasure for compliance and audit purposes. -

-
- - -