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

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

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

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

View File

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