diff --git a/webapp/app.py b/webapp/app.py index e39cc8e..79a5a2d 100644 --- a/webapp/app.py +++ b/webapp/app.py @@ -814,7 +814,11 @@ def blancco_view_report(filename): except Exception as ex: flash(f"Failed to parse {filename}: {ex}", "danger") return redirect(url_for("blancco_reports")) - return render_template("report_view.html", filename=filename, data=data) + # 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) + return render_template("report_view.html", filename=filename, data=data, + result=blancco_report.report_result(data), outcomes=outcomes) @app.route("/reports/delete/", methods=["POST"]) @@ -836,8 +840,13 @@ def blancco_delete_report(filename): @app.route("/imaging") def imaging_dashboard(): + show_all = request.args.get("all") == "1" sessions = imaging_status.list_sessions() - return render_template("imaging.html", sessions=sessions) + hidden = 0 + if not show_all: + sessions, hidden = imaging_status.hide_stale(sessions) + return render_template("imaging.html", sessions=sessions, hidden=hidden, + show_all=show_all, window_hours=imaging_status.ACTIVE_WINDOW_HOURS) @app.route("/imaging/tiles") @@ -846,6 +855,8 @@ def imaging_tiles_partial(): SSE/polling refresh to swap #imaging-tiles innerHTML without a full page reload.""" sessions = imaging_status.list_sessions() + if request.args.get("all") != "1": + sessions, _hidden = imaging_status.hide_stale(sessions) return render_template("_imaging_tiles.html", sessions=sessions) @@ -1179,6 +1190,12 @@ def enrollment_download(filename): def enrollment_delete(filename): filename = secure_filename(filename) fpath = os.path.join(config.ENROLLMENT_PPKG_DIR, filename) + active = read_ppkg_conf().get("SOURCE_PPKG", "") + if active and filename.lower() == active.lower(): + # startnet.cmd would keep pointing at a file that no longer exists. + flash(f"{filename} is the active package. Upload or activate another one before deleting it.", + "danger") + return redirect(url_for("enrollment")) if os.path.isfile(fpath): os.remove(fpath) audit("ENROLLMENT_DELETE", filename) diff --git a/webapp/services/blancco_evidence.py b/webapp/services/blancco_evidence.py index 336707f..35a0b6b 100644 --- a/webapp/services/blancco_evidence.py +++ b/webapp/services/blancco_evidence.py @@ -30,6 +30,15 @@ import config from services import blancco_report _DOC_ID = re.compile(r"^[0-9a-fA-F-]{36}$") +# Reports without a usable document id (older or hand-made ones) are tracked +# by file name instead, so they can still be ticked and cleared. +_FILE_KEY = re.compile(r"^file:[A-Za-z0-9._-]{1,200}$") +_REPORT_KEY = re.compile(f"{_DOC_ID.pattern}|{_FILE_KEY.pattern}") + + +def report_key(document_id: str, filename: str) -> str: + """The state-file key for one report: its document id, else "file:".""" + return document_id if _DOC_ID.match(document_id or "") else f"file:{filename}" # --- state ------------------------------------------------------------------ @@ -71,7 +80,7 @@ def record_cloud(document_id: str, sent: bool, serial: str = "", detail: str = " def mark_servicenow(document_id: str, done: bool = True): - if not _DOC_ID.match(document_id or ""): + if not _REPORT_KEY.match(document_id or ""): raise ValueError("bad document_id") state = load_state() if done: @@ -127,6 +136,7 @@ def collect(since=None, until=None) -> list[dict]: "data": data, "document_id": (data.get("meta") or {}).get("document_id", ""), }) + out[-1]["key"] = report_key(out[-1]["document_id"], f) return out @@ -193,7 +203,7 @@ def servicenow_items(days: int = 30, include_done: bool = False) -> list[dict]: state = load_state() items = [] for r in reversed(collect(since)): - done = state["servicenow"].get(r["document_id"]) + done = state["servicenow"].get(r["key"]) if done and not include_done: continue data = r["data"] @@ -212,6 +222,7 @@ def servicenow_items(days: int = 30, include_done: bool = False) -> list[dict]: for e in failed) items.append({ "document_id": r["document_id"], + "key": r["key"], "file": r["file"], "when": r["when"].astimezone(_tz()), "serial": system.get("serial", ""), @@ -232,8 +243,8 @@ def servicenow_items(days: int = 30, include_done: bool = False) -> list[dict]: # in the state file keyed by month or document_id. TODO_KINDS = { "evidence_uploaded": re.compile(r"^20\d\d-(0[1-9]|1[0-2])$"), # month - "destroyed": _DOC_ID, # failed wipe: Certificate of Destruction recorded - "cloud_resolved": _DOC_ID, # report not in the cloud: uploaded by hand + "destroyed": _REPORT_KEY, # failed wipe: Certificate of Destruction recorded + "cloud_resolved": _REPORT_KEY, # report not in the cloud: uploaded by hand } _EVIDENCE_ZIP = re.compile(r"^ENT-056_.+_(20\d\d-\d\d)\.zip$") @@ -265,9 +276,9 @@ def todo() -> dict: if m and m.group(1) not in state["evidence_uploaded"]: packs.append({"month": m.group(1), "file": f}) year = servicenow_items(days=366, include_done=True) - failed = [i for i in year if i["failed"] and i["document_id"] not in state["destroyed"]] + failed = [i for i in year if i["failed"] and i["key"] not in state["destroyed"]] not_in_cloud = [i for i in year - if i["cloud"] == "failed" and i["document_id"] not in state["cloud_resolved"]] + if i["cloud"] == "failed" and i["key"] not in state["cloud_resolved"]] servicenow = sum(1 for i in year if not i["done"] and i["when"] >= datetime.now(timezone.utc) - timedelta(days=30)) return { diff --git a/webapp/services/imaging_status.py b/webapp/services/imaging_status.py index b4b8a43..201f17f 100644 --- a/webapp/services/imaging_status.py +++ b/webapp/services/imaging_status.py @@ -15,7 +15,7 @@ import json import os import re import tempfile -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from typing import Optional import config @@ -220,6 +220,29 @@ def list_sessions(include_inferred: bool = True) -> list[dict]: return out +def hide_stale(sessions: list[dict], hours: float = ACTIVE_WINDOW_HOURS) -> tuple[list[dict], int]: + """Drop succeeded sessions not updated for `hours` from the dashboard list. + + Failed and in-progress sessions always stay: a failure must be seen and + cleared by someone. Returns (kept, hidden_count). + """ + cutoff = datetime.now().astimezone() - timedelta(hours=hours) + kept, hidden = [], 0 + for s in sessions: + if s.get("status") == "succeeded": + try: + when = datetime.fromisoformat(s.get("last_updated") or "") + if when.tzinfo is None: + when = when.astimezone() + except ValueError: + when = None + if when is not None and when < cutoff: + hidden += 1 + continue + kept.append(s) + return kept, hidden + + def get_session(serial: str) -> Optional[dict]: path = _path_for(serial) if not os.path.isfile(path): diff --git a/webapp/templates/_imaging_tiles.html b/webapp/templates/_imaging_tiles.html index b71e139..a2d0f87 100644 --- a/webapp/templates/_imaging_tiles.html +++ b/webapp/templates/_imaging_tiles.html @@ -186,12 +186,14 @@
LAPS password QR (paste -> scan on bay - persists until cleared)
- +
diff --git a/webapp/templates/enrollment.html b/webapp/templates/enrollment.html index 7bcc859..e98ea9e 100644 --- a/webapp/templates/enrollment.html +++ b/webapp/templates/enrollment.html @@ -39,8 +39,9 @@ {% for p in packages %} + {% set is_active = active_ppkg and active_ppkg.SOURCE_PPKG and p.filename|lower == active_ppkg.SOURCE_PPKG|lower %} - {{ p.filename }} + {{ p.filename }}{% if is_active %} Active{% endif %} {{ "%.1f"|format(p.size / 1048576) }} MB {{ p.modified | timestamp_fmt }} @@ -48,11 +49,17 @@ class="btn btn-sm btn-outline-primary btn-row-action" title="Download"> Download + {% if is_active %} + + {% else %} + {% endif %} {% endfor %} diff --git a/webapp/templates/imaging.html b/webapp/templates/imaging.html index ec82e6d..4823e00 100644 --- a/webapp/templates/imaging.html +++ b/webapp/templates/imaging.html @@ -22,6 +22,12 @@ function cancelImagingReload() {

Imaging Progress

Live via SSE (15s polling fallback). Client pushes → /imaging/status; log-inferred bays in yellow. + {% if hidden %} +
{{ hidden }} finished session{{ 's' if hidden != 1 }} older than {{ window_hours }} h hidden. + Show all
+ {% elif show_all %} +
Showing finished sessions of every age. Hide old finished sessions
+ {% endif %}
@@ -91,7 +97,7 @@ Content-Type: application/json // server signals a state change. Preserves scroll, filter input value, // and any LAPS input that the operator is actively editing. (function() { - var TILES_URL = "{{ url_for('imaging_tiles_partial') }}"; + var TILES_URL = "{{ url_for('imaging_tiles_partial', all='1' if show_all else None) }}"; var STREAM_URL = "{{ url_for('imaging_stream') }}"; var POLL_MS = 15000; var lastHash = null; @@ -308,6 +314,15 @@ document.addEventListener('click', function(e) { } else if (e.target.classList.contains('laps-clear-btn')) { card = e.target.closest('.laps-card'); if (card) clearLapsQR(card); + } else if (e.target.classList.contains('laps-show-btn')) { + card = e.target.closest('.laps-card'); + var inp = card && card.querySelector('.laps-input'); + if (inp) { + var show = inp.type === 'password'; + inp.type = show ? 'text' : 'password'; + e.target.textContent = show ? 'Hide' : 'Show'; + e.target.setAttribute('aria-pressed', show ? 'true' : 'false'); + } } }); document.addEventListener('keydown', function(e) { diff --git a/webapp/templates/import.html b/webapp/templates/import.html index 27538b6..0e9a714 100644 --- a/webapp/templates/import.html +++ b/webapp/templates/import.html @@ -168,7 +168,17 @@ document.addEventListener('DOMContentLoaded', function() { ['importForm', 'uploadImportForm'].forEach(function(formId) { var form = document.getElementById(formId); if (form) { - form.addEventListener('submit', function() { + form.addEventListener('submit', function(e) { + // Import overwrites same-name files in the image and, from the + // upload folder, moves (consumes) the source. Make that explicit. + var src = form.querySelector('select[name="source"]'); + var tgt = form.querySelector('select[name="target"]'); + var what = formId === 'uploadImportForm' + ? 'The files are MOVED out of the upload folder, so the source is used up.' + : 'The files are copied from the USB drive.'; + var msg = 'Import ' + (src ? src.value : '') + ' into ' + (tgt ? tgt.options[tgt.selectedIndex].text : '') + '?\n\n' + + what + '\nFiles with the same name in the image are overwritten. This takes several minutes.'; + if (!confirm(msg)) { e.preventDefault(); return; } var btn = form.querySelector('button[type="submit"]'); btn.disabled = true; btn.innerHTML = ' Importing...'; diff --git a/webapp/templates/report_view.html b/webapp/templates/report_view.html index a91676b..4450047 100644 --- a/webapp/templates/report_view.html +++ b/webapp/templates/report_view.html @@ -43,9 +43,11 @@ {# Hero summary: state badge + standard + drive count + erasure datetime #} {% set first = (data.erasures or [{}])[0] %} -{% set overall_state = first.state or 'Unknown' %} -{% set overall_tone = 'success' if overall_state == 'Successful' else 'danger' if overall_state else 'secondary' %} -{% set overall_stat = 'success' if overall_state == 'Successful' else 'danger' if overall_state else '' %} +{% 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
@@ -55,11 +57,11 @@
Erasure standard
-
{{ first.erasure_standard_name or '-' }}
+
{{ standards | join(', ') if standards else (first.erasure_standard_name or '-') }}
Drives erased
-
{{ data.erasures | length }}
+
{{ wiped | length }} of {{ outcomes | length }}
Elapsed time
diff --git a/webapp/templates/reports_servicenow.html b/webapp/templates/reports_servicenow.html index 7482c92..ab6ce46 100644 --- a/webapp/templates/reports_servicenow.html +++ b/webapp/templates/reports_servicenow.html @@ -65,7 +65,7 @@ {% else %}-{% endif %} -
+ {% if it.done %} diff --git a/webapp/templates/reports_todo.html b/webapp/templates/reports_todo.html index 1a750ee..b95b962 100644 --- a/webapp/templates/reports_todo.html +++ b/webapp/templates/reports_todo.html @@ -72,7 +72,7 @@ {{ it.note }} Report - +
@@ -103,7 +103,7 @@ {{ it.when | local_dt }} XML -
+