blancco: fix silent prefs fallback, suspend trap, display blank + add View
End-to-end fixes for Blancco Drive Eraser PXE flow uncovered by chasing
"reports never reach SMB share" across two air-gapped sites:
playbook/blancco-init.sh:
* Drop silent || true on wget of preferences.xml + config.xml. Fail
loud with shell-drop if download or marker grep fails. Background:
airootfs /opt/scripts/validate_preferences.sh restores
/albus/preferences.save (factory defaults, empty network_share) if
xmllint fails. wget failure made every report silently land nowhere.
* Clobber /albus/preferences.save with the same served file so even if
the validator fallback fires, the SMB target survives.
* Bind-mount /dev/null over /sys/power/{state,disk,mem_sleep,autosleep}
before switch_root. Albus's license-retry path writes /sys/power/state
directly (bypassing systemd targets); this is the last-line block.
* /dev/null symlinks for sleep/suspend/hibernate systemd targets in the
airootfs overlay + logind drop-in with IdleAction/Handle*=ignore.
Three independent layers because cmdline systemd.mask alone is bypassed
by direct /sys/power/state writes.
* xinitrc.d/00-no-screen-blank.sh runs xset s off -dpms + setterm
-blank 0 -powerdown 0 so the Blancco GUI doesn't blank during long
erasures.
* Removed the 20-failsafeDriver.conf "modesetting" pin. modesetting
needs DRM/KMS which we disable on kernel cmdline; "vesa" also failed
on NVIDIA. With the pin gone Xorg auto-picks fbdev which uses the
kernel framebuffer from vga=normal - works across Intel, AMD, and
older NVIDIA without nouveau.
playbook/pxe_server_setup.yml:
* dnsmasq.conf: explicit empty-value dhcp-option=3 + dhcp-option=6.
Without them, dnsmasq defaults to sending its own IP as router AND
DNS. Commenting the configured-value lines did NOT disable the push
(root cause of "wired keeps picking up 10.9.100.1 as gateway").
* Split the Blancco config.img extraction and preferences.xml deploy
into separate tasks. The previous shell-with-creates: gate caused
playbook re-runs to skip the prefs deploy entirely after first run.
* Added a validation task that runs python3 xml parse + grep on the
deployed preferences.xml to fail the playbook at deploy time if the
SMB markers are missing.
* Added Environment=TZ=America/New_York to the pxe-webapp systemd
service so report mtimes and audit log render in Eastern time even
if the Python process is started before timedatectl converges.
webapp:
* services/blancco_report.py: parse Blancco's XML report format
(recursive <entries name="..."> walker) into a friendly dict.
* templates/report_view.html: Bootstrap "Drive Erasure Certificate"
layout - hero summary, customer + system cards, per-drive cards with
step-by-step erasure timeline, document signing footer with
integrity hash detail.
* /reports/view/<filename> route + View button on the reports list
(XML reports only; PDFs still download).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -105,22 +105,96 @@ wget -O /tmp/kmod.tar.gz http://$SERVER/blancco/kmod.tar.gz 2>&1
|
|||||||
[ -s /tmp/kmod.tar.gz ] && (cd /run/newroot && gunzip -c /tmp/kmod.tar.gz | tar xf - && rm -f /tmp/kmod.tar.gz)
|
[ -s /tmp/kmod.tar.gz ] && (cd /run/newroot && gunzip -c /tmp/kmod.tar.gz | tar xf - && rm -f /tmp/kmod.tar.gz)
|
||||||
|
|
||||||
mkdir -p /run/newroot/albus
|
mkdir -p /run/newroot/albus
|
||||||
wget -O /run/newroot/albus/config.xml http://$SERVER/blancco/config-clean.xml 2>&1 || true
|
|
||||||
wget -O /run/newroot/albus/preferences.xml http://$SERVER/blancco/preferences.xml 2>&1 || true
|
# preferences.xml REQUIRED. Without valid file, Blancco airootfs's
|
||||||
cp -f /run/newroot/albus/preferences.xml /run/newroot/albus/preferences.save 2>/dev/null || true
|
# /opt/scripts/validate_preferences.sh silently restores
|
||||||
|
# /albus/preferences.save (factory defaults, empty network_share) so
|
||||||
|
# erasure reports never reach SMB. Fail loud here instead.
|
||||||
|
PREFS=/run/newroot/albus/preferences.xml
|
||||||
|
if ! wget -O "$PREFS" "http://$SERVER/blancco/preferences.xml"; then
|
||||||
|
echo "ERROR: preferences.xml download failed from http://$SERVER/blancco/preferences.xml"
|
||||||
|
echo "Reports would fall back to factory defaults (no SMB target)."
|
||||||
|
echo "Dropping to shell - check Apache + network."
|
||||||
|
exec /bin/sh
|
||||||
|
fi
|
||||||
|
# Busybox initramfs has no xmllint. Grep for required SMB markers instead;
|
||||||
|
# if either is missing, the prefs file in web_root is stale or corrupted.
|
||||||
|
if [ ! -s "$PREFS" ] || ! grep -q "<hostname>$SERVER</hostname>" "$PREFS" \
|
||||||
|
|| ! grep -q '<path>blancco-reports</path>' "$PREFS"; then
|
||||||
|
echo "ERROR: preferences.xml missing required network_share entries."
|
||||||
|
echo "Expected <hostname>$SERVER</hostname> + <path>blancco-reports</path>."
|
||||||
|
echo "=== first 60 lines of $PREFS ==="
|
||||||
|
head -60 "$PREFS"
|
||||||
|
exec /bin/sh
|
||||||
|
fi
|
||||||
|
# Clobber preferences.save too. validate_preferences.sh in airootfs falls
|
||||||
|
# back to preferences.save on any future xmllint failure; if that ever
|
||||||
|
# fires, we want the fallback to still have the right SMB target.
|
||||||
|
cp -f "$PREFS" /run/newroot/albus/preferences.save
|
||||||
|
|
||||||
|
if ! wget -O /run/newroot/albus/config.xml "http://$SERVER/blancco/config-clean.xml"; then
|
||||||
|
echo "ERROR: config.xml (license container) download failed."
|
||||||
|
exec /bin/sh
|
||||||
|
fi
|
||||||
|
|
||||||
mkdir -p /run/newroot/etc/X11/xorg.conf.d
|
mkdir -p /run/newroot/etc/X11/xorg.conf.d
|
||||||
cat > /run/newroot/etc/X11/xorg.conf.d/20-failsafeDriver.conf << XEOF
|
# Don't pin a single Xorg driver - hardware varies per site (Intel iGPU,
|
||||||
Section "Device"
|
# NVIDIA GK208, AMD, etc). Let Xorg auto-pick. With nomodeset + KMS
|
||||||
Identifier "Failsafe Video Device"
|
# blacklist on kernel cmdline, Xorg falls back to fbdev (uses
|
||||||
Driver "modesetting"
|
# kernel-provided framebuffer from vga=normal) which works on most boxes.
|
||||||
EndSection
|
# Previous "modesetting" pin needed KMS we disabled; "vesa" pin also
|
||||||
|
# didn't drive NVIDIA cards. Removing the pin entirely.
|
||||||
|
rm -f /run/newroot/etc/X11/xorg.conf.d/20-failsafeDriver.conf 2>/dev/null || true
|
||||||
|
|
||||||
|
# Hard-mask sleep/suspend/hibernate targets in the airootfs overlay. systemd
|
||||||
|
# kernel cmdline systemd.mask= only blocks systemd-side activation; userspace
|
||||||
|
# can still write /sys/power/state directly. /dev/null symlinks under
|
||||||
|
# /etc/systemd/system/ block ALL systemd-mediated paths AND stop logind from
|
||||||
|
# advertising sleep capability to userland. Combined with the logind drop-in
|
||||||
|
# below, /sys/power/state writes from non-root userland (Albus) also fail.
|
||||||
|
mkdir -p /run/newroot/etc/systemd/system
|
||||||
|
for tgt in sleep.target suspend.target hibernate.target hybrid-sleep.target suspend-then-hibernate.target; do
|
||||||
|
ln -sf /dev/null "/run/newroot/etc/systemd/system/$tgt"
|
||||||
|
done
|
||||||
|
|
||||||
|
mkdir -p /run/newroot/etc/systemd/logind.conf.d
|
||||||
|
cat > /run/newroot/etc/systemd/logind.conf.d/no-suspend.conf << XEOF
|
||||||
|
[Login]
|
||||||
|
IdleAction=ignore
|
||||||
|
HandleSuspendKey=ignore
|
||||||
|
HandleHibernateKey=ignore
|
||||||
|
HandleLidSwitch=ignore
|
||||||
|
HandleLidSwitchDocked=ignore
|
||||||
|
HandlePowerKey=ignore
|
||||||
XEOF
|
XEOF
|
||||||
|
|
||||||
|
# Disable Xorg screensaver + DPMS so display stays on during long erasures.
|
||||||
|
# Numbered 00-* to run before vendor xinitrc.d hooks.
|
||||||
|
mkdir -p /run/newroot/etc/X11/xinit/xinitrc.d
|
||||||
|
cat > /run/newroot/etc/X11/xinit/xinitrc.d/00-no-screen-blank.sh << 'XEOF'
|
||||||
|
#!/bin/sh
|
||||||
|
xset s off -dpms 2>/dev/null || true
|
||||||
|
xset s noblank 2>/dev/null || true
|
||||||
|
setterm -blank 0 -powerdown 0 2>/dev/null || true
|
||||||
|
XEOF
|
||||||
|
chmod +x /run/newroot/etc/X11/xinit/xinitrc.d/00-no-screen-blank.sh
|
||||||
|
|
||||||
mkdir -p /run/newroot/proc /run/newroot/sys /run/newroot/dev /run/newroot/run /run/newroot/tmp
|
mkdir -p /run/newroot/proc /run/newroot/sys /run/newroot/dev /run/newroot/run /run/newroot/tmp
|
||||||
mount --move /proc /run/newroot/proc
|
mount --move /proc /run/newroot/proc
|
||||||
mount --move /sys /run/newroot/sys
|
mount --move /sys /run/newroot/sys
|
||||||
mount --move /dev /run/newroot/dev
|
mount --move /dev /run/newroot/dev
|
||||||
|
|
||||||
|
# Hard-block kernel suspend/hibernate path. Bind-mount /dev/null over the
|
||||||
|
# sysfs power-control files so any userland write (Albus auto-suspend,
|
||||||
|
# pm-utils, etc) becomes a no-op. This is the LAST line of defense - kernel
|
||||||
|
# cmdline systemd.mask, /dev/null symlinks for sleep targets, and logind
|
||||||
|
# drop-ins were all bypassed by Albus writing /sys/power/state directly.
|
||||||
|
# Bind-mount works at the VFS layer below sysfs, so even kernel-side mount
|
||||||
|
# remounts wouldn't undo it.
|
||||||
|
mount --bind /dev/null /run/newroot/sys/power/state 2>/dev/null || true
|
||||||
|
mount --bind /dev/null /run/newroot/sys/power/disk 2>/dev/null || true
|
||||||
|
mount --bind /dev/null /run/newroot/sys/power/mem_sleep 2>/dev/null || true
|
||||||
|
mount --bind /dev/null /run/newroot/sys/power/autosleep 2>/dev/null || true
|
||||||
|
|
||||||
echo "Switching root..."
|
echo "Switching root..."
|
||||||
exec switch_root /run/newroot /sbin/init
|
exec switch_root /run/newroot /sbin/init
|
||||||
|
|||||||
@@ -159,8 +159,14 @@
|
|||||||
# the wired NIC much later). Removing these options entirely lets
|
# the wired NIC much later). Removing these options entirely lets
|
||||||
# Windows route internet via WiFi and same-subnet PXE/SMB traffic
|
# Windows route internet via WiFi and same-subnet PXE/SMB traffic
|
||||||
# via wired, no migration script needed.
|
# via wired, no migration script needed.
|
||||||
# dhcp-option=3,10.9.100.1
|
#
|
||||||
# dhcp-option=6,8.8.8.8
|
# Important: dnsmasq DEFAULTS to sending its own listening address as
|
||||||
|
# both router and DNS when these options are unset. Commenting them
|
||||||
|
# out is NOT the same as disabling - imaged PCs (and Blancco PXE
|
||||||
|
# clients) end up with 10.9.100.1 as gateway. The empty-value form
|
||||||
|
# below explicitly suppresses both options.
|
||||||
|
dhcp-option=3
|
||||||
|
dhcp-option=6
|
||||||
enable-tftp
|
enable-tftp
|
||||||
tftp-root={{ tftp_dir }}
|
tftp-root={{ tftp_dir }}
|
||||||
# Arch-aware NBP: legacy BIOS PXE ROMs (client-arch=0) cannot run
|
# Arch-aware NBP: legacy BIOS PXE ROMs (client-arch=0) cannot run
|
||||||
@@ -863,9 +869,8 @@
|
|||||||
args:
|
args:
|
||||||
creates: "{{ web_root }}/blancco/kmod.tar.gz"
|
creates: "{{ web_root }}/blancco/kmod.tar.gz"
|
||||||
|
|
||||||
- name: "Deploy Blancco config and preferences (null-stripped)"
|
- name: "Extract config-clean.xml from config.img (strip null bytes)"
|
||||||
shell: |
|
shell: |
|
||||||
# Strip null bytes from config.img files and deploy
|
|
||||||
if [ -f "{{ web_root }}/blancco/config.img" ]; then
|
if [ -f "{{ web_root }}/blancco/config.img" ]; then
|
||||||
WORK=$(mktemp -d)
|
WORK=$(mktemp -d)
|
||||||
cd "$WORK"
|
cd "$WORK"
|
||||||
@@ -873,11 +878,30 @@
|
|||||||
tr -d '\000' < config.xml > "{{ web_root }}/blancco/config-clean.xml"
|
tr -d '\000' < config.xml > "{{ web_root }}/blancco/config-clean.xml"
|
||||||
rm -rf "$WORK"
|
rm -rf "$WORK"
|
||||||
fi
|
fi
|
||||||
# Deploy preferences from playbook (pre-configured with network share)
|
|
||||||
cp "{{ usb_root }}/playbook/blancco-preferences.xml" "{{ web_root }}/blancco/preferences.xml"
|
|
||||||
args:
|
args:
|
||||||
creates: "{{ web_root }}/blancco/config-clean.xml"
|
creates: "{{ web_root }}/blancco/config-clean.xml"
|
||||||
|
|
||||||
|
# Idempotent file copy (was bundled into the previous shell task with a
|
||||||
|
# 'creates:' gate, so playbook re-runs never picked up edits to the
|
||||||
|
# source XML - stale prefs survived deploys).
|
||||||
|
- name: "Deploy Blancco preferences.xml from playbook source"
|
||||||
|
copy:
|
||||||
|
src: "{{ usb_root }}/playbook/blancco-preferences.xml"
|
||||||
|
dest: "{{ web_root }}/blancco/preferences.xml"
|
||||||
|
mode: '0644'
|
||||||
|
|
||||||
|
# Hard gate: blancco-init.sh in initramfs drops to shell if the served
|
||||||
|
# preferences.xml is malformed or missing the SMB target. Fail the
|
||||||
|
# playbook here too so the problem surfaces at deploy time, not at the
|
||||||
|
# next erasure attempt.
|
||||||
|
- name: "Validate deployed preferences.xml is well-formed + targets the right SMB share"
|
||||||
|
shell: |
|
||||||
|
set -e
|
||||||
|
python3 -c 'import xml.etree.ElementTree as ET; ET.parse("{{ web_root }}/blancco/preferences.xml")'
|
||||||
|
grep -q '<hostname>10.9.100.1</hostname>' "{{ web_root }}/blancco/preferences.xml"
|
||||||
|
grep -q '<path>blancco-reports</path>' "{{ web_root }}/blancco/preferences.xml"
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
- name: "Ensure Samba user for Blancco reports exists (idempotent)"
|
- name: "Ensure Samba user for Blancco reports exists (idempotent)"
|
||||||
shell: |
|
shell: |
|
||||||
id blancco >/dev/null 2>&1 || useradd -r -s /usr/sbin/nologin blancco
|
id blancco >/dev/null 2>&1 || useradd -r -s /usr/sbin/nologin blancco
|
||||||
@@ -994,6 +1018,11 @@
|
|||||||
Environment=BLANCCO_REPORTS=/srv/samba/blancco-reports
|
Environment=BLANCCO_REPORTS=/srv/samba/blancco-reports
|
||||||
Environment=ENROLLMENT_SHARE=/srv/samba/enrollment
|
Environment=ENROLLMENT_SHARE=/srv/samba/enrollment
|
||||||
Environment=AUDIT_LOG=/var/log/pxe-webapp-audit.log
|
Environment=AUDIT_LOG=/var/log/pxe-webapp-audit.log
|
||||||
|
# Lock TZ so report/backup mtimes and audit-log timestamps render
|
||||||
|
# in Eastern time regardless of how the host's /etc/localtime ends
|
||||||
|
# up. Without this, a Python process started before timedatectl
|
||||||
|
# finishes can cache UTC for its lifetime.
|
||||||
|
Environment=TZ=America/New_York
|
||||||
ExecStart=/usr/bin/python3 app.py
|
ExecStart=/usr/bin/python3 app.py
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ from lxml import etree
|
|||||||
from werkzeug.utils import secure_filename
|
from werkzeug.utils import secure_filename
|
||||||
|
|
||||||
import config
|
import config
|
||||||
from services import deploy, fs, images, system, unattend, wim
|
from services import blancco_report, deploy, fs, images, system, unattend, wim
|
||||||
from services.audit import audit
|
from services.audit import audit
|
||||||
from services.csrf import init_csrf
|
from services.csrf import init_csrf
|
||||||
|
|
||||||
@@ -390,6 +390,24 @@ def blancco_download_report(filename):
|
|||||||
return send_file(fpath, as_attachment=True)
|
return send_file(fpath, as_attachment=True)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/reports/view/<filename>")
|
||||||
|
def blancco_view_report(filename):
|
||||||
|
filename = secure_filename(filename)
|
||||||
|
fpath = os.path.join(config.BLANCCO_REPORTS, filename)
|
||||||
|
if not os.path.isfile(fpath):
|
||||||
|
flash(f"Report not found: {filename}", "danger")
|
||||||
|
return redirect(url_for("blancco_reports"))
|
||||||
|
if not filename.lower().endswith(".xml"):
|
||||||
|
flash("Formatted view supports XML reports only.", "warning")
|
||||||
|
return redirect(url_for("blancco_reports"))
|
||||||
|
try:
|
||||||
|
data = blancco_report.parse(fpath)
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/reports/delete/<filename>", methods=["POST"])
|
@app.route("/reports/delete/<filename>", methods=["POST"])
|
||||||
def blancco_delete_report(filename):
|
def blancco_delete_report(filename):
|
||||||
filename = secure_filename(filename)
|
filename = secure_filename(filename)
|
||||||
|
|||||||
105
webapp/services/blancco_report.py
Normal file
105
webapp/services/blancco_report.py
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
"""
|
||||||
|
Parse Blancco Drive Eraser XML report into a friendly Python dict.
|
||||||
|
|
||||||
|
Blancco encodes everything as <entries name="X"><entry name="K" type="T">V</entry>...
|
||||||
|
which is too noisy to read directly in a template. Walking the tree once into
|
||||||
|
nested dicts/lists lets the template render fields by their semantic name
|
||||||
|
without any XPath-style noise.
|
||||||
|
|
||||||
|
Returned shape (best-effort; missing keys are OK in templates):
|
||||||
|
{
|
||||||
|
"meta": {document_id, date, product_name, product_version,
|
||||||
|
product_revision, integrity},
|
||||||
|
"company": {business_location, business_name, customer_license, ...},
|
||||||
|
"license_consumption_ids": [str, ...],
|
||||||
|
"erasures": [ {erasure_id, timestamp, target:{...}, state,
|
||||||
|
elapsed_time, erasure_standard_name, step:[...], ...} ],
|
||||||
|
"hardware": {system:{...}, bios:{...}, processors:{...}, ...},
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
|
|
||||||
|
def _walk(node):
|
||||||
|
# Recursive walk over Blancco's <entry|entries name="K"> children.
|
||||||
|
# Repeated names collapse to a list.
|
||||||
|
result = {}
|
||||||
|
for child in node:
|
||||||
|
name = child.attrib.get("name") or child.tag
|
||||||
|
if child.tag == "entry":
|
||||||
|
val = (child.text or "").strip()
|
||||||
|
elif child.tag == "entries":
|
||||||
|
val = _walk(child)
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
if name in result:
|
||||||
|
existing = result[name]
|
||||||
|
if not isinstance(existing, list):
|
||||||
|
result[name] = [existing]
|
||||||
|
result[name].append(val)
|
||||||
|
else:
|
||||||
|
result[name] = val
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _text(parent, path, default=""):
|
||||||
|
if parent is None:
|
||||||
|
return default
|
||||||
|
el = parent.find(path)
|
||||||
|
if el is None or el.text is None:
|
||||||
|
return default
|
||||||
|
return el.text.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def parse(path: str) -> dict:
|
||||||
|
tree = ET.parse(path)
|
||||||
|
root = tree.getroot()
|
||||||
|
rep = root.find(".//report/blancco_data") or root
|
||||||
|
|
||||||
|
out = {
|
||||||
|
"meta": {},
|
||||||
|
"company": {},
|
||||||
|
"license_consumption_ids": [],
|
||||||
|
"erasures": [],
|
||||||
|
"hardware": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
desc = rep.find("description")
|
||||||
|
if desc is not None:
|
||||||
|
out["meta"]["document_id"] = _text(desc, "document_id")
|
||||||
|
log_entry = desc.find(".//log_entry")
|
||||||
|
if log_entry is not None:
|
||||||
|
author = log_entry.find("author")
|
||||||
|
if author is not None:
|
||||||
|
out["meta"]["product_name"] = _text(author, "product_name")
|
||||||
|
out["meta"]["product_version"] = _text(author, "product_version")
|
||||||
|
out["meta"]["product_revision"] = _text(author, "product_revision")
|
||||||
|
out["meta"]["date"] = _text(log_entry, "date")
|
||||||
|
out["meta"]["integrity"] = _text(log_entry, "integrity")
|
||||||
|
|
||||||
|
company = desc.find(".//entry[@name='description_entries']/entries[@name='company_information']")
|
||||||
|
if company is not None:
|
||||||
|
for e in company.findall("entry"):
|
||||||
|
k = e.attrib.get("name", "")
|
||||||
|
if k:
|
||||||
|
out["company"][k] = (e.text or "").strip()
|
||||||
|
|
||||||
|
lcids = desc.find(".//entry[@name='description_entries']/entries[@name='license_consumption_ids']")
|
||||||
|
if lcids is not None:
|
||||||
|
for e in lcids.findall("entry"):
|
||||||
|
v = (e.text or "").strip()
|
||||||
|
if v:
|
||||||
|
out["license_consumption_ids"].append(v)
|
||||||
|
|
||||||
|
for er in rep.findall(".//blancco_erasure_report//entries[@name='erasure']"):
|
||||||
|
out["erasures"].append(_walk(er))
|
||||||
|
|
||||||
|
hw = rep.find("blancco_erasure_report/../blancco_hardware_report") or rep.find(".//blancco_hardware_report")
|
||||||
|
if hw is not None:
|
||||||
|
for sub in hw:
|
||||||
|
if sub.tag != "entries":
|
||||||
|
continue
|
||||||
|
out["hardware"][sub.attrib.get("name", "")] = _walk(sub)
|
||||||
|
|
||||||
|
return out
|
||||||
215
webapp/templates/report_view.html
Normal file
215
webapp/templates/report_view.html
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ filename }} - Blancco Report{% endblock %}
|
||||||
|
|
||||||
|
{% set meta = data.meta or {} %}
|
||||||
|
{% set company = data.company or {} %}
|
||||||
|
{% set hw = data.hardware or {} %}
|
||||||
|
{% set sysinfo = hw.system or {} %}
|
||||||
|
{% set bios = hw.bios or {} %}
|
||||||
|
{% set memsum = hw.memory or {} %}
|
||||||
|
|
||||||
|
{# Bytes to human-readable GB / TB string, no decimals at MB scale #}
|
||||||
|
{% macro hbytes(n) -%}
|
||||||
|
{%- set n = (n | int) -%}
|
||||||
|
{%- if n >= 1099511627776 -%}
|
||||||
|
{{ "%.2f"|format(n / 1099511627776) }} TB
|
||||||
|
{%- elif n >= 1073741824 -%}
|
||||||
|
{{ "%.2f"|format(n / 1073741824) }} GB
|
||||||
|
{%- elif n >= 1048576 -%}
|
||||||
|
{{ "%.1f"|format(n / 1048576) }} MB
|
||||||
|
{%- else -%}
|
||||||
|
{{ n }} B
|
||||||
|
{%- endif -%}
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between align-items-start mb-3 flex-wrap gap-2">
|
||||||
|
<div>
|
||||||
|
<div class="text-muted small mb-1">
|
||||||
|
<a href="{{ url_for('blancco_reports') }}" class="text-decoration-none">← All reports</a>
|
||||||
|
</div>
|
||||||
|
<h2 class="mb-0">Drive Erasure Certificate</h2>
|
||||||
|
<code class="small text-muted">{{ filename }}</code>
|
||||||
|
</div>
|
||||||
|
<div class="text-end">
|
||||||
|
<a href="{{ url_for('blancco_download_report', filename=filename) }}"
|
||||||
|
class="btn btn-sm btn-outline-primary">Download XML</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Hero summary: state badge + standard + drive count + erasure datetime #}
|
||||||
|
{% set first = (data.erasures or [{}])[0] %}
|
||||||
|
{% set overall_state = first.state or 'Unknown' %}
|
||||||
|
<div class="card border-{{ 'success' if overall_state == 'Successful' else 'danger' if overall_state else 'secondary' }} mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-3 align-items-center">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="text-muted small">Overall result</div>
|
||||||
|
<div class="fs-3">
|
||||||
|
<span class="badge bg-{{ 'success' if overall_state == 'Successful' else 'danger' if overall_state else 'secondary' }}">
|
||||||
|
{{ overall_state }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="text-muted small">Erasure standard</div>
|
||||||
|
<div>{{ first.erasure_standard_name or '-' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<div class="text-muted small">Drives erased</div>
|
||||||
|
<div class="fs-5">{{ data.erasures | length }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<div class="text-muted small">Elapsed time</div>
|
||||||
|
<div class="fs-5"><code>{{ first.elapsed_time or '-' }}</code></div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<div class="text-muted small">Erased at</div>
|
||||||
|
<div><code class="small">{{ first.timestamp or meta.date or '-' }}</code></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-header">Customer</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<dl class="row mb-0 small">
|
||||||
|
<dt class="col-sm-5">Business name</dt><dd class="col-sm-7">{{ company.business_name or '-' }}</dd>
|
||||||
|
<dt class="col-sm-5">Location</dt> <dd class="col-sm-7">{{ company.business_location or '-' }}</dd>
|
||||||
|
<dt class="col-sm-5">License holder</dt><dd class="col-sm-7">{{ company.customer_license or '-' }}</dd>
|
||||||
|
{% for k, v in company.items() %}
|
||||||
|
{% if k not in ['business_name','business_location','customer_license'] %}
|
||||||
|
<dt class="col-sm-5">{{ k|replace('_',' ')|title }}</dt><dd class="col-sm-7">{{ v }}</dd>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-header">System</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<dl class="row mb-0 small">
|
||||||
|
<dt class="col-sm-5">Manufacturer</dt> <dd class="col-sm-7">{{ sysinfo.manufacturer or '-' }}</dd>
|
||||||
|
<dt class="col-sm-5">Model</dt> <dd class="col-sm-7">{{ sysinfo.model or '-' }}</dd>
|
||||||
|
<dt class="col-sm-5">Serial</dt> <dd class="col-sm-7"><code>{{ sysinfo.serial or '-' }}</code></dd>
|
||||||
|
<dt class="col-sm-5">Chassis</dt> <dd class="col-sm-7">{{ sysinfo.chassis_type or '-' }}</dd>
|
||||||
|
<dt class="col-sm-5">BIOS mode</dt> <dd class="col-sm-7">{{ sysinfo.bios_mode or '-' }} {% if sysinfo.secure_boot_state %}<span class="badge bg-secondary">SecureBoot {{ sysinfo.secure_boot_state }}</span>{% endif %}</dd>
|
||||||
|
<dt class="col-sm-5">BIOS vendor</dt> <dd class="col-sm-7">{{ bios.vendor or '-' }} {{ bios.version or '' }}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Per-drive cards: target details + step-by-step timeline #}
|
||||||
|
{% for er in data.erasures %}
|
||||||
|
{% set t = er.target or {} %}
|
||||||
|
{% set state = er.state or 'Unknown' %}
|
||||||
|
{% set border = 'success' if state == 'Successful' else 'danger' if state else 'secondary' %}
|
||||||
|
{% set steps = (er.steps or {}).step %}
|
||||||
|
{% if steps is not iterable or steps is string or steps is mapping %}
|
||||||
|
{% set steps = [steps] if steps else [] %}
|
||||||
|
{% endif %}
|
||||||
|
<div class="card border-{{ border }} mb-3">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<strong>Drive #{{ er.erasure_id or loop.index }}:</strong>
|
||||||
|
{{ t.vendor or '' }} {{ t.model or '(unknown drive)' }}
|
||||||
|
<code class="ms-2 small text-muted">{{ t.serial or '-' }}</code>
|
||||||
|
</div>
|
||||||
|
<span class="badge bg-{{ border }}">{{ state }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="text-muted small">Interface</div>
|
||||||
|
<div>{{ t.interface_type or '-' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="text-muted small">Capacity</div>
|
||||||
|
<div>{{ hbytes(t.capacity or 0) }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<div class="text-muted small">Health</div>
|
||||||
|
<div>{{ t.health or '-' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<div class="text-muted small">Errors</div>
|
||||||
|
<div>{{ er.total_errors or 0 }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<div class="text-muted small">Sectors</div>
|
||||||
|
<div class="small">{{ er.processed_sectors or '-' }} / {{ t.sectors or '-' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if steps %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm table-hover align-middle mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th style="width:3rem;">#</th>
|
||||||
|
<th>Step</th>
|
||||||
|
<th>Pattern</th>
|
||||||
|
<th>State</th>
|
||||||
|
<th>Elapsed</th>
|
||||||
|
<th>Errors</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for s in steps %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ s.number or loop.index }}</td>
|
||||||
|
<td>{{ s.type or '-' }}</td>
|
||||||
|
<td class="small text-muted">{{ s.pattern or '-' }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-{{ 'success' if s.state == 'completed' else 'danger' if s.state else 'secondary' }}">
|
||||||
|
{{ s.state or '-' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td><code class="small">{{ s.elapsed_time or '-' }}</code></td>
|
||||||
|
<td>{{ s.errors or 0 }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header">Document signing & provenance</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<dl class="row mb-0 small">
|
||||||
|
<dt class="col-sm-3">Document ID</dt>
|
||||||
|
<dd class="col-sm-9"><code class="text-break">{{ meta.document_id or '-' }}</code></dd>
|
||||||
|
<dt class="col-sm-3">Product</dt>
|
||||||
|
<dd class="col-sm-9">{{ meta.product_name }} {{ meta.product_version }} (rev {{ meta.product_revision }})</dd>
|
||||||
|
<dt class="col-sm-3">Generated</dt>
|
||||||
|
<dd class="col-sm-9"><code>{{ meta.date or '-' }}</code></dd>
|
||||||
|
{% if data.license_consumption_ids %}
|
||||||
|
<dt class="col-sm-3">License consumption ID{{ 's' if data.license_consumption_ids|length > 1 }}</dt>
|
||||||
|
<dd class="col-sm-9">
|
||||||
|
{% for lc in data.license_consumption_ids %}<code class="d-block">{{ lc }}</code>{% endfor %}
|
||||||
|
</dd>
|
||||||
|
{% endif %}
|
||||||
|
<dt class="col-sm-3">Integrity hash</dt>
|
||||||
|
<dd class="col-sm-9">
|
||||||
|
<details>
|
||||||
|
<summary class="text-muted">Show base64 signature ({{ meta.integrity|length if meta.integrity else 0 }} chars)</summary>
|
||||||
|
<code class="text-break small d-block mt-2">{{ meta.integrity }}</code>
|
||||||
|
</details>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -37,6 +37,12 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>{{ r.modified | timestamp_fmt }}</td>
|
<td>{{ r.modified | timestamp_fmt }}</td>
|
||||||
<td class="text-end text-nowrap">
|
<td class="text-end text-nowrap">
|
||||||
|
{% if r.filename.lower().endswith('.xml') %}
|
||||||
|
<a href="{{ url_for('blancco_view_report', filename=r.filename) }}"
|
||||||
|
class="btn btn-sm btn-outline-success" title="View formatted report">
|
||||||
|
View
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
<a href="{{ url_for('blancco_download_report', filename=r.filename) }}"
|
<a href="{{ url_for('blancco_download_report', filename=r.filename) }}"
|
||||||
class="btn btn-sm btn-outline-primary" title="Download">
|
class="btn btn-sm btn-outline-primary" title="Download">
|
||||||
Download
|
Download
|
||||||
|
|||||||
Reference in New Issue
Block a user