From 83ba4865e8d83fccfde8ce1f000c671f4f224ce9 Mon Sep 17 00:00:00 2001 From: cproudlock Date: Mon, 5 Oct 2026 14:16:14 -0400 Subject: [PATCH] Show every time in US Eastern (America/New_York) Webapp: config.DISPLAY_TZ (default America/New_York) and a local_dt filter that renders datetimes, ISO strings and Unix timestamps as e.g. "2026-10-05 13:54 EDT", DST included. Used for the ServiceNow tick list, technician to-do, imaging tiles and detail page; timestamp_fmt (reports, backups, enrollment) goes through it too instead of relying on the process TZ. CSV report_date is Eastern with its offset. Evidence-pack months are Eastern calendar months, so a wipe late on the 31st lands in that month rather than the next. Blancco: init points the new root's /etc/localtime at America/New_York so the GUI clock is local. Report timestamps keep their explicit offset; report.utc_offset stays 0 since a fixed offset cannot follow DST. Report file names stay UTC, as Blancco's own Save names them. --- playbook/blancco-init.sh | 7 +++++ webapp/app.py | 36 ++++++++++++++++++++++-- webapp/config.py | 3 ++ webapp/services/blancco_evidence.py | 21 +++++++++----- webapp/templates/_imaging_tiles.html | 4 +-- webapp/templates/imaging_detail.html | 8 +++--- webapp/templates/reports_servicenow.html | 4 +-- webapp/templates/reports_todo.html | 8 +++--- 8 files changed, 69 insertions(+), 22 deletions(-) diff --git a/playbook/blancco-init.sh b/playbook/blancco-init.sh index 932b8d6..bd2aa84 100644 --- a/playbook/blancco-init.sh +++ b/playbook/blancco-init.sh @@ -370,6 +370,13 @@ else echo " (no blancco-progress.py on server: no live progress on the dashboard)" fi +# Site timezone for Blancco's own clock (GUI, logs). Report timestamps keep +# an explicit UTC offset; report.utc_offset in the preferences stays 0 +# because it is a fixed offset and cannot follow daylight saving. +if [ -e /run/newroot/usr/share/zoneinfo/America/New_York ]; then + ln -sf /usr/share/zoneinfo/America/New_York /run/newroot/etc/localtime +fi + # Debug boots only: Blancco's own debug mode (/etc/debug.environment makes # isDebugMode true, so 2_sshd starts sshd) plus an SSH key for root. # Root is locked and sshd is key-only, so without the key there is no way diff --git a/webapp/app.py b/webapp/app.py index df362c2..72a977b 100644 --- a/webapp/app.py +++ b/webapp/app.py @@ -638,7 +638,7 @@ def blancco_export_reports(): if when < since: continue rows.extend(blancco_report.export_rows( - f, data, when.isoformat(), + f, data, to_display_tz(when).isoformat(), blancco_evidence.cloud_status(cloud_state, (data.get("meta") or {}).get("document_id", "")))) files.append(f) pdf = os.path.splitext(f)[0] + ".pdf" @@ -1490,10 +1490,40 @@ def api_save_unattend(image_type): # Template helpers # --------------------------------------------------------------------------- +def to_display_tz(value): + """datetime (naive = UTC), ISO-8601 string or Unix timestamp -> aware + datetime in config.DISPLAY_TZ; None when it cannot be read.""" + from datetime import timezone + from zoneinfo import ZoneInfo + if value is None or value == "": + return None + if isinstance(value, (int, float)): + dt = datetime.fromtimestamp(value, timezone.utc) + elif isinstance(value, datetime): + dt = value if value.tzinfo else value.replace(tzinfo=timezone.utc) + else: + try: + dt = datetime.fromisoformat(str(value).replace("Z", "+00:00")) + except ValueError: + return None + if not dt.tzinfo: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(ZoneInfo(config.DISPLAY_TZ)) + + +@app.template_filter("local_dt") +def local_dt(value, seconds=False): + """Any timestamp shown in the UI, as e.g. '2026-10-05 13:54 EDT'.""" + dt = to_display_tz(value) + if dt is None: + return value or "-" + return dt.strftime("%Y-%m-%d %H:%M:%S %Z" if seconds else "%Y-%m-%d %H:%M %Z") + + @app.template_filter("timestamp_fmt") def timestamp_fmt(ts): - """Format a Unix timestamp to a human-readable date string.""" - return datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M") + """Format a Unix timestamp in the display timezone.""" + return local_dt(ts) @app.context_processor diff --git a/webapp/config.py b/webapp/config.py index 2685fe6..f234d22 100644 --- a/webapp/config.py +++ b/webapp/config.py @@ -25,6 +25,9 @@ IMAGING_DIR = os.environ.get("IMAGING_DIR", "/var/log/pxe-imaging") # 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") +# Every time the webapp shows (and evidence-pack month boundaries) is in +# this zone, DST included. Stored timestamps keep their own UTC offsets. +DISPLAY_TZ = os.environ.get("DISPLAY_TZ", "America/New_York") 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 diff --git a/webapp/services/blancco_evidence.py b/webapp/services/blancco_evidence.py index 677f1d6..336707f 100644 --- a/webapp/services/blancco_evidence.py +++ b/webapp/services/blancco_evidence.py @@ -130,16 +130,23 @@ def collect(since=None, until=None) -> list[dict]: return out +def _tz(): + from zoneinfo import ZoneInfo + return ZoneInfo(config.DISPLAY_TZ) + + def month_bounds(month: str): - """'2026-10' -> (2026-10-01T00:00Z, 2026-11-01T00:00Z).""" + """'2026-10' -> start of that month and of the next, in the site's + display timezone (a wipe at 21:00 EDT on the 31st belongs to that + month, not the next one as it would in UTC).""" 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) + start = datetime(y, m, 1, tzinfo=_tz()) + end = datetime(y + (m == 12), m % 12 + 1, 1, tzinfo=_tz()) return start, end def previous_month(now=None) -> str: - now = now or datetime.now(timezone.utc) + now = now or datetime.now(_tz()) y, m = (now.year, now.month - 1) if now.month > 1 else (now.year - 1, 12) return f"{y:04d}-{m:02d}" @@ -150,7 +157,7 @@ def summary_csv(reports: list[dict], state: dict) -> str: writer.writeheader() for r in reports: writer.writerows(blancco_report.export_rows( - r["file"], r["data"], r["when"].isoformat(), + r["file"], r["data"], r["when"].astimezone(_tz()).isoformat(), cloud_status(state, r["document_id"]))) return buf.getvalue() @@ -168,7 +175,7 @@ def build_evidence_zip(month: str, fileobj) -> int: 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"Blancco erasure evidence, {config.BLANCCO_SITE}, {month} ({config.DISPLAY_TZ})\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" @@ -206,7 +213,7 @@ def servicenow_items(days: int = 30, include_done: bool = False) -> list[dict]: items.append({ "document_id": r["document_id"], "file": r["file"], - "when": r["when"], + "when": r["when"].astimezone(_tz()), "serial": system.get("serial", ""), "model": system.get("model", ""), "failed": bool(failed) or not erasures, diff --git a/webapp/templates/_imaging_tiles.html b/webapp/templates/_imaging_tiles.html index 3f1c534..b71e139 100644 --- a/webapp/templates/_imaging_tiles.html +++ b/webapp/templates/_imaging_tiles.html @@ -164,8 +164,8 @@ {% endif %}
- started {{ s.started_at or '-' }} - last {{ s.last_updated or '-' }} + started {{ s.started_at | local_dt(true) }} + last {{ s.last_updated | local_dt(true) }} MAC {{ s.mac or '-' }} {% if s.current_stage %}{{ s.current_stage }}{% endif %}
diff --git a/webapp/templates/imaging_detail.html b/webapp/templates/imaging_detail.html index 89d7579..4d5a325 100644 --- a/webapp/templates/imaging_detail.html +++ b/webapp/templates/imaging_detail.html @@ -39,14 +39,14 @@ {{ session.intune_device_id }} {% else %}-{% endif %} -
Started
{{ session.started_at or '-' }}
-
Last update
{{ session.last_updated or '-' }}
+
Started
{{ session.started_at | local_dt(true) }}
+
Last update
{{ session.last_updated | local_dt(true) }}
Stage
{{ session.stage_index or 0 }}/{{ session.stage_total or '?' }} {% if session.current_stage %} - {{ session.current_stage }}{% endif %}
{% if session.previous_run_at %} -
Prior run
{{ session.previous_run_at }}
+
Prior run
{{ session.previous_run_at | local_dt(true) }}
{% endif %} {% if session.error %}
Error
@@ -66,7 +66,7 @@ {% for h in session.stage_history %} - {{ h.ts }} + {{ h.ts | local_dt(true) }} {{ h.stage_index }} diff --git a/webapp/templates/reports_servicenow.html b/webapp/templates/reports_servicenow.html index 3b13c8b..7482c92 100644 --- a/webapp/templates/reports_servicenow.html +++ b/webapp/templates/reports_servicenow.html @@ -33,7 +33,7 @@ Serial Model - Wiped (UTC) + Wiped Tick in ServiceNow Destruction note Cloud @@ -48,7 +48,7 @@ {% if it.serial %}{% endif %} {{ it.model or '-' }} - {{ it.when.strftime('%Y-%m-%d %H:%M') }} + {{ it.when | local_dt }} {% if it.failed %}Blancco Wipe failed {% else %}Wiped{% endif %} diff --git a/webapp/templates/reports_todo.html b/webapp/templates/reports_todo.html index 905458c..bee84d0 100644 --- a/webapp/templates/reports_todo.html +++ b/webapp/templates/reports_todo.html @@ -62,13 +62,13 @@

Remove the drive, destroy it per site procedure, record the Certificate of Destruction and the destruction note on the RITM, then mark it here.

- + {% for it in todo.failed %} - +
SerialModelWiped (UTC)FailureActions
SerialModelWipedFailureActions
{{ it.serial or '-' }} {{ it.model or '-' }}{{ it.when.strftime('%Y-%m-%d %H:%M') }}{{ it.when | local_dt }} {{ it.note }} Report @@ -94,13 +94,13 @@

Auditors pull certificates from the Blancco Management Portal. Upload the report's XML there by hand, then mark it resolved.

- + {% for it in todo.not_in_cloud %} - +
SerialModelWiped (UTC)Actions
SerialModelWipedActions
{{ it.serial or '-' }} {{ it.model or '-' }}{{ it.when.strftime('%Y-%m-%d %H:%M') }}{{ it.when | local_dt }} XML