Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/guide/dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 12 additions & 4 deletions fastapi_taskflow/dashboard/js.py
Original file line number Diff line number Diff line change
Expand Up @@ -763,8 +763,8 @@
? '<div class="d-logs">'
+ task.logs.map(function(line) {
if (line.startsWith('--- ')) return '<div class="log-sep">' + esc(line) + '</div>';
var ts = line.slice(0, 19), msg = line.slice(20);
return '<div class="log-line"><span class="log-ts">' + esc(ts) + '</span><span>' + esc(msg) + '</span></div>';
var ts = line.slice(0, 20), msg = line.slice(21);
return '<div class="log-line"><span class="log-ts">' + esc(fmtDate(ts)) + '</span><span>' + esc(msg) + '</span></div>';
}).join('')
+ '</div>'
: '';
Expand Down Expand Up @@ -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);
}));
Expand All @@ -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; }
}

Expand Down
2 changes: 1 addition & 1 deletion fastapi_taskflow/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
26 changes: 20 additions & 6 deletions fastapi_taskflow/models.py
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
}
2 changes: 1 addition & 1 deletion fastapi_taskflow/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -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},
)
)
Expand Down
46 changes: 44 additions & 2 deletions tests/test_models.py
Original file line number Diff line number Diff line change
@@ -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():
Expand Down Expand Up @@ -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
Expand Down
Loading