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:
179
webapp/app.py
179
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/<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 {
|
||||
|
||||
Reference in New Issue
Block a user