blancco: CMMC evidence pack, ServiceNow tick list, cloud-upload status
Automates the Blancco parts of the onsite-support CMMC playbook:
- ENT-056/158 evidence pack: /reports/evidence?month=YYYY-MM returns
ENT-056_<Site>_<YYYY-MM>.zip (summary.csv, README, each report's XML
and PDF). pxe-blancco-evidence.timer saves last month's pack to the
reports share's evidence/ folder on the 1st (Persistent).
- ENT-045/159 ServiceNow tick list: /reports/servicenow lists recent
reports as "Wiped" or "Blancco Wipe failed" with a pre-written
destruction note, copy buttons and Done (CSRF-protected).
- Cloud upload: blancco-progress.py posts each report's upload result
("Sending report document id" -> "Report sent to MC") to
/reports/cloud-status (CSRF-exempt like /imaging/status); the Reports
page and the CSVs show it, so a wipe missing from BMP is visible.
- CSV gains failure_reason (exception messages, failed steps, error
counts), business_location and cloud_upload.
- Preferences: business_location "West Jefferson" (config.BLANCCO_SITE)
and pdf+xml reports; the report copier now copies the PDF too, named
after its XML. .part and hidden files are not listed as reports.
State (cloud confirmations, ServiceNow ticks) is a JSON file at
/var/lib/pxe-webapp/blancco-state.json, off the report share.
This commit is contained in:
@@ -291,33 +291,39 @@ cat > /run/newroot/usr/local/sbin/blancco-report-sync << 'XEOF'
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Copy Blancco reports to the SMB report share.
|
# Copy Blancco reports to the SMB report share.
|
||||||
#
|
#
|
||||||
# In process=auto Albus saves each report to /reports/<document_id>.xml and
|
# In process=auto Albus saves each report to /reports/<document_id>.xml
|
||||||
# uploads it to the cloud, but never writes the share: auto_backup is
|
# (and .pdf when pdf+xml is selected) and uploads it to the cloud, but never
|
||||||
# forced off outside workflow/manual mode. Albus has the share mounted at
|
# writes the share: auto_backup is forced off outside workflow/manual mode.
|
||||||
# /mnt/share already, so copy each finished report there, named the way a
|
# Albus has the share mounted at /mnt/share already, so copy each finished
|
||||||
# manual Save names it (UTC YYYYMMDD_HHMMSS_report.xml).
|
# file there, named the way a manual Save names it (UTC
|
||||||
|
# YYYYMMDD_HHMMSS_report.<ext>, the time taken from the XML so the PDF of
|
||||||
|
# the same report gets the same name).
|
||||||
# Usage: blancco-report-sync [--once]
|
# Usage: blancco-report-sync [--once]
|
||||||
DONE=/var/lib/blancco-report-sync
|
DONE=/var/lib/blancco-report-sync
|
||||||
mkdir -p "$DONE"
|
mkdir -p "$DONE"
|
||||||
while :; do
|
while :; do
|
||||||
for f in /reports/*.xml; do
|
for f in /reports/*.xml /reports/*.pdf; do
|
||||||
[ -s "$f" ] || continue
|
[ -s "$f" ] || continue
|
||||||
id=$(basename "$f" .xml)
|
base=$(basename "$f")
|
||||||
[ -e "$DONE/$id" ] && continue
|
[ -e "$DONE/$base" ] && continue
|
||||||
# Leave files Albus may still be writing.
|
# Leave files Albus may still be writing.
|
||||||
[ $(( $(date +%s) - $(stat -c %Y "$f") )) -ge 5 ] || continue
|
[ $(( $(date +%s) - $(stat -c %Y "$f") )) -ge 5 ] || continue
|
||||||
mountpoint -q /mnt/share || continue
|
mountpoint -q /mnt/share || continue
|
||||||
name="$(date -u -r "$f" +%Y%m%d_%H%M%S)_report.xml"
|
id=${base%.*}
|
||||||
|
ext=${base##*.}
|
||||||
|
ref="/reports/$id.xml"
|
||||||
|
[ -f "$ref" ] || ref="$f"
|
||||||
|
name="$(date -u -r "$ref" +%Y%m%d_%H%M%S)_report.$ext"
|
||||||
if cp "$f" "/mnt/share/$name.part" && mv -f "/mnt/share/$name.part" "/mnt/share/$name"; then
|
if cp "$f" "/mnt/share/$name.part" && mv -f "/mnt/share/$name.part" "/mnt/share/$name"; then
|
||||||
touch "$DONE/$id"
|
touch "$DONE/$base"
|
||||||
echo "copied $id to $name"
|
echo "copied $base to $name"
|
||||||
serial=$(tr -d '"\\' < /sys/class/dmi/id/product_serial 2>/dev/null)
|
serial=$(tr -d '"\\' 2>/dev/null < /sys/class/dmi/id/product_serial)
|
||||||
[ -n "$serial" ] && curl -s -m 5 -o /dev/null -H 'Content-Type: application/json' \
|
[ -n "$serial" ] && curl -s -m 5 -o /dev/null -H 'Content-Type: application/json' \
|
||||||
-d "{\"serial\":\"$serial\",\"log_lines\":[\"Report copied to share: $name\"]}" \
|
-d "{\"serial\":\"$serial\",\"log_lines\":[\"Report copied to share: $name\"]}" \
|
||||||
http://172.16.9.1:9009/imaging/status || true
|
http://172.16.9.1:9009/imaging/status || true
|
||||||
else
|
else
|
||||||
rm -f "/mnt/share/$name.part"
|
rm -f "/mnt/share/$name.part"
|
||||||
echo "copy of $id failed, will retry"
|
echo "copy of $base failed, will retry"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
[ "$1" = "--once" ] && exit 0
|
[ "$1" = "--once" ] && exit 0
|
||||||
|
|||||||
@@ -252,7 +252,7 @@
|
|||||||
<creation_date>2026-06-22</creation_date>
|
<creation_date>2026-06-22</creation_date>
|
||||||
</image>
|
</image>
|
||||||
<customer_data>
|
<customer_data>
|
||||||
<business_location></business_location>
|
<business_location>West Jefferson</business_location>
|
||||||
<customer_license>GE Aerospace</customer_license>
|
<customer_license>GE Aerospace</customer_license>
|
||||||
<erasure_person></erasure_person>
|
<erasure_person></erasure_person>
|
||||||
<erasure_provider></erasure_provider>
|
<erasure_provider></erasure_provider>
|
||||||
@@ -312,8 +312,8 @@
|
|||||||
<per_drive>false</per_drive>
|
<per_drive>false</per_drive>
|
||||||
<auto_backup>true</auto_backup>
|
<auto_backup>true</auto_backup>
|
||||||
<extensions type="array" key="extension">
|
<extensions type="array" key="extension">
|
||||||
<extension selected="true">xml</extension>
|
<extension>xml</extension>
|
||||||
<extension>pdf+xml</extension>
|
<extension selected="true">pdf+xml</extension>
|
||||||
</extensions>
|
</extensions>
|
||||||
<utc_offset>0</utc_offset>
|
<utc_offset>0</utc_offset>
|
||||||
<network_info>true</network_info>
|
<network_info>true</network_info>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import time
|
|||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
STATUS_URL = "http://172.16.9.1:9009/imaging/status"
|
STATUS_URL = "http://172.16.9.1:9009/imaging/status"
|
||||||
|
CLOUD_URL = "http://172.16.9.1:9009/reports/cloud-status"
|
||||||
STAGE_TOTAL = 7
|
STAGE_TOTAL = 7
|
||||||
# Progress lines arrive in bursts; post at most this often unless the
|
# Progress lines arrive in bursts; post at most this often unless the
|
||||||
# stage itself changes.
|
# stage itself changes.
|
||||||
@@ -69,6 +70,11 @@ RULES = [
|
|||||||
(re.compile(r"Report sent to MC"), 6, "Report sent to cloud", None),
|
(re.compile(r"Report sent to MC"), 6, "Report sent to cloud", None),
|
||||||
(re.compile(r"Auto process reached the final step"), 7, "Done", "succeeded"),
|
(re.compile(r"Auto process reached the final step"), 7, "Done", "succeeded"),
|
||||||
]
|
]
|
||||||
|
# Cloud upload of a report: "[comm.manager] Sending report document id: <id>"
|
||||||
|
# then "Report sent to MC" (or an upload failure).
|
||||||
|
SENDING = re.compile(r"Sending report document id: ([0-9a-fA-F-]{36})")
|
||||||
|
SENT = re.compile(r"Report sent to MC")
|
||||||
|
SEND_FAILED = re.compile(r"(report (upload|send|sending)[^\n]*fail|failed to (send|upload) report)", re.I)
|
||||||
# Drive progress, e.g. "FORMAT NVM progress: 75%". Excludes the self-test
|
# Drive progress, e.g. "FORMAT NVM progress: 75%". Excludes the self-test
|
||||||
# completion and volume lines that also carry a percentage.
|
# completion and volume lines that also carry a percentage.
|
||||||
PROGRESS = re.compile(r"\bprogress[^%\d]{0,20}(\d{1,3})%", re.I)
|
PROGRESS = re.compile(r"\bprogress[^%\d]{0,20}(\d{1,3})%", re.I)
|
||||||
@@ -115,10 +121,35 @@ class Reporter:
|
|||||||
self.pending = []
|
self.pending = []
|
||||||
self.last_post = now
|
self.last_post = now
|
||||||
|
|
||||||
|
def cloud(self, document_id, sent, detail):
|
||||||
|
"""Tell the webapp whether a report reached the Blancco cloud."""
|
||||||
|
payload = {"document_id": document_id, "serial": SERIAL,
|
||||||
|
"sent": sent, "detail": detail[:200]}
|
||||||
|
if self.dry_run:
|
||||||
|
print(json.dumps({"cloud": payload}))
|
||||||
|
return
|
||||||
|
req = urllib.request.Request(
|
||||||
|
CLOUD_URL, data=json.dumps(payload).encode(),
|
||||||
|
headers={"Content-Type": "application/json"})
|
||||||
|
try:
|
||||||
|
urllib.request.urlopen(req, timeout=5).read()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
def line(self, text):
|
def line(self, text):
|
||||||
text = text.strip()
|
text = text.strip()
|
||||||
if not text:
|
if not text:
|
||||||
return
|
return
|
||||||
|
m = SENDING.search(text)
|
||||||
|
if m:
|
||||||
|
self.sending = m.group(1)
|
||||||
|
elif getattr(self, "sending", None):
|
||||||
|
if SENT.search(text):
|
||||||
|
self.cloud(self.sending, True, text)
|
||||||
|
self.sending = None
|
||||||
|
elif SEND_FAILED.search(text):
|
||||||
|
self.cloud(self.sending, False, text)
|
||||||
|
self.sending = None
|
||||||
for pat, idx, stage, status in RULES:
|
for pat, idx, stage, status in RULES:
|
||||||
m = pat.search(text)
|
m = pat.search(text)
|
||||||
if not m:
|
if not m:
|
||||||
|
|||||||
@@ -1148,6 +1148,44 @@
|
|||||||
enabled: yes
|
enabled: yes
|
||||||
daemon_reload: yes
|
daemon_reload: yes
|
||||||
|
|
||||||
|
# Monthly Blancco CMMC evidence pack (ENT-056/158): on the 1st, save last
|
||||||
|
# month's ZIP to the reports share's evidence/ folder for the tech to copy
|
||||||
|
# into Box. Persistent catches a run missed while the server was off.
|
||||||
|
- name: "Create Blancco evidence pack service and timer"
|
||||||
|
copy:
|
||||||
|
dest: "/etc/systemd/system/{{ item.name }}"
|
||||||
|
content: "{{ item.content }}"
|
||||||
|
loop:
|
||||||
|
- name: pxe-blancco-evidence.service
|
||||||
|
content: |
|
||||||
|
[Unit]
|
||||||
|
Description=Save last month's Blancco CMMC evidence pack to the reports share
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
WorkingDirectory=/opt/pxe-webapp
|
||||||
|
Environment=BLANCCO_REPORTS=/srv/samba/blancco-reports
|
||||||
|
Environment=TZ=America/New_York
|
||||||
|
ExecStart=/usr/bin/python3 /opt/pxe-webapp/blancco_evidence_monthly.py
|
||||||
|
- name: pxe-blancco-evidence.timer
|
||||||
|
content: |
|
||||||
|
[Unit]
|
||||||
|
Description=Monthly Blancco CMMC evidence pack
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-01 06:00:00
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
|
|
||||||
|
- name: "Enable Blancco evidence pack timer"
|
||||||
|
systemd:
|
||||||
|
name: pxe-blancco-evidence.timer
|
||||||
|
state: started
|
||||||
|
enabled: yes
|
||||||
|
daemon_reload: yes
|
||||||
|
|
||||||
- name: "Configure unified Apache site (static files + webapp proxy)"
|
- name: "Configure unified Apache site (static files + webapp proxy)"
|
||||||
copy:
|
copy:
|
||||||
dest: /etc/apache2/sites-available/pxe-server.conf
|
dest: /etc/apache2/sites-available/pxe-server.conf
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ from werkzeug.utils import secure_filename
|
|||||||
|
|
||||||
import config
|
import config
|
||||||
from services import (
|
from services import (
|
||||||
|
blancco_evidence,
|
||||||
blancco_report,
|
blancco_report,
|
||||||
deploy,
|
deploy,
|
||||||
fs,
|
fs,
|
||||||
@@ -541,10 +542,12 @@ def clonezilla_delete(filename):
|
|||||||
@app.route("/reports")
|
@app.route("/reports")
|
||||||
def blancco_reports():
|
def blancco_reports():
|
||||||
reports = []
|
reports = []
|
||||||
|
state = blancco_evidence.load_state()
|
||||||
if os.path.isdir(config.BLANCCO_REPORTS):
|
if os.path.isdir(config.BLANCCO_REPORTS):
|
||||||
for f in sorted(os.listdir(config.BLANCCO_REPORTS), reverse=True):
|
for f in sorted(os.listdir(config.BLANCCO_REPORTS), reverse=True):
|
||||||
fpath = os.path.join(config.BLANCCO_REPORTS, f)
|
fpath = os.path.join(config.BLANCCO_REPORTS, f)
|
||||||
if not os.path.isfile(fpath):
|
# .part files are reports still being copied in by Blancco.
|
||||||
|
if not os.path.isfile(fpath) or f.startswith(".") or f.endswith(".part"):
|
||||||
continue
|
continue
|
||||||
stat = os.stat(fpath)
|
stat = os.stat(fpath)
|
||||||
ext = os.path.splitext(f)[1].lower()
|
ext = os.path.splitext(f)[1].lower()
|
||||||
@@ -554,7 +557,8 @@ def blancco_reports():
|
|||||||
# negligible at fleet sizes).
|
# negligible at fleet sizes).
|
||||||
serial = ""
|
serial = ""
|
||||||
model = ""
|
model = ""
|
||||||
state = ""
|
result = ""
|
||||||
|
cloud = ""
|
||||||
if ext == ".xml":
|
if ext == ".xml":
|
||||||
try:
|
try:
|
||||||
data = blancco_report.parse(fpath)
|
data = blancco_report.parse(fpath)
|
||||||
@@ -567,11 +571,13 @@ def blancco_reports():
|
|||||||
erasures = data.get("erasures") or []
|
erasures = data.get("erasures") or []
|
||||||
states = [(e.get("state") or "").strip() for e in erasures if e.get("state")]
|
states = [(e.get("state") or "").strip() for e in erasures if e.get("state")]
|
||||||
if not states:
|
if not states:
|
||||||
state = ""
|
result = ""
|
||||||
elif any(s.lower() != "successful" for s in states):
|
elif any(s.lower() != "successful" for s in states):
|
||||||
state = "Failed"
|
result = "Failed"
|
||||||
else:
|
else:
|
||||||
state = "Successful"
|
result = "Successful"
|
||||||
|
cloud = blancco_evidence.cloud_status(
|
||||||
|
state, (data.get("meta") or {}).get("document_id", ""))
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
reports.append({
|
reports.append({
|
||||||
@@ -581,11 +587,14 @@ def blancco_reports():
|
|||||||
"type": ext.lstrip(".").upper() or "FILE",
|
"type": ext.lstrip(".").upper() or "FILE",
|
||||||
"serial": serial,
|
"serial": serial,
|
||||||
"model": model,
|
"model": model,
|
||||||
"state": state,
|
"state": result,
|
||||||
|
"cloud": cloud,
|
||||||
})
|
})
|
||||||
return render_template(
|
return render_template(
|
||||||
"reports.html",
|
"reports.html",
|
||||||
reports=reports,
|
reports=reports,
|
||||||
|
site=config.BLANCCO_SITE,
|
||||||
|
last_month=blancco_evidence.previous_month(),
|
||||||
image_types=config.IMAGE_TYPES,
|
image_types=config.IMAGE_TYPES,
|
||||||
friendly_names=config.FRIENDLY_NAMES,
|
friendly_names=config.FRIENDLY_NAMES,
|
||||||
)
|
)
|
||||||
@@ -620,6 +629,7 @@ def blancco_export_reports():
|
|||||||
# when a report has no parseable date. PDFs (7.20 writes pdf+xml) ride
|
# when a report has no parseable date. PDFs (7.20 writes pdf+xml) ride
|
||||||
# along with the XML of the same name.
|
# along with the XML of the same name.
|
||||||
rows, files = [], []
|
rows, files = [], []
|
||||||
|
cloud_state = blancco_evidence.load_state()
|
||||||
folder = config.BLANCCO_REPORTS
|
folder = config.BLANCCO_REPORTS
|
||||||
names = sorted(os.listdir(folder)) if os.path.isdir(folder) else []
|
names = sorted(os.listdir(folder)) if os.path.isdir(folder) else []
|
||||||
for f in names:
|
for f in names:
|
||||||
@@ -634,7 +644,9 @@ def blancco_export_reports():
|
|||||||
os.stat(fpath).st_mtime, timezone.utc)
|
os.stat(fpath).st_mtime, timezone.utc)
|
||||||
if when < since:
|
if when < since:
|
||||||
continue
|
continue
|
||||||
rows.extend(blancco_report.export_rows(f, data, when.isoformat()))
|
rows.extend(blancco_report.export_rows(
|
||||||
|
f, data, when.isoformat(),
|
||||||
|
blancco_evidence.cloud_status(cloud_state, (data.get("meta") or {}).get("document_id", ""))))
|
||||||
files.append(f)
|
files.append(f)
|
||||||
pdf = os.path.splitext(f)[0] + ".pdf"
|
pdf = os.path.splitext(f)[0] + ".pdf"
|
||||||
if pdf in names:
|
if pdf in names:
|
||||||
@@ -664,6 +676,54 @@ def blancco_export_reports():
|
|||||||
download_name=f"{stem}.zip")
|
download_name=f"{stem}.zip")
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/reports/cloud-status", methods=["POST"])
|
||||||
|
def blancco_cloud_status():
|
||||||
|
# CSRF-exempt machine-to-machine endpoint: blancco-progress.py, running
|
||||||
|
# inside Blancco, reports whether each report reached the Blancco cloud.
|
||||||
|
p = request.get_json(silent=True) or {}
|
||||||
|
try:
|
||||||
|
blancco_evidence.record_cloud(
|
||||||
|
p.get("document_id", ""), bool(p.get("sent")), p.get("serial", ""), p.get("detail", ""))
|
||||||
|
except ValueError as ex:
|
||||||
|
return jsonify({"error": str(ex)}), 400
|
||||||
|
return jsonify({"ok": True}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/reports/evidence")
|
||||||
|
def blancco_evidence_pack():
|
||||||
|
"""CMMC evidence pack (ENT-056/158): one ZIP per site and month."""
|
||||||
|
month = request.args.get("month") or blancco_evidence.previous_month()
|
||||||
|
if not re.fullmatch(r"20\d\d-(0[1-9]|1[0-2])", month):
|
||||||
|
abort(400)
|
||||||
|
out = tempfile.SpooledTemporaryFile(max_size=64 * 1024 * 1024)
|
||||||
|
count = blancco_evidence.build_evidence_zip(month, out)
|
||||||
|
out.seek(0)
|
||||||
|
audit("REPORT_EVIDENCE", f"{month} {count} reports")
|
||||||
|
return send_file(out, mimetype="application/zip", as_attachment=True,
|
||||||
|
download_name=blancco_evidence.evidence_name(month) + ".zip")
|
||||||
|
|
||||||
|
|
||||||
|
@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)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/reports/servicenow/<document_id>", methods=["POST"])
|
||||||
|
def blancco_servicenow_mark(document_id):
|
||||||
|
done = request.form.get("done", "1") == "1"
|
||||||
|
try:
|
||||||
|
blancco_evidence.mark_servicenow(document_id, done)
|
||||||
|
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")))
|
||||||
|
|
||||||
|
|
||||||
@app.route("/reports/download/<filename>")
|
@app.route("/reports/download/<filename>")
|
||||||
def blancco_download_report(filename):
|
def blancco_download_report(filename):
|
||||||
filename = secure_filename(filename)
|
filename = secure_filename(filename)
|
||||||
|
|||||||
36
webapp/blancco_evidence_monthly.py
Normal file
36
webapp/blancco_evidence_monthly.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Save the monthly Blancco CMMC evidence pack to the report share.
|
||||||
|
|
||||||
|
Run by pxe-blancco-evidence.timer on the 1st of each month: writes
|
||||||
|
<BLANCCO_REPORTS>/evidence/ENT-056_<Site>_<YYYY-MM>.zip for the previous
|
||||||
|
month (or --month YYYY-MM), so the tech only has to copy it into the
|
||||||
|
site's Box evidence folder. Same pack as the webapp's /reports/evidence.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
import config # noqa: E402
|
||||||
|
from services import blancco_evidence # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||||
|
ap.add_argument("--month", default=blancco_evidence.previous_month(),
|
||||||
|
help="YYYY-MM, default: previous month")
|
||||||
|
args = ap.parse_args()
|
||||||
|
blancco_evidence.month_bounds(args.month) # validates the format
|
||||||
|
out_dir = os.path.join(config.BLANCCO_REPORTS, "evidence")
|
||||||
|
os.makedirs(out_dir, exist_ok=True)
|
||||||
|
final = os.path.join(out_dir, blancco_evidence.evidence_name(args.month) + ".zip")
|
||||||
|
tmp = final + ".part"
|
||||||
|
with open(tmp, "wb") as f:
|
||||||
|
count = blancco_evidence.build_evidence_zip(args.month, f)
|
||||||
|
os.replace(tmp, final)
|
||||||
|
print(f"{final}: {count} report(s)")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -21,6 +21,11 @@ WEB_ROOT = os.environ.get("WEB_ROOT", "/var/www/html")
|
|||||||
BOOT_WIM = os.path.join(WEB_ROOT, "win11", "sources", "boot.wim")
|
BOOT_WIM = os.path.join(WEB_ROOT, "win11", "sources", "boot.wim")
|
||||||
AUDIT_LOG = os.environ.get("AUDIT_LOG", "/var/log/pxe-webapp-audit.log")
|
AUDIT_LOG = os.environ.get("AUDIT_LOG", "/var/log/pxe-webapp-audit.log")
|
||||||
IMAGING_DIR = os.environ.get("IMAGING_DIR", "/var/log/pxe-imaging")
|
IMAGING_DIR = os.environ.get("IMAGING_DIR", "/var/log/pxe-imaging")
|
||||||
|
# Blancco evidence: site name stamped on reports and evidence packs, and a
|
||||||
|
# small JSON state file (cloud-upload confirmations, ServiceNow ticks) kept
|
||||||
|
# off the report share so it never shows up as a report.
|
||||||
|
BLANCCO_SITE = os.environ.get("BLANCCO_SITE", "West Jefferson")
|
||||||
|
BLANCCO_STATE = os.environ.get("BLANCCO_STATE", "/var/lib/pxe-webapp/blancco-state.json")
|
||||||
|
|
||||||
# Log sources used by services/imaging_log_tail.py to infer client progress
|
# Log sources used by services/imaging_log_tail.py to infer client progress
|
||||||
# when no /imaging/status push has arrived yet. Override per-host if any of
|
# when no /imaging/status push has arrived yet. Override per-host if any of
|
||||||
|
|||||||
215
webapp/services/blancco_evidence.py
Normal file
215
webapp/services/blancco_evidence.py
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
"""CMMC evidence helpers for Blancco erasure reports.
|
||||||
|
|
||||||
|
The onsite-support playbook asks techs for, among others:
|
||||||
|
ENT-045 / 159 list of sanitized media + failure cause (ServiceNow
|
||||||
|
Onsite Bin RITM "Wiped" / "Blancco Wipe failed" boxes)
|
||||||
|
ENT-056 / 158 sample Blancco erasure certificates per site, monthly
|
||||||
|
|
||||||
|
This module backs the webapp's evidence pack (one ZIP per site and month:
|
||||||
|
summary.csv plus each report's XML and PDF), the ServiceNow tick list,
|
||||||
|
and the record of which reports reached the Blancco cloud (posted by
|
||||||
|
blancco-progress.py running inside Blancco).
|
||||||
|
|
||||||
|
State lives in one small JSON file (config.BLANCCO_STATE), not on the report
|
||||||
|
share, written atomically:
|
||||||
|
{"cloud": {document_id: {"sent": bool, "ts", "serial", "detail"}},
|
||||||
|
"servicenow": {document_id: {"ts"}}}
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import tempfile
|
||||||
|
import zipfile
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import config
|
||||||
|
from services import blancco_report
|
||||||
|
|
||||||
|
_DOC_ID = re.compile(r"^[0-9a-fA-F-]{36}$")
|
||||||
|
|
||||||
|
|
||||||
|
# --- state ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def load_state() -> dict:
|
||||||
|
try:
|
||||||
|
with open(config.BLANCCO_STATE) as f:
|
||||||
|
state = json.load(f)
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
state = {}
|
||||||
|
state.setdefault("cloud", {})
|
||||||
|
state.setdefault("servicenow", {})
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
def _save_state(state: dict):
|
||||||
|
folder = os.path.dirname(config.BLANCCO_STATE)
|
||||||
|
os.makedirs(folder, exist_ok=True)
|
||||||
|
fd, tmp = tempfile.mkstemp(dir=folder, prefix=".blancco-state-")
|
||||||
|
with os.fdopen(fd, "w") as f:
|
||||||
|
json.dump(state, f, indent=1, sort_keys=True)
|
||||||
|
os.replace(tmp, config.BLANCCO_STATE)
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||||
|
|
||||||
|
|
||||||
|
def record_cloud(document_id: str, sent: bool, serial: str = "", detail: str = ""):
|
||||||
|
if not _DOC_ID.match(document_id or ""):
|
||||||
|
raise ValueError("bad document_id")
|
||||||
|
state = load_state()
|
||||||
|
state["cloud"][document_id] = {
|
||||||
|
"sent": bool(sent), "ts": _now(),
|
||||||
|
"serial": (serial or "")[:64], "detail": (detail or "")[:200],
|
||||||
|
}
|
||||||
|
_save_state(state)
|
||||||
|
|
||||||
|
|
||||||
|
def mark_servicenow(document_id: str, done: bool = True):
|
||||||
|
if not _DOC_ID.match(document_id or ""):
|
||||||
|
raise ValueError("bad document_id")
|
||||||
|
state = load_state()
|
||||||
|
if done:
|
||||||
|
state["servicenow"][document_id] = {"ts": _now()}
|
||||||
|
else:
|
||||||
|
state["servicenow"].pop(document_id, None)
|
||||||
|
_save_state(state)
|
||||||
|
|
||||||
|
|
||||||
|
def cloud_status(state: dict, document_id: str) -> str:
|
||||||
|
""""sent", "failed", or "" when Blancco never told us."""
|
||||||
|
rec = state["cloud"].get(document_id)
|
||||||
|
if not rec:
|
||||||
|
return ""
|
||||||
|
return "sent" if rec.get("sent") else "failed"
|
||||||
|
|
||||||
|
|
||||||
|
# --- reports ----------------------------------------------------------------
|
||||||
|
|
||||||
|
def site_slug(site: str | None = None) -> str:
|
||||||
|
return re.sub(r"[^A-Za-z0-9]+", "", site or config.BLANCCO_SITE) or "Site"
|
||||||
|
|
||||||
|
|
||||||
|
def collect(since=None, until=None) -> list[dict]:
|
||||||
|
"""Every XML report on the share dated in [since, until), oldest first.
|
||||||
|
|
||||||
|
Each entry: {"file", "pdf" (or None), "when", "data", "document_id"}.
|
||||||
|
Dated by the timestamp inside the report, mtime only as a fallback.
|
||||||
|
"""
|
||||||
|
folder = config.BLANCCO_REPORTS
|
||||||
|
names = set(os.listdir(folder)) if os.path.isdir(folder) else set()
|
||||||
|
out = []
|
||||||
|
for f in sorted(names):
|
||||||
|
path = os.path.join(folder, f)
|
||||||
|
if not f.lower().endswith(".xml") or f.startswith(".") or not os.path.isfile(path):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
data = blancco_report.parse(path)
|
||||||
|
except Exception:
|
||||||
|
data = {}
|
||||||
|
when = blancco_report.report_datetime(data) or datetime.fromtimestamp(
|
||||||
|
os.stat(path).st_mtime, timezone.utc)
|
||||||
|
if since and when < since:
|
||||||
|
continue
|
||||||
|
if until and when >= until:
|
||||||
|
continue
|
||||||
|
pdf = os.path.splitext(f)[0] + ".pdf"
|
||||||
|
out.append({
|
||||||
|
"file": f,
|
||||||
|
"pdf": pdf if pdf in names else None,
|
||||||
|
"when": when,
|
||||||
|
"data": data,
|
||||||
|
"document_id": (data.get("meta") or {}).get("document_id", ""),
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def month_bounds(month: str):
|
||||||
|
"""'2026-10' -> (2026-10-01T00:00Z, 2026-11-01T00:00Z)."""
|
||||||
|
y, m = (int(x) for x in month.split("-"))
|
||||||
|
start = datetime(y, m, 1, tzinfo=timezone.utc)
|
||||||
|
end = datetime(y + (m == 12), m % 12 + 1, 1, tzinfo=timezone.utc)
|
||||||
|
return start, end
|
||||||
|
|
||||||
|
|
||||||
|
def previous_month(now=None) -> str:
|
||||||
|
now = now or datetime.now(timezone.utc)
|
||||||
|
y, m = (now.year, now.month - 1) if now.month > 1 else (now.year - 1, 12)
|
||||||
|
return f"{y:04d}-{m:02d}"
|
||||||
|
|
||||||
|
|
||||||
|
def summary_csv(reports: list[dict], state: dict) -> str:
|
||||||
|
buf = io.StringIO()
|
||||||
|
writer = csv.DictWriter(buf, fieldnames=blancco_report.EXPORT_COLUMNS)
|
||||||
|
writer.writeheader()
|
||||||
|
for r in reports:
|
||||||
|
writer.writerows(blancco_report.export_rows(
|
||||||
|
r["file"], r["data"], r["when"].isoformat(),
|
||||||
|
cloud_status(state, r["document_id"])))
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def evidence_name(month: str, site: str | None = None) -> str:
|
||||||
|
return f"ENT-056_{site_slug(site)}_{month}"
|
||||||
|
|
||||||
|
|
||||||
|
def build_evidence_zip(month: str, fileobj) -> int:
|
||||||
|
"""Write the evidence pack for `month` into fileobj; return report count."""
|
||||||
|
start, end = month_bounds(month)
|
||||||
|
reports = collect(start, end)
|
||||||
|
state = load_state()
|
||||||
|
stem = evidence_name(month)
|
||||||
|
with zipfile.ZipFile(fileobj, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
zf.writestr(f"{stem}/summary.csv", summary_csv(reports, state))
|
||||||
|
zf.writestr(f"{stem}/README.txt", (
|
||||||
|
f"Blancco erasure evidence, {config.BLANCCO_SITE}, {month} (UTC)\n"
|
||||||
|
f"{len(reports)} report(s). summary.csv has one row per erased drive,\n"
|
||||||
|
"with failure_reason for failed wipes and cloud_upload as recorded by\n"
|
||||||
|
"the PXE server. Certificates of record are in the Blancco Management\n"
|
||||||
|
"Portal; these are the site copies.\n"))
|
||||||
|
for r in reports:
|
||||||
|
zf.write(os.path.join(config.BLANCCO_REPORTS, r["file"]), f"{stem}/{r['file']}")
|
||||||
|
if r["pdf"]:
|
||||||
|
zf.write(os.path.join(config.BLANCCO_REPORTS, r["pdf"]), f"{stem}/{r['pdf']}")
|
||||||
|
return len(reports)
|
||||||
|
|
||||||
|
|
||||||
|
def servicenow_items(days: int = 30, include_done: bool = False) -> list[dict]:
|
||||||
|
"""Reports to reflect on the Onsite Bin Management RITM, newest first."""
|
||||||
|
since = datetime.now(timezone.utc) - timedelta(days=days)
|
||||||
|
state = load_state()
|
||||||
|
items = []
|
||||||
|
for r in reversed(collect(since)):
|
||||||
|
done = state["servicenow"].get(r["document_id"])
|
||||||
|
if done and not include_done:
|
||||||
|
continue
|
||||||
|
data = r["data"]
|
||||||
|
system = (data.get("hardware") or {}).get("system") or {}
|
||||||
|
if isinstance(system, list):
|
||||||
|
system = system[0] if system else {}
|
||||||
|
erasures = data.get("erasures") or []
|
||||||
|
failed = [e for e in erasures
|
||||||
|
if (e.get("state") or "").strip().lower() != "successful"]
|
||||||
|
reasons = [blancco_report.failure_reason(e) for e in failed]
|
||||||
|
drives = ", ".join(
|
||||||
|
f"{(e.get('target') or {}).get('model', '')} {(e.get('target') or {}).get('serial', '')}".strip()
|
||||||
|
for e in failed)
|
||||||
|
items.append({
|
||||||
|
"document_id": r["document_id"],
|
||||||
|
"file": r["file"],
|
||||||
|
"when": r["when"],
|
||||||
|
"serial": system.get("serial", ""),
|
||||||
|
"model": system.get("model", ""),
|
||||||
|
"failed": bool(failed) or not erasures,
|
||||||
|
"note": (f"Blancco wipe failed on {drives or 'drive'}: "
|
||||||
|
f"{'; '.join(x.rstrip('. ') for x in reasons if x) or 'no erasure recorded'}. "
|
||||||
|
"Drive to be destroyed; record Certificate of Destruction.")
|
||||||
|
if (failed or not erasures) else "",
|
||||||
|
"cloud": cloud_status(state, r["document_id"]),
|
||||||
|
"done": done.get("ts") if done else None,
|
||||||
|
})
|
||||||
|
return items
|
||||||
@@ -125,12 +125,54 @@ EXPORT_COLUMNS = [
|
|||||||
"system_manufacturer", "system_model", "system_serial",
|
"system_manufacturer", "system_model", "system_serial",
|
||||||
"drive_model", "drive_serial", "drive_capacity_gb", "drive_interface",
|
"drive_model", "drive_serial", "drive_capacity_gb", "drive_interface",
|
||||||
"erasure_standard", "state", "start_time", "end_time", "elapsed_time",
|
"erasure_standard", "state", "start_time", "end_time", "elapsed_time",
|
||||||
"total_errors", "license_consumption_id", "business_name",
|
"total_errors", "failure_reason", "license_consumption_id",
|
||||||
|
"business_name", "business_location", "cloud_upload",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def export_rows(filename: str, data: dict, when: str) -> list[dict]:
|
def _as_list(x):
|
||||||
"""One CSV row per erased drive; a report with no erasures still gets a row."""
|
if x is None or x == "":
|
||||||
|
return []
|
||||||
|
return x if isinstance(x, list) else [x]
|
||||||
|
|
||||||
|
|
||||||
|
def failure_reason(er: dict) -> str:
|
||||||
|
"""Why an erasure did not succeed, from the report itself; "" if it did.
|
||||||
|
|
||||||
|
Blancco records exception messages under erasure_details (e.g.
|
||||||
|
"NBP area content was not erased."), a state and error count per step,
|
||||||
|
and total/read/mismatch error counts. Auditors (ENT-158/159) want the
|
||||||
|
cause of failure next to each failed wipe.
|
||||||
|
"""
|
||||||
|
if (er.get("state") or "").strip().lower() == "successful":
|
||||||
|
return ""
|
||||||
|
parts = []
|
||||||
|
details = er.get("erasure_details") or {}
|
||||||
|
for exc in _as_list(details.get("exception")) + _as_list(details.get("messages")):
|
||||||
|
msg = exc.get("message") if isinstance(exc, dict) else exc
|
||||||
|
if msg and msg not in parts:
|
||||||
|
parts.append(msg)
|
||||||
|
steps = (er.get("steps") or {}).get("step")
|
||||||
|
for i, st in enumerate(_as_list(steps), 1):
|
||||||
|
if isinstance(st, dict) and (st.get("state") or "completed") != "completed":
|
||||||
|
name = st.get("name") or st.get("step_name") or f"step {i}"
|
||||||
|
parts.append(f"{name}: {st.get('state')}")
|
||||||
|
for key in ("total_errors", "read_errors", "mismatch_errors"):
|
||||||
|
try:
|
||||||
|
n = int(er.get(key) or 0)
|
||||||
|
except ValueError:
|
||||||
|
n = 0
|
||||||
|
if n:
|
||||||
|
parts.append(f"{key.replace('_', ' ')}: {n}")
|
||||||
|
return "; ".join(parts) or f"state: {er.get('state') or 'unknown'}"
|
||||||
|
|
||||||
|
|
||||||
|
def export_rows(filename: str, data: dict, when: str, cloud: str = "") -> list[dict]:
|
||||||
|
"""One CSV row per erased drive; a report with no erasures still gets a row.
|
||||||
|
|
||||||
|
cloud is the upload status recorded by the webapp for this report
|
||||||
|
("sent", "failed" or "" when unknown).
|
||||||
|
"""
|
||||||
meta = data.get("meta") or {}
|
meta = data.get("meta") or {}
|
||||||
system = (data.get("hardware") or {}).get("system") or {}
|
system = (data.get("hardware") or {}).get("system") or {}
|
||||||
if isinstance(system, list):
|
if isinstance(system, list):
|
||||||
@@ -145,6 +187,8 @@ def export_rows(filename: str, data: dict, when: str) -> list[dict]:
|
|||||||
"system_serial": system.get("serial", ""),
|
"system_serial": system.get("serial", ""),
|
||||||
"license_consumption_id": ";".join(data.get("license_consumption_ids") or []),
|
"license_consumption_id": ";".join(data.get("license_consumption_ids") or []),
|
||||||
"business_name": (data.get("company") or {}).get("business_name", ""),
|
"business_name": (data.get("company") or {}).get("business_name", ""),
|
||||||
|
"business_location": (data.get("company") or {}).get("business_location", ""),
|
||||||
|
"cloud_upload": cloud,
|
||||||
}
|
}
|
||||||
rows = []
|
rows = []
|
||||||
for er in data.get("erasures") or []:
|
for er in data.get("erasures") or []:
|
||||||
@@ -167,5 +211,6 @@ def export_rows(filename: str, data: dict, when: str) -> list[dict]:
|
|||||||
"end_time": er.get("end_time", ""),
|
"end_time": er.get("end_time", ""),
|
||||||
"elapsed_time": er.get("elapsed_time", ""),
|
"elapsed_time": er.get("elapsed_time", ""),
|
||||||
"total_errors": er.get("total_errors", ""),
|
"total_errors": er.get("total_errors", ""),
|
||||||
|
"failure_reason": failure_reason(er),
|
||||||
})
|
})
|
||||||
return rows or [base]
|
return rows or [base]
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from flask import abort, request, session
|
|||||||
# trust-by-network. Keep the list short and explicit.
|
# trust-by-network. Keep the list short and explicit.
|
||||||
CSRF_EXEMPT_PATHS = {
|
CSRF_EXEMPT_PATHS = {
|
||||||
"/imaging/status",
|
"/imaging/status",
|
||||||
|
"/reports/cloud-status",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,23 @@
|
|||||||
<p class="small text-muted mt-2 mb-0">Dated by the timestamp inside each report, not the file date.</p>
|
<p class="small text-muted mt-2 mb-0">Dated by the timestamp inside each report, not the file date.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="section-card">
|
||||||
|
<div class="section-title">CMMC Evidence - {{ site }}</div>
|
||||||
|
<div class="d-flex flex-wrap align-items-center gap-3">
|
||||||
|
<form method="get" action="{{ url_for('blancco_evidence_pack') }}" class="d-flex flex-wrap align-items-center gap-2">
|
||||||
|
<label for="evidenceMonth" class="mb-0">Evidence pack (ENT-056/158) for</label>
|
||||||
|
<input id="evidenceMonth" type="month" name="month" value="{{ last_month }}" class="form-control form-control-sm w-auto" required>
|
||||||
|
<button type="submit" class="pxe-btn pxe-btn-secondary" title="summary.csv plus every report's XML and PDF for that month">
|
||||||
|
<i class="bi bi-file-zip"></i> Download
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<a href="{{ url_for('blancco_servicenow') }}" class="pxe-btn pxe-btn-secondary" title="What to tick on each Onsite Bin Management RITM">
|
||||||
|
<i class="bi bi-list-check"></i> ServiceNow tick list (ENT-045/159)
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<p class="small text-muted mt-2 mb-0">The evidence pack for last month is also saved automatically to <span class="mono">evidence\</span> on the reports share on the 1st. Certificates of record are in the Blancco Management Portal.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="section-card">
|
<div class="section-card">
|
||||||
<div class="section-title">Drive Erasure Certificates</div>
|
<div class="section-title">Drive Erasure Certificates</div>
|
||||||
{% if reports %}
|
{% if reports %}
|
||||||
@@ -40,6 +57,7 @@
|
|||||||
<th>Model</th>
|
<th>Model</th>
|
||||||
<th>Date</th>
|
<th>Date</th>
|
||||||
<th>Result</th>
|
<th>Result</th>
|
||||||
|
<th title="Whether Blancco reported uploading this report to the Blancco cloud">Cloud</th>
|
||||||
<th class="text-end">Actions</th>
|
<th class="text-end">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -55,6 +73,11 @@
|
|||||||
{% elif r.state %}<span class="badge badge-secondary">{{ r.state }}</span>
|
{% elif r.state %}<span class="badge badge-secondary">{{ r.state }}</span>
|
||||||
{% else %}<span class="text-muted">-</span>{% endif %}
|
{% else %}<span class="text-muted">-</span>{% endif %}
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if r.cloud == 'sent' %}<span class="badge badge-success">Sent</span>
|
||||||
|
{% elif r.cloud == 'failed' %}<span class="badge badge-danger" title="Resend from Blancco, or upload the XML to the Blancco portal">Not sent</span>
|
||||||
|
{% else %}<span class="text-muted" title="No upload status recorded (older report, or saved manually)">-</span>{% endif %}
|
||||||
|
</td>
|
||||||
<td class="actions text-end text-nowrap">
|
<td class="actions text-end text-nowrap">
|
||||||
{% if r.filename.lower().endswith('.xml') %}
|
{% if r.filename.lower().endswith('.xml') %}
|
||||||
<a href="{{ url_for('blancco_view_report', filename=r.filename) }}"
|
<a href="{{ url_for('blancco_view_report', filename=r.filename) }}"
|
||||||
|
|||||||
104
webapp/templates/reports_servicenow.html
Normal file
104
webapp/templates/reports_servicenow.html
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}ServiceNow Tick List - PXE Server Manager{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="page-header">
|
||||||
|
<h1><i class="bi bi-list-check"></i> ServiceNow Tick List</h1>
|
||||||
|
<div class="header-actions">
|
||||||
|
<a href="{{ url_for('blancco_reports') }}" class="pxe-btn pxe-btn-ghost"><i class="bi bi-arrow-left"></i> Erasure reports</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-card">
|
||||||
|
<div class="section-title">Onsite Bin Management RITM (ENT-045 / ENT-159)</div>
|
||||||
|
<p class="mb-2">
|
||||||
|
For each wiped asset, open its Onsite Bin Management RITM and tick
|
||||||
|
<strong>Wiped</strong>, or <strong>Blancco Wipe failed</strong> with the destruction note below.
|
||||||
|
Then click <strong>Done</strong> here so it drops off the list.
|
||||||
|
</p>
|
||||||
|
<form method="get" class="d-flex flex-wrap align-items-center gap-2 mb-0">
|
||||||
|
<label for="days" class="mb-0">Last</label>
|
||||||
|
<select id="days" name="days" class="form-select form-select-sm w-auto" onchange="this.form.submit()">
|
||||||
|
{% for d in (7, 30, 90, 366) %}<option value="{{ d }}" {% if d == days %}selected{% endif %}>{{ d }} days</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
<label class="mb-0 ms-2"><input type="checkbox" name="all" value="1" {% if include_done %}checked{% endif %} onchange="this.form.submit()"> show done</label>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-card">
|
||||||
|
{% if items %}
|
||||||
|
<div class="table-container">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Serial</th>
|
||||||
|
<th>Model</th>
|
||||||
|
<th>Wiped (UTC)</th>
|
||||||
|
<th>Tick in ServiceNow</th>
|
||||||
|
<th>Destruction note</th>
|
||||||
|
<th>Cloud</th>
|
||||||
|
<th class="text-end">Done</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for it in items %}
|
||||||
|
<tr title="{{ it.file }}">
|
||||||
|
<td class="text-nowrap">
|
||||||
|
<span class="mono">{{ it.serial or '-' }}</span>
|
||||||
|
{% if it.serial %}<button type="button" class="btn btn-sm btn-outline-secondary btn-row-action copy-btn" data-copy-text="{{ it.serial }}">copy</button>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ it.model or '-' }}</td>
|
||||||
|
<td class="mono text-nowrap">{{ it.when.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||||
|
<td>
|
||||||
|
{% if it.failed %}<span class="badge badge-danger">Blancco Wipe failed</span>
|
||||||
|
{% else %}<span class="badge badge-success">Wiped</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td style="max-width: 28rem;">
|
||||||
|
{% if it.note %}
|
||||||
|
<div class="small">{{ it.note }}</div>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary btn-row-action copy-btn mt-1" data-copy-text="{{ it.note }}">copy note</button>
|
||||||
|
{% else %}<span class="text-muted">-</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if it.cloud == 'sent' %}<span class="badge badge-success">Sent</span>
|
||||||
|
{% elif it.cloud == 'failed' %}<span class="badge badge-danger">Not sent</span>
|
||||||
|
{% 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;">
|
||||||
|
<input type="hidden" name="_csrf_token" value="{{ csrf_token() }}">
|
||||||
|
{% if it.done %}
|
||||||
|
<input type="hidden" name="done" value="0">
|
||||||
|
<button type="submit" class="pxe-btn pxe-btn-ghost btn-row-action" title="Ticked {{ it.done }}">Undo</button>
|
||||||
|
{% else %}
|
||||||
|
<input type="hidden" name="done" value="1">
|
||||||
|
<button type="submit" class="pxe-btn pxe-btn-success btn-row-action">Done</button>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center text-muted py-5">
|
||||||
|
<p class="mb-1">Nothing left to tick for the last {{ days }} days.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_scripts %}
|
||||||
|
<script>
|
||||||
|
document.querySelectorAll('.copy-btn').forEach(function (b) {
|
||||||
|
b.addEventListener('click', function () {
|
||||||
|
var t = b.getAttribute('data-copy-text') || '';
|
||||||
|
var done = function () { var o = b.textContent; b.textContent = 'copied'; setTimeout(function () { b.textContent = o; }, 1200); };
|
||||||
|
if (navigator.clipboard && window.isSecureContext) { navigator.clipboard.writeText(t).then(done); return; }
|
||||||
|
var ta = document.createElement('textarea'); ta.value = t; document.body.appendChild(ta); ta.select();
|
||||||
|
try { document.execCommand('copy'); done(); } finally { document.body.removeChild(ta); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user