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.
This commit is contained in:
@@ -370,6 +370,13 @@ else
|
|||||||
echo " (no blancco-progress.py on server: no live progress on the dashboard)"
|
echo " (no blancco-progress.py on server: no live progress on the dashboard)"
|
||||||
fi
|
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
|
# 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.
|
# 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
|
# Root is locked and sshd is key-only, so without the key there is no way
|
||||||
|
|||||||
@@ -638,7 +638,7 @@ def blancco_export_reports():
|
|||||||
if when < since:
|
if when < since:
|
||||||
continue
|
continue
|
||||||
rows.extend(blancco_report.export_rows(
|
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", ""))))
|
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"
|
||||||
@@ -1490,10 +1490,40 @@ def api_save_unattend(image_type):
|
|||||||
# Template helpers
|
# 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")
|
@app.template_filter("timestamp_fmt")
|
||||||
def timestamp_fmt(ts):
|
def timestamp_fmt(ts):
|
||||||
"""Format a Unix timestamp to a human-readable date string."""
|
"""Format a Unix timestamp in the display timezone."""
|
||||||
return datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M")
|
return local_dt(ts)
|
||||||
|
|
||||||
|
|
||||||
@app.context_processor
|
@app.context_processor
|
||||||
|
|||||||
@@ -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
|
# small JSON state file (cloud-upload confirmations, ServiceNow ticks) kept
|
||||||
# off the report share so it never shows up as a report.
|
# off the report share so it never shows up as a report.
|
||||||
BLANCCO_SITE = os.environ.get("BLANCCO_SITE", "West Jefferson")
|
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")
|
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
|
||||||
|
|||||||
@@ -130,16 +130,23 @@ def collect(since=None, until=None) -> list[dict]:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _tz():
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
return ZoneInfo(config.DISPLAY_TZ)
|
||||||
|
|
||||||
|
|
||||||
def month_bounds(month: str):
|
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("-"))
|
y, m = (int(x) for x in month.split("-"))
|
||||||
start = datetime(y, m, 1, tzinfo=timezone.utc)
|
start = datetime(y, m, 1, tzinfo=_tz())
|
||||||
end = datetime(y + (m == 12), m % 12 + 1, 1, tzinfo=timezone.utc)
|
end = datetime(y + (m == 12), m % 12 + 1, 1, tzinfo=_tz())
|
||||||
return start, end
|
return start, end
|
||||||
|
|
||||||
|
|
||||||
def previous_month(now=None) -> str:
|
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)
|
y, m = (now.year, now.month - 1) if now.month > 1 else (now.year - 1, 12)
|
||||||
return f"{y:04d}-{m:02d}"
|
return f"{y:04d}-{m:02d}"
|
||||||
|
|
||||||
@@ -150,7 +157,7 @@ def summary_csv(reports: list[dict], state: dict) -> str:
|
|||||||
writer.writeheader()
|
writer.writeheader()
|
||||||
for r in reports:
|
for r in reports:
|
||||||
writer.writerows(blancco_report.export_rows(
|
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"])))
|
cloud_status(state, r["document_id"])))
|
||||||
return buf.getvalue()
|
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:
|
with zipfile.ZipFile(fileobj, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||||
zf.writestr(f"{stem}/summary.csv", summary_csv(reports, state))
|
zf.writestr(f"{stem}/summary.csv", summary_csv(reports, state))
|
||||||
zf.writestr(f"{stem}/README.txt", (
|
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"
|
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"
|
"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"
|
"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({
|
items.append({
|
||||||
"document_id": r["document_id"],
|
"document_id": r["document_id"],
|
||||||
"file": r["file"],
|
"file": r["file"],
|
||||||
"when": r["when"],
|
"when": r["when"].astimezone(_tz()),
|
||||||
"serial": system.get("serial", ""),
|
"serial": system.get("serial", ""),
|
||||||
"model": system.get("model", ""),
|
"model": system.get("model", ""),
|
||||||
"failed": bool(failed) or not erasures,
|
"failed": bool(failed) or not erasures,
|
||||||
|
|||||||
@@ -164,8 +164,8 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="mt-2 d-flex flex-wrap gap-3" style="color:var(--text-light); font-size:0.75rem;">
|
<div class="mt-2 d-flex flex-wrap gap-3" style="color:var(--text-light); font-size:0.75rem;">
|
||||||
<span>started <code class="mono">{{ s.started_at or '-' }}</code></span>
|
<span>started <code class="mono">{{ s.started_at | local_dt(true) }}</code></span>
|
||||||
<span>last <code class="mono">{{ s.last_updated or '-' }}</code></span>
|
<span>last <code class="mono">{{ s.last_updated | local_dt(true) }}</code></span>
|
||||||
<span>MAC <code class="mono">{{ s.mac or '-' }}</code></span>
|
<span>MAC <code class="mono">{{ s.mac or '-' }}</code></span>
|
||||||
{% if s.current_stage %}<span class="mono">{{ s.current_stage }}</span>{% endif %}
|
{% if s.current_stage %}<span class="mono">{{ s.current_stage }}</span>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -39,14 +39,14 @@
|
|||||||
<span class="mono" style="word-break: break-all;">{{ session.intune_device_id }}</span>
|
<span class="mono" style="word-break: break-all;">{{ session.intune_device_id }}</span>
|
||||||
{% else %}-{% endif %}
|
{% else %}-{% endif %}
|
||||||
</dd>
|
</dd>
|
||||||
<dt class="col-4 text-light">Started</dt><dd class="col-8"><span class="mono">{{ session.started_at or '-' }}</span></dd>
|
<dt class="col-4 text-light">Started</dt><dd class="col-8"><span class="mono">{{ session.started_at | local_dt(true) }}</span></dd>
|
||||||
<dt class="col-4 text-light">Last update</dt><dd class="col-8"><span class="mono">{{ session.last_updated or '-' }}</span></dd>
|
<dt class="col-4 text-light">Last update</dt><dd class="col-8"><span class="mono">{{ session.last_updated | local_dt(true) }}</span></dd>
|
||||||
<dt class="col-4 text-light">Stage</dt><dd class="col-8">
|
<dt class="col-4 text-light">Stage</dt><dd class="col-8">
|
||||||
{{ session.stage_index or 0 }}/{{ session.stage_total or '?' }}
|
{{ session.stage_index or 0 }}/{{ session.stage_total or '?' }}
|
||||||
{% if session.current_stage %} - <span class="mono">{{ session.current_stage }}</span>{% endif %}
|
{% if session.current_stage %} - <span class="mono">{{ session.current_stage }}</span>{% endif %}
|
||||||
</dd>
|
</dd>
|
||||||
{% if session.previous_run_at %}
|
{% if session.previous_run_at %}
|
||||||
<dt class="col-4 text-light">Prior run</dt><dd class="col-8"><span class="mono">{{ session.previous_run_at }}</span></dd>
|
<dt class="col-4 text-light">Prior run</dt><dd class="col-8"><span class="mono">{{ session.previous_run_at | local_dt(true) }}</span></dd>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if session.error %}
|
{% if session.error %}
|
||||||
<dt class="col-4 text-danger">Error</dt>
|
<dt class="col-4 text-danger">Error</dt>
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% for h in session.stage_history %}
|
{% for h in session.stage_history %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><span class="mono">{{ h.ts }}</span></td>
|
<td><span class="mono">{{ h.ts | local_dt(true) }}</span></td>
|
||||||
<td><span class="badge badge-secondary">{{ h.stage_index }}</span></td>
|
<td><span class="badge badge-secondary">{{ h.stage_index }}</span></td>
|
||||||
<td>
|
<td>
|
||||||
<span class="badge badge-{{ 'success' if h.status == 'succeeded' else ('danger' if h.status == 'failed' else 'primary') }}">
|
<span class="badge badge-{{ 'success' if h.status == 'succeeded' else ('danger' if h.status == 'failed' else 'primary') }}">
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th>Serial</th>
|
<th>Serial</th>
|
||||||
<th>Model</th>
|
<th>Model</th>
|
||||||
<th>Wiped (UTC)</th>
|
<th>Wiped</th>
|
||||||
<th>Tick in ServiceNow</th>
|
<th>Tick in ServiceNow</th>
|
||||||
<th>Destruction note</th>
|
<th>Destruction note</th>
|
||||||
<th>Cloud</th>
|
<th>Cloud</th>
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
{% 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 %}
|
{% 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>
|
||||||
<td>{{ it.model or '-' }}</td>
|
<td>{{ it.model or '-' }}</td>
|
||||||
<td class="mono text-nowrap">{{ it.when.strftime('%Y-%m-%d %H:%M') }}</td>
|
<td class="mono text-nowrap">{{ it.when | local_dt }}</td>
|
||||||
<td>
|
<td>
|
||||||
{% if it.failed %}<span class="badge badge-danger">Blancco Wipe failed</span>
|
{% if it.failed %}<span class="badge badge-danger">Blancco Wipe failed</span>
|
||||||
{% else %}<span class="badge badge-success">Wiped</span>{% endif %}
|
{% else %}<span class="badge badge-success">Wiped</span>{% endif %}
|
||||||
|
|||||||
@@ -62,13 +62,13 @@
|
|||||||
<p class="mb-2">Remove the drive, destroy it per site procedure, record the Certificate of Destruction and the destruction note on the RITM, then mark it here.</p>
|
<p class="mb-2">Remove the drive, destroy it per site procedure, record the Certificate of Destruction and the destruction note on the RITM, then mark it here.</p>
|
||||||
<div class="table-container">
|
<div class="table-container">
|
||||||
<table class="data-table">
|
<table class="data-table">
|
||||||
<thead><tr><th>Serial</th><th>Model</th><th>Wiped (UTC)</th><th>Failure</th><th class="text-end">Actions</th></tr></thead>
|
<thead><tr><th>Serial</th><th>Model</th><th>Wiped</th><th>Failure</th><th class="text-end">Actions</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for it in todo.failed %}
|
{% for it in todo.failed %}
|
||||||
<tr title="{{ it.file }}">
|
<tr title="{{ it.file }}">
|
||||||
<td class="mono">{{ it.serial or '-' }}</td>
|
<td class="mono">{{ it.serial or '-' }}</td>
|
||||||
<td>{{ it.model or '-' }}</td>
|
<td>{{ it.model or '-' }}</td>
|
||||||
<td class="mono text-nowrap">{{ it.when.strftime('%Y-%m-%d %H:%M') }}</td>
|
<td class="mono text-nowrap">{{ it.when | local_dt }}</td>
|
||||||
<td class="small wrap">{{ it.note }}</td>
|
<td class="small wrap">{{ it.note }}</td>
|
||||||
<td class="text-end text-nowrap">
|
<td class="text-end text-nowrap">
|
||||||
<a href="{{ url_for('blancco_view_report', filename=it.file) }}" class="pxe-btn pxe-btn-secondary btn-row-action"><i class="bi bi-eye"></i> Report</a>
|
<a href="{{ url_for('blancco_view_report', filename=it.file) }}" class="pxe-btn pxe-btn-secondary btn-row-action"><i class="bi bi-eye"></i> Report</a>
|
||||||
@@ -94,13 +94,13 @@
|
|||||||
<p class="mb-2">Auditors pull certificates from the Blancco Management Portal. Upload the report's XML there by hand, then mark it resolved.</p>
|
<p class="mb-2">Auditors pull certificates from the Blancco Management Portal. Upload the report's XML there by hand, then mark it resolved.</p>
|
||||||
<div class="table-container">
|
<div class="table-container">
|
||||||
<table class="data-table">
|
<table class="data-table">
|
||||||
<thead><tr><th>Serial</th><th>Model</th><th>Wiped (UTC)</th><th class="text-end">Actions</th></tr></thead>
|
<thead><tr><th>Serial</th><th>Model</th><th>Wiped</th><th class="text-end">Actions</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for it in todo.not_in_cloud %}
|
{% for it in todo.not_in_cloud %}
|
||||||
<tr title="{{ it.file }}">
|
<tr title="{{ it.file }}">
|
||||||
<td class="mono">{{ it.serial or '-' }}</td>
|
<td class="mono">{{ it.serial or '-' }}</td>
|
||||||
<td>{{ it.model or '-' }}</td>
|
<td>{{ it.model or '-' }}</td>
|
||||||
<td class="mono text-nowrap">{{ it.when.strftime('%Y-%m-%d %H:%M') }}</td>
|
<td class="mono text-nowrap">{{ it.when | local_dt }}</td>
|
||||||
<td class="text-end text-nowrap">
|
<td class="text-end text-nowrap">
|
||||||
<a href="{{ url_for('blancco_download_report', filename=it.file) }}" class="pxe-btn pxe-btn-secondary btn-row-action"><i class="bi bi-download"></i> XML</a>
|
<a href="{{ url_for('blancco_download_report', filename=it.file) }}" class="pxe-btn pxe-btn-secondary btn-row-action"><i class="bi bi-download"></i> XML</a>
|
||||||
<form method="post" action="{{ url_for('blancco_todo_mark', kind='cloud_resolved', key=it.document_id) }}" style="display:inline;">
|
<form method="post" action="{{ url_for('blancco_todo_mark', kind='cloud_resolved', key=it.document_id) }}" style="display:inline;">
|
||||||
|
|||||||
Reference in New Issue
Block a user