From 7080d8b9c216f9d621b327c1d15589f645995bb9 Mon Sep 17 00:00:00 2001 From: Richard Quaicoe Date: Thu, 16 Jul 2026 15:18:21 +0000 Subject: [PATCH] fix: dashboard timestamps respect viewer's timezone created_at was serialized without a UTC offset, so the dashboard's Date parsing silently treated it as local time, throwing off both displayed times and the time-range filter. Timestamps now always serialize with an explicit UTC offset, and the UI shows/exports/logs times in the viewer's local zone. --- docs/guide/dashboard.md | 3 +++ fastapi_taskflow/dashboard/js.py | 16 ++++++++--- fastapi_taskflow/middleware.py | 2 +- fastapi_taskflow/models.py | 26 +++++++++++++----- fastapi_taskflow/router.py | 2 +- tests/test_models.py | 46 ++++++++++++++++++++++++++++++-- 6 files changed, 81 insertions(+), 14 deletions(-) diff --git a/docs/guide/dashboard.md b/docs/guide/dashboard.md index 90f3637..e4fcf47 100644 --- a/docs/guide/dashboard.md +++ b/docs/guide/dashboard.md @@ -88,6 +88,9 @@ Controls in the filter panel let you narrow the task table: All active filters combine: a task must pass every one of them to appear in the table. The table paginates at 30 tasks per page. +!!! note "Timestamps and timezones" + Task times (`created_at`, `started`, `ended`) are stored and transmitted as UTC, and the dashboard renders them in your browser's local timezone, with the zone abbreviation shown alongside each time (e.g. "Jul 15, 2:32:01 PM PDT"). The **time-range picker** compares against your browser's current time, so "last 6 hours" always means the 6 hours before *your* local now, regardless of where the server is deployed. + --- ## Task Detail Panel diff --git a/fastapi_taskflow/dashboard/js.py b/fastapi_taskflow/dashboard/js.py index 4af8ef1..d403956 100644 --- a/fastapi_taskflow/dashboard/js.py +++ b/fastapi_taskflow/dashboard/js.py @@ -763,8 +763,8 @@ ? '
' + task.logs.map(function(line) { if (line.startsWith('--- ')) return '
' + esc(line) + '
'; - var ts = line.slice(0, 19), msg = line.slice(20); - return '
' + esc(ts) + '' + esc(msg) + '
'; + var ts = line.slice(0, 20), msg = line.slice(21); + return '
' + esc(fmtDate(ts)) + '' + esc(msg) + '
'; }).join('') + '
' : ''; @@ -844,7 +844,7 @@ t.status, t.duration != null ? (t.duration * 1000).toFixed(0) : '', t.retries_used, - t.created_at || '', + t.created_at ? fmtDateFull(t.created_at) : '', t.error ? t.error.replace(/[\r\n]+/g, ' ') : '', ].map(csvCell); })); @@ -870,7 +870,15 @@ function fmtDate(iso) { try { - return new Date(iso).toLocaleString(undefined, { month:'short', day:'numeric', hour:'2-digit', minute:'2-digit', second:'2-digit' }); + return new Date(iso).toLocaleString(undefined, { month:'short', day:'numeric', hour:'2-digit', minute:'2-digit', second:'2-digit', timeZoneName:'short' }); + } catch(e) { return iso; } + } + + // Like fmtDate but includes the year, for contexts (e.g. CSV export) where + // rows may span multiple years and the local date needs to be unambiguous. + function fmtDateFull(iso) { + try { + return new Date(iso).toLocaleString(undefined, { year:'numeric', month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit', second:'2-digit', timeZoneName:'short' }); } catch(e) { return iso; } } diff --git a/fastapi_taskflow/middleware.py b/fastapi_taskflow/middleware.py index 26d999c..65ef9a3 100644 --- a/fastapi_taskflow/middleware.py +++ b/fastapi_taskflow/middleware.py @@ -281,7 +281,7 @@ def _make_sink(self, ctx: ExecContext) -> Callable: def sink(msg: str, level: str, extra: dict) -> None: ts = datetime.now(timezone.utc) ctx.store.append_log( - ctx.task_id, f"{ts.strftime('%Y-%m-%dT%H:%M:%S')} {msg}" + ctx.task_id, f"{ts.strftime('%Y-%m-%dT%H:%M:%SZ')} {msg}" ) if ctx.logger is not None: event = LogEvent( diff --git a/fastapi_taskflow/models.py b/fastapi_taskflow/models.py index 149ed60..430e626 100644 --- a/fastapi_taskflow/models.py +++ b/fastapi_taskflow/models.py @@ -1,9 +1,23 @@ from dataclasses import dataclass, field -from datetime import datetime +from datetime import datetime, timezone from enum import Enum from typing import Any, Literal, Optional +def _iso_utc(dt: "datetime | None") -> "str | None": + """Serialize a datetime as an unambiguous UTC ISO 8601 string. + + Naive datetimes are treated as UTC (the convention used everywhere in + this codebase) rather than left offset-less, since an offset-less ISO + string is parsed as local time by JS ``Date`` and most other clients. + """ + if dt is None: + return None + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.isoformat() + + @dataclass class QueueConfig: """Configuration for a named task queue. @@ -164,7 +178,7 @@ class TaskRecord: status: TaskStatus args: tuple = field(default_factory=tuple) kwargs: dict = field(default_factory=dict) - created_at: datetime = field(default_factory=datetime.utcnow) + created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) start_time: datetime | None = None end_time: datetime | None = None retries_used: int = 0 @@ -224,9 +238,9 @@ def to_dict(self) -> dict[str, Any]: "task_id": self.task_id, "func_name": self.func_name, "status": self.status.value, - "created_at": self.created_at.isoformat(), - "start_time": self.start_time.isoformat() if self.start_time else None, - "end_time": self.end_time.isoformat() if self.end_time else None, + "created_at": _iso_utc(self.created_at), + "start_time": _iso_utc(self.start_time), + "end_time": _iso_utc(self.end_time), "duration": self.duration, "retries_used": self.retries_used, "error": self.error, @@ -266,6 +280,6 @@ def to_dict(self) -> dict[str, Any]: "action": self.action, "task_id": self.task_id, "actor": self.actor, - "timestamp": self.timestamp.isoformat(), + "timestamp": _iso_utc(self.timestamp), "detail": self.detail, } diff --git a/fastapi_taskflow/router.py b/fastapi_taskflow/router.py index 7d3d1a7..af9b65d 100644 --- a/fastapi_taskflow/router.py +++ b/fastapi_taskflow/router.py @@ -210,7 +210,7 @@ def update_queue( action="update_queue", task_id=queue_name, actor=_actor(request, secret_key), - timestamp=__import__("datetime").datetime.utcnow(), + timestamp=datetime.now(timezone.utc), detail={"concurrency": body.concurrency, "max_size": body.max_size}, ) ) diff --git a/tests/test_models.py b/tests/test_models.py index 99aafd2..8e5afd4 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,6 +1,6 @@ -from datetime import datetime +from datetime import datetime, timezone -from fastapi_taskflow.models import TaskConfig, TaskRecord, TaskStatus +from fastapi_taskflow.models import AuditEntry, TaskConfig, TaskRecord, TaskStatus def test_task_record_duration_none_when_not_finished(): @@ -30,6 +30,48 @@ def test_to_dict_contains_expected_keys(): assert d["duration"] is None +def test_to_dict_timestamps_carry_utc_offset_when_naive(): + # Naive datetimes (the historical convention for created_at) must still + # serialize with an explicit UTC offset, since an offset-less ISO string + # is parsed as local time by JS `Date` and most other clients. + naive = datetime(2024, 1, 1, 12, 0, 0) + record = TaskRecord( + task_id="t1", + func_name="func", + status=TaskStatus.SUCCESS, + created_at=naive, + start_time=naive, + end_time=naive, + ) + d = record.to_dict() + assert d["created_at"] == "2024-01-01T12:00:00+00:00" + assert d["start_time"] == "2024-01-01T12:00:00+00:00" + assert d["end_time"] == "2024-01-01T12:00:00+00:00" + + +def test_to_dict_timestamps_preserve_aware_utc_offset(): + aware = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + record = TaskRecord( + task_id="t1", + func_name="func", + status=TaskStatus.SUCCESS, + created_at=aware, + ) + d = record.to_dict() + assert d["created_at"] == "2024-01-01T12:00:00+00:00" + + +def test_audit_entry_to_dict_timestamp_carries_utc_offset_when_naive(): + entry = AuditEntry( + entry_id="e1", + action="retry", + task_id="t1", + actor="anonymous", + timestamp=datetime(2024, 1, 1, 12, 0, 0), + ) + assert entry.to_dict()["timestamp"] == "2024-01-01T12:00:00+00:00" + + def test_task_config_defaults(): config = TaskConfig() assert config.retries == 0