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:
@@ -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/<filename>", 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)
|
||||
|
||||
@@ -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:<name>"."""
|
||||
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 {
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -186,12 +186,14 @@
|
||||
<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="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"
|
||||
style="max-width: 22rem;"
|
||||
placeholder="paste LAPS password from Intune portal here"
|
||||
autocomplete="off"
|
||||
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-outline-secondary laps-clear-btn" {% if not s.laps_password %}style="display:none;"{% endif %}>Clear</button>
|
||||
</div>
|
||||
|
||||
@@ -39,8 +39,9 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for p in packages %}
|
||||
{% set is_active = active_ppkg and active_ppkg.SOURCE_PPKG and p.filename|lower == active_ppkg.SOURCE_PPKG|lower %}
|
||||
<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>{{ p.modified | timestamp_fmt }}</td>
|
||||
<td class="actions text-end text-nowrap">
|
||||
@@ -48,11 +49,17 @@
|
||||
class="btn btn-sm btn-outline-primary btn-row-action" title="Download">
|
||||
<i class="bi bi-download"></i> Download
|
||||
</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"
|
||||
data-bs-toggle="modal" data-bs-target="#deleteModal"
|
||||
data-filename="{{ p.filename }}" title="Delete">
|
||||
<i class="bi bi-trash"></i> Delete
|
||||
</button>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
@@ -22,6 +22,12 @@ function cancelImagingReload() {
|
||||
<div>
|
||||
<h1>Imaging Progress</h1>
|
||||
<small class="text-light">Live via SSE (15s polling fallback). Client pushes → <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 class="header-actions">
|
||||
<span class="status-indicator">
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = '<span class="spinner-border spinner-border-sm me-1"></span> Importing...';
|
||||
|
||||
@@ -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 %}
|
||||
<div class="dashboard-grid mb-4">
|
||||
<div class="stat-card {{ overall_stat }}">
|
||||
<div class="label">Overall result</div>
|
||||
@@ -55,11 +57,11 @@
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<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 class="stat-card">
|
||||
<div class="label">Drives erased</div>
|
||||
<div class="value">{{ data.erasures | length }}</div>
|
||||
<div class="value">{{ wiped | length }} of {{ outcomes | length }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">Elapsed time</div>
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
{% else %}<span class="text-muted">-</span>{% endif %}
|
||||
</td>
|
||||
<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() }}">
|
||||
{% if it.done %}
|
||||
<input type="hidden" name="done" value="0">
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
<td class="small wrap">{{ it.note }}</td>
|
||||
<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>
|
||||
<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() }}">
|
||||
<button type="submit" class="pxe-btn pxe-btn-success btn-row-action">COD recorded</button>
|
||||
</form>
|
||||
@@ -103,7 +103,7 @@
|
||||
<td class="mono text-nowrap">{{ it.when | local_dt }}</td>
|
||||
<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>
|
||||
<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() }}">
|
||||
<button type="submit" class="pxe-btn pxe-btn-success btn-row-action">Resolved</button>
|
||||
</form>
|
||||
|
||||
Reference in New Issue
Block a user