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
+ add
@@ -106,6 +106,25 @@
{% if not schedules %}
No schedules — group runs manually only.
{% endif %}
+
+ cron expression reference
+
+ expression meaning
+
+ 0 * * * * every hour (on the hour)
+ */15 * * * * every 15 minutes
+ 0 4 * * * daily at 04:00
+ 0 4 * * 1-5 weekdays at 04:00
+ 0 4 * * 6,0 weekends at 04:00
+ 0 0 * * 1 every 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 "—" }}