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

-
- - -