From a66488d1f2c96bfbf20d923bcf960a5f1ae77bf5 Mon Sep 17 00:00:00 2001 From: Paul Trowbridge Date: Wed, 3 Jun 2026 23:21:05 -0400 Subject: [PATCH] Convert all timestamps to local time for display and scheduling Scheduler now evaluates cron expressions against local time instead of UTC, so schedules fire at the user's local clock time. All timestamp displays in templates use a new `localtime` Jinja filter that converts UTC strings from SQLite to the server's local timezone. Updated CLAUDE.md to reflect the systemd service setup. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- CLAUDE.md | 13 +++++++++++-- pipekit/scheduler.py | 14 +++++++------- pipekit/web/app.py | 17 +++++++++++++++-- pipekit/web/templates/_group_live.html | 2 +- pipekit/web/templates/_group_run_live.html | 2 +- pipekit/web/templates/_module_live.html | 2 +- pipekit/web/templates/group_detail.html | 4 ++-- pipekit/web/templates/group_form.html | 21 ++++++++++++++++++++- pipekit/web/templates/group_run_detail.html | 8 ++++---- pipekit/web/templates/groups.html | 2 +- pipekit/web/templates/modules_index.html | 2 +- pipekit/web/templates/run_detail.html | 6 +++--- pipekit/web/templates/runs.html | 2 +- 13 files changed, 68 insertions(+), 27 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 24797fc..6f52788 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,13 +4,22 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Running the Server +Pipekit runs as a systemd service (`pipekit.service`). Common commands: + +```bash +sudo systemctl status pipekit # check status +sudo systemctl restart pipekit # restart after changes +sudo systemctl stop pipekit # stop +sudo journalctl -u pipekit -f # follow logs +``` + +For dev/testing outside the service — never nohup or background it: + ```bash pipekit serve # default host/port from config.yaml pipekit serve --host 0.0.0.0 --port 8080 --reload # dev mode with auto-reload ``` -The user runs the server themselves in their own terminal — do not nohup or background it. - ## Other CLI Commands ```bash diff --git a/pipekit/scheduler.py b/pipekit/scheduler.py index 64b9727..a29f941 100644 --- a/pipekit/scheduler.py +++ b/pipekit/scheduler.py @@ -17,8 +17,8 @@ from datetime import datetime, timedelta, timezone log = logging.getLogger(__name__) -def _utcnow() -> datetime: - return datetime.now(timezone.utc) +def _now() -> datetime: + return datetime.now() def _check_and_fire() -> None: @@ -29,7 +29,7 @@ def _check_and_fire() -> None: log.error("scheduler: failed to load schedules: %s", e) return - now = _utcnow() + now = _now() for sched in schedules: try: _maybe_fire(sched, now) @@ -43,15 +43,15 @@ def _maybe_fire(sched: dict, now: datetime) -> None: last_str = sched["last_fired_at"] if last_str: - last_dt = datetime.fromisoformat(last_str).replace(tzinfo=timezone.utc) + # SQLite stores UTC; convert to local for cron evaluation. + last_dt = datetime.fromisoformat(last_str).replace( + tzinfo=timezone.utc).astimezone().replace(tzinfo=None) else: - # Never fired — look back 2 minutes so we pick up the current tick - # without replaying indefinitely old missed ticks. last_dt = now - timedelta(seconds=120) try: cron = croniter(sched["cron_expr"], last_dt) - next_dt = cron.get_next(datetime).replace(tzinfo=timezone.utc) + next_dt = cron.get_next(datetime) except CroniterBadCronError: log.warning("scheduler: invalid cron_expr %r for schedule id=%s — skipping", sched["cron_expr"], sched["id"]) diff --git a/pipekit/web/app.py b/pipekit/web/app.py index 19f4962..2132c65 100644 --- a/pipekit/web/app.py +++ b/pipekit/web/app.py @@ -38,7 +38,20 @@ def _fmt_duration(seconds) -> str: return f"{s // 60}m {s % 60:02d}s" +def _fmt_localtime(utc_str) -> str: + """Convert a UTC datetime string from SQLite to local time for display.""" + if not utc_str: + return "—" + from datetime import datetime, timezone + try: + dt = datetime.fromisoformat(str(utc_str)).replace(tzinfo=timezone.utc) + return dt.astimezone().strftime("%Y-%m-%d %H:%M %Z") + except (ValueError, TypeError): + return str(utc_str) + + _templates.env.filters["duration"] = _fmt_duration +_templates.env.filters["localtime"] = _fmt_localtime def mount_web(app: FastAPI) -> None: @@ -656,13 +669,13 @@ def _schedules_with_next(schedules: list[dict]) -> list[dict]: """Attach a 'next_fire_at' string to each schedule dict.""" from croniter import croniter, CroniterBadCronError from datetime import datetime, timezone - now = datetime.now(timezone.utc) + now = datetime.now() result = [] for s in schedules: s = dict(s) try: cron = croniter(s["cron_expr"], now) - s["next_fire_at"] = cron.get_next(datetime).strftime("%Y-%m-%d %H:%M UTC") + s["next_fire_at"] = cron.get_next(datetime).strftime("%Y-%m-%d %H:%M %Z") except CroniterBadCronError: s["next_fire_at"] = "invalid expression" result.append(s) diff --git a/pipekit/web/templates/_group_live.html b/pipekit/web/templates/_group_live.html index c95aab5..3216c49 100644 --- a/pipekit/web/templates/_group_live.html +++ b/pipekit/web/templates/_group_live.html @@ -28,7 +28,7 @@ {% for r in recent_runs %} #{{ r.id }} - {{ r.started_at or "—" }} + {{ r.started_at | localtime }} {{ r.duration_s | duration }} {{ r.triggered_by or "—" }} {{ r.status }} diff --git a/pipekit/web/templates/_group_run_live.html b/pipekit/web/templates/_group_run_live.html index 42768b9..3eb1105 100644 --- a/pipekit/web/templates/_group_run_live.html +++ b/pipekit/web/templates/_group_run_live.html @@ -31,7 +31,7 @@ #{{ r.id }} {{ r.module_name }} - {{ r.started_at or "—" }} + {{ r.started_at | localtime }} {{ r.duration_s | duration }} {{ r.status }} {{ r.row_count if r.row_count is not none else "—" }} diff --git a/pipekit/web/templates/_module_live.html b/pipekit/web/templates/_module_live.html index 47bd24e..821ce36 100644 --- a/pipekit/web/templates/_module_live.html +++ b/pipekit/web/templates/_module_live.html @@ -21,7 +21,7 @@ {% for r in recent_runs %} #{{ r.id }} - {{ r.started_at or '—' }} + {{ r.started_at | localtime }} {{ r.duration_s | duration }} {{ r.status }} {{ r.row_count if r.row_count is not none else "—" }} diff --git a/pipekit/web/templates/group_detail.html b/pipekit/web/templates/group_detail.html index f91482b..777c0bf 100644 --- a/pipekit/web/templates/group_detail.html +++ b/pipekit/web/templates/group_detail.html @@ -67,7 +67,7 @@ cron expression - next fire (UTC) + next fire last fired enabled @@ -77,7 +77,7 @@ {{ s.cron_expr }} {% if s.enabled %}{{ s.next_fire_at }}{% else %}—{% endif %} - {{ s.last_fired_at or "never" }} + {{ s.last_fired_at | localtime }} {% if s.enabled %} yes diff --git a/pipekit/web/templates/group_form.html b/pipekit/web/templates/group_form.html index 123f8ed..3cd1b87 100644 --- a/pipekit/web/templates/group_form.html +++ b/pipekit/web/templates/group_form.html @@ -66,7 +66,7 @@
Schedules - cron expressions in UTC — e.g. 0 4 * * * for daily at 04:00 + cron expressions in local time — e.g. 0 4 * * * for daily at 04:00
@@ -106,6 +106,25 @@ {% if not schedules %}
No schedules — group runs manually only.
{% endif %} +
+ cron expression reference + + + + + + + + + + + + +
expressionmeaning
0 * * * *every hour (on the hour)
*/15 * * * *every 15 minutes
0 4 * * *daily at 04:00
0 4 * * 1-5weekdays at 04:00
0 4 * * 6,0weekends at 04:00
0 0 * * 1every Monday at midnight
0 6,18 * * *twice daily at 06:00 and 18:00
0 0 1 * *first day of each month
+
+ Format: minute hour day-of-month month day-of-week  ·  all times local +
+
diff --git a/pipekit/web/templates/group_run_detail.html b/pipekit/web/templates/group_run_detail.html index 1abe76d..a758abd 100644 --- a/pipekit/web/templates/group_run_detail.html +++ b/pipekit/web/templates/group_run_detail.html @@ -8,7 +8,7 @@ Group run #{{ group_run.id }} {{ group_run.group_name }} · - started {{ group_run.started_at }} + started {{ group_run.started_at | localtime }} {{ group_run.status }} @@ -16,8 +16,8 @@
-
started
{{ group_run.started_at }}
-
finished
{{ group_run.finished_at or "—" }}
+
started
{{ group_run.started_at | localtime }}
+
finished
{{ group_run.finished_at | localtime }}
duration
{{ group_run.duration_s | duration }}
triggered by
{{ group_run.triggered_by or "—" }}
@@ -50,7 +50,7 @@ #{{ r.id }} {{ r.module_name }} - {{ r.started_at or "—" }} + {{ r.started_at | localtime }} {{ r.duration_s | duration }} {{ r.status }} {{ r.row_count if r.row_count is not none else "—" }} diff --git a/pipekit/web/templates/groups.html b/pipekit/web/templates/groups.html index 995bbdd..007d4a6 100644 --- a/pipekit/web/templates/groups.html +++ b/pipekit/web/templates/groups.html @@ -28,7 +28,7 @@ {{ g.name }} {{ g.member_count }} - {{ g.last_run_at or "—" }} + {{ g.last_run_at | localtime }} {% if g.last_status %} {{ g.last_status }} diff --git a/pipekit/web/templates/modules_index.html b/pipekit/web/templates/modules_index.html index 9d03972..3435fa6 100644 --- a/pipekit/web/templates/modules_index.html +++ b/pipekit/web/templates/modules_index.html @@ -39,7 +39,7 @@ {{ g.group_name }} {% endfor %} - {{ m.last_run_at or "—" }} + {{ m.last_run_at | localtime }} {% with module=m %}{% include "_module_status_pill.html" %}{% endwith %} {{ m.last_row_count if m.last_row_count is not none else "—" }} diff --git a/pipekit/web/templates/run_detail.html b/pipekit/web/templates/run_detail.html index 7c901d0..5c64b1f 100644 --- a/pipekit/web/templates/run_detail.html +++ b/pipekit/web/templates/run_detail.html @@ -8,14 +8,14 @@ Run #{{ run.id }} {{ run.module_name }} · - started {{ run.started_at }} + started {{ run.started_at | localtime }} {{ run.status }}
-
started
{{ run.started_at }}
-
finished
{{ run.finished_at or '—' }}
+
started
{{ run.started_at | localtime }}
+
finished
{{ run.finished_at | localtime }}
rows
{{ run.row_count if run.row_count is not none else '—' }}
watermarks
{{ run.watermark_values_json or '—' }}
{% if run.error %}
error
{{ run.error }}
{% endif %} diff --git a/pipekit/web/templates/runs.html b/pipekit/web/templates/runs.html index 102e840..c3ad514 100644 --- a/pipekit/web/templates/runs.html +++ b/pipekit/web/templates/runs.html @@ -33,7 +33,7 @@ #{{ r.id }} {{ r.module_name }} - {{ r.started_at or '—' }} + {{ r.started_at | localtime }} {{ r.duration_s | duration }} {{ r.status }} {{ r.row_count if r.row_count is not none else "—" }}