Webapp: fix six live bugs found in the page inventory

- Report view: the hero summarised only the first erasure, so a drive whose
  Purge failed and Clear fallback succeeded showed Failed while the list said
  Successful. It now uses the per-drive outcome (report_result) and lists the
  standards that wiped each drive.
- To-do and ServiceNow: reports with no usable document id could never be
  ticked or cleared (400). They are now tracked by file name (file:<name>).
- Imaging: the LAPS password field is masked, with a Show/Hide toggle.
- Imaging: succeeded sessions not updated for ACTIVE_WINDOW_HOURS are hidden
  from the dashboard (failed and in-progress always stay), with Show all.
- Enrollment: the active SFLD package can no longer be deleted, which left
  ppkg.conf pointing at a missing file; its row is marked Active.
- Import: confirm before importing, saying upload-folder sources are moved
  and same-name files in the image are overwritten.
This commit is contained in:
cproudlock
2026-10-14 13:29:00 -04:00
parent 24b4d9df66
commit dfd57bef17
10 changed files with 108 additions and 21 deletions

View File

@@ -814,7 +814,11 @@ def blancco_view_report(filename):
except Exception as ex: except Exception as ex:
flash(f"Failed to parse {filename}: {ex}", "danger") flash(f"Failed to parse {filename}: {ex}", "danger")
return redirect(url_for("blancco_reports")) 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/<filename>", methods=["POST"]) @app.route("/reports/delete/<filename>", methods=["POST"])
@@ -836,8 +840,13 @@ def blancco_delete_report(filename):
@app.route("/imaging") @app.route("/imaging")
def imaging_dashboard(): def imaging_dashboard():
show_all = request.args.get("all") == "1"
sessions = imaging_status.list_sessions() 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") @app.route("/imaging/tiles")
@@ -846,6 +855,8 @@ def imaging_tiles_partial():
SSE/polling refresh to swap #imaging-tiles innerHTML without a full page SSE/polling refresh to swap #imaging-tiles innerHTML without a full page
reload.""" reload."""
sessions = imaging_status.list_sessions() 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) return render_template("_imaging_tiles.html", sessions=sessions)
@@ -1179,6 +1190,12 @@ def enrollment_download(filename):
def enrollment_delete(filename): def enrollment_delete(filename):
filename = secure_filename(filename) filename = secure_filename(filename)
fpath = os.path.join(config.ENROLLMENT_PPKG_DIR, 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): if os.path.isfile(fpath):
os.remove(fpath) os.remove(fpath)
audit("ENROLLMENT_DELETE", filename) audit("ENROLLMENT_DELETE", filename)

View File

@@ -30,6 +30,15 @@ import config
from services import blancco_report from services import blancco_report
_DOC_ID = re.compile(r"^[0-9a-fA-F-]{36}$") _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:<name>"."""
return document_id if _DOC_ID.match(document_id or "") else f"file:{filename}"
# --- state ------------------------------------------------------------------ # --- 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): 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") raise ValueError("bad document_id")
state = load_state() state = load_state()
if done: if done:
@@ -127,6 +136,7 @@ def collect(since=None, until=None) -> list[dict]:
"data": data, "data": data,
"document_id": (data.get("meta") or {}).get("document_id", ""), "document_id": (data.get("meta") or {}).get("document_id", ""),
}) })
out[-1]["key"] = report_key(out[-1]["document_id"], f)
return out return out
@@ -193,7 +203,7 @@ def servicenow_items(days: int = 30, include_done: bool = False) -> list[dict]:
state = load_state() state = load_state()
items = [] items = []
for r in reversed(collect(since)): 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: if done and not include_done:
continue continue
data = r["data"] data = r["data"]
@@ -212,6 +222,7 @@ def servicenow_items(days: int = 30, include_done: bool = False) -> list[dict]:
for e in failed) for e in failed)
items.append({ items.append({
"document_id": r["document_id"], "document_id": r["document_id"],
"key": r["key"],
"file": r["file"], "file": r["file"],
"when": r["when"].astimezone(_tz()), "when": r["when"].astimezone(_tz()),
"serial": system.get("serial", ""), "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. # in the state file keyed by month or document_id.
TODO_KINDS = { TODO_KINDS = {
"evidence_uploaded": re.compile(r"^20\d\d-(0[1-9]|1[0-2])$"), # month "evidence_uploaded": re.compile(r"^20\d\d-(0[1-9]|1[0-2])$"), # month
"destroyed": _DOC_ID, # failed wipe: Certificate of Destruction recorded "destroyed": _REPORT_KEY, # failed wipe: Certificate of Destruction recorded
"cloud_resolved": _DOC_ID, # report not in the cloud: uploaded by hand "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$") _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"]: if m and m.group(1) not in state["evidence_uploaded"]:
packs.append({"month": m.group(1), "file": f}) packs.append({"month": m.group(1), "file": f})
year = servicenow_items(days=366, include_done=True) 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 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"] servicenow = sum(1 for i in year if not i["done"]
and i["when"] >= datetime.now(timezone.utc) - timedelta(days=30)) and i["when"] >= datetime.now(timezone.utc) - timedelta(days=30))
return { return {

View File

@@ -15,7 +15,7 @@ import json
import os import os
import re import re
import tempfile import tempfile
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
from typing import Optional from typing import Optional
import config import config
@@ -220,6 +220,29 @@ def list_sessions(include_inferred: bool = True) -> list[dict]:
return out 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]: def get_session(serial: str) -> Optional[dict]:
path = _path_for(serial) path = _path_for(serial)
if not os.path.isfile(path): if not os.path.isfile(path):

View File

@@ -186,12 +186,14 @@
<div class="mt-3 laps-card" data-serial="{{ s.serial }}"> <div class="mt-3 laps-card" data-serial="{{ s.serial }}">
<div class="eyebrow mb-2">LAPS password QR (paste -> scan on bay - persists until cleared)</div> <div class="eyebrow mb-2">LAPS password QR (paste -> scan on bay - persists until cleared)</div>
<div class="d-flex flex-wrap align-items-center gap-2"> <div class="d-flex flex-wrap align-items-center gap-2">
<input type="text" {# Masked: the tile is on a shared screen; Show reveals it while typing. #}
<input type="password"
class="form-control form-control-sm laps-input mono" class="form-control form-control-sm laps-input mono"
style="max-width: 22rem;" style="max-width: 22rem;"
placeholder="paste LAPS password from Intune portal here" placeholder="paste LAPS password from Intune portal here"
autocomplete="off" autocomplete="off"
value="{{ s.laps_password or '' }}"> value="{{ s.laps_password or '' }}">
<button type="button" class="btn btn-sm btn-outline-secondary laps-show-btn" aria-pressed="false">Show</button>
<button type="button" class="btn btn-sm btn-primary laps-make-btn">{% if s.laps_password %}Update QR{% else %}Make QR{% endif %}</button> <button type="button" class="btn btn-sm btn-primary laps-make-btn">{% if s.laps_password %}Update QR{% else %}Make QR{% endif %}</button>
<button type="button" class="btn btn-sm btn-outline-secondary laps-clear-btn" {% if not s.laps_password %}style="display:none;"{% endif %}>Clear</button> <button type="button" class="btn btn-sm btn-outline-secondary laps-clear-btn" {% if not s.laps_password %}style="display:none;"{% endif %}>Clear</button>
</div> </div>

View File

@@ -39,8 +39,9 @@
</thead> </thead>
<tbody> <tbody>
{% for p in packages %} {% for p in packages %}
{% set is_active = active_ppkg and active_ppkg.SOURCE_PPKG and p.filename|lower == active_ppkg.SOURCE_PPKG|lower %}
<tr> <tr>
<td><span class="mono"><i class="bi bi-file-earmark-zip"></i> {{ p.filename }}</span></td> <td><span class="mono"><i class="bi bi-file-earmark-zip"></i> {{ p.filename }}</span>{% if is_active %} <span class="badge badge-info">Active</span>{% endif %}</td>
<td>{{ "%.1f"|format(p.size / 1048576) }} MB</td> <td>{{ "%.1f"|format(p.size / 1048576) }} MB</td>
<td>{{ p.modified | timestamp_fmt }}</td> <td>{{ p.modified | timestamp_fmt }}</td>
<td class="actions text-end text-nowrap"> <td class="actions text-end text-nowrap">
@@ -48,11 +49,17 @@
class="btn btn-sm btn-outline-primary btn-row-action" title="Download"> class="btn btn-sm btn-outline-primary btn-row-action" title="Download">
<i class="bi bi-download"></i> Download <i class="bi bi-download"></i> Download
</a> </a>
{% if is_active %}
<button type="button" class="btn btn-sm btn-outline-secondary btn-row-action" disabled>
<i class="bi bi-lock"></i> Active
</button>
{% else %}
<button type="button" class="btn btn-sm btn-outline-danger btn-row-action" <button type="button" class="btn btn-sm btn-outline-danger btn-row-action"
data-bs-toggle="modal" data-bs-target="#deleteModal" data-bs-toggle="modal" data-bs-target="#deleteModal"
data-filename="{{ p.filename }}" title="Delete"> data-filename="{{ p.filename }}" title="Delete">
<i class="bi bi-trash"></i> Delete <i class="bi bi-trash"></i> Delete
</button> </button>
{% endif %}
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}

View File

@@ -22,6 +22,12 @@ function cancelImagingReload() {
<div> <div>
<h1>Imaging Progress</h1> <h1>Imaging Progress</h1>
<small class="text-light">Live via SSE (15s polling fallback). Client pushes &rarr; <code class="mono">/imaging/status</code>; log-inferred bays in yellow.</small> <small class="text-light">Live via SSE (15s polling fallback). Client pushes &rarr; <code class="mono">/imaging/status</code>; log-inferred bays in yellow.</small>
{% if hidden %}
<div class="small mt-1">{{ hidden }} finished session{{ 's' if hidden != 1 }} older than {{ window_hours }} h hidden.
<a href="{{ url_for('imaging_dashboard', all='1') }}">Show all</a></div>
{% elif show_all %}
<div class="small mt-1">Showing finished sessions of every age. <a href="{{ url_for('imaging_dashboard') }}">Hide old finished sessions</a></div>
{% endif %}
</div> </div>
<div class="header-actions"> <div class="header-actions">
<span class="status-indicator"> <span class="status-indicator">
@@ -91,7 +97,7 @@ Content-Type: application/json
// server signals a state change. Preserves scroll, filter input value, // server signals a state change. Preserves scroll, filter input value,
// and any LAPS input that the operator is actively editing. // and any LAPS input that the operator is actively editing.
(function() { (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 STREAM_URL = "{{ url_for('imaging_stream') }}";
var POLL_MS = 15000; var POLL_MS = 15000;
var lastHash = null; var lastHash = null;
@@ -308,6 +314,15 @@ document.addEventListener('click', function(e) {
} else if (e.target.classList.contains('laps-clear-btn')) { } else if (e.target.classList.contains('laps-clear-btn')) {
card = e.target.closest('.laps-card'); card = e.target.closest('.laps-card');
if (card) clearLapsQR(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) { document.addEventListener('keydown', function(e) {

View File

@@ -168,7 +168,17 @@ document.addEventListener('DOMContentLoaded', function() {
['importForm', 'uploadImportForm'].forEach(function(formId) { ['importForm', 'uploadImportForm'].forEach(function(formId) {
var form = document.getElementById(formId); var form = document.getElementById(formId);
if (form) { 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"]'); var btn = form.querySelector('button[type="submit"]');
btn.disabled = true; btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span> Importing...'; btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span> Importing...';

View File

@@ -43,9 +43,11 @@
{# Hero summary: state badge + standard + drive count + erasure datetime #} {# Hero summary: state badge + standard + drive count + erasure datetime #}
{% set first = (data.erasures or [{}])[0] %} {% set first = (data.erasures or [{}])[0] %}
{% set overall_state = first.state or 'Unknown' %} {% set overall_state = result or 'Unknown' %}
{% set overall_tone = 'success' if overall_state == 'Successful' else 'danger' if overall_state else 'secondary' %} {% 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 overall_state else '' %} {% 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 %}
<div class="dashboard-grid mb-4"> <div class="dashboard-grid mb-4">
<div class="stat-card {{ overall_stat }}"> <div class="stat-card {{ overall_stat }}">
<div class="label">Overall result</div> <div class="label">Overall result</div>
@@ -55,11 +57,11 @@
</div> </div>
<div class="stat-card"> <div class="stat-card">
<div class="label">Erasure standard</div> <div class="label">Erasure standard</div>
<div class="value" style="font-size:1.05rem;">{{ first.erasure_standard_name or '-' }}</div> <div class="value" style="font-size:1.05rem;">{{ standards | join(', ') if standards else (first.erasure_standard_name or '-') }}</div>
</div> </div>
<div class="stat-card"> <div class="stat-card">
<div class="label">Drives erased</div> <div class="label">Drives erased</div>
<div class="value">{{ data.erasures | length }}</div> <div class="value">{{ wiped | length }} of {{ outcomes | length }}</div>
</div> </div>
<div class="stat-card"> <div class="stat-card">
<div class="label">Elapsed time</div> <div class="label">Elapsed time</div>

View File

@@ -65,7 +65,7 @@
{% else %}<span class="text-muted">-</span>{% endif %} {% else %}<span class="text-muted">-</span>{% endif %}
</td> </td>
<td class="text-end text-nowrap"> <td class="text-end text-nowrap">
<form method="post" action="{{ url_for('blancco_servicenow_mark', document_id=it.document_id, all='1' if include_done else None) }}" style="display:inline;"> <form method="post" action="{{ url_for('blancco_servicenow_mark', document_id=it.key, all='1' if include_done else None) }}" style="display:inline;">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
{% if it.done %} {% if it.done %}
<input type="hidden" name="done" value="0"> <input type="hidden" name="done" value="0">

View File

@@ -72,7 +72,7 @@
<td class="small wrap">{{ it.note }}</td> <td class="small wrap">{{ it.note }}</td>
<td class="text-end text-nowrap"> <td class="text-end text-nowrap">
<a href="{{ url_for('blancco_view_report', filename=it.file) }}" class="pxe-btn pxe-btn-secondary btn-row-action"><i class="bi bi-eye"></i> Report</a> <a href="{{ url_for('blancco_view_report', filename=it.file) }}" class="pxe-btn pxe-btn-secondary btn-row-action"><i class="bi bi-eye"></i> Report</a>
<form method="post" action="{{ url_for('blancco_todo_mark', kind='destroyed', key=it.document_id) }}" style="display:inline;"> <form method="post" action="{{ url_for('blancco_todo_mark', kind='destroyed', key=it.key) }}" style="display:inline;">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="pxe-btn pxe-btn-success btn-row-action">COD recorded</button> <button type="submit" class="pxe-btn pxe-btn-success btn-row-action">COD recorded</button>
</form> </form>
@@ -103,7 +103,7 @@
<td class="mono text-nowrap">{{ it.when | local_dt }}</td> <td class="mono text-nowrap">{{ it.when | local_dt }}</td>
<td class="text-end text-nowrap"> <td class="text-end text-nowrap">
<a href="{{ url_for('blancco_download_report', filename=it.file) }}" class="pxe-btn pxe-btn-secondary btn-row-action"><i class="bi bi-download"></i> XML</a> <a href="{{ url_for('blancco_download_report', filename=it.file) }}" class="pxe-btn pxe-btn-secondary btn-row-action"><i class="bi bi-download"></i> XML</a>
<form method="post" action="{{ url_for('blancco_todo_mark', kind='cloud_resolved', key=it.document_id) }}" style="display:inline;"> <form method="post" action="{{ url_for('blancco_todo_mark', kind='cloud_resolved', key=it.key) }}" style="display:inline;">
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="pxe-btn pxe-btn-success btn-row-action">Resolved</button> <button type="submit" class="pxe-btn pxe-btn-success btn-row-action">Resolved</button>
</form> </form>