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) <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-06-03 23:21:05 -04:00
parent 95292bd3f8
commit a66488d1f2
13 changed files with 68 additions and 27 deletions

View File

@ -4,13 +4,22 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Running the Server ## 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 ```bash
pipekit serve # default host/port from config.yaml pipekit serve # default host/port from config.yaml
pipekit serve --host 0.0.0.0 --port 8080 --reload # dev mode with auto-reload 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 ## Other CLI Commands
```bash ```bash

View File

@ -17,8 +17,8 @@ from datetime import datetime, timedelta, timezone
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
def _utcnow() -> datetime: def _now() -> datetime:
return datetime.now(timezone.utc) return datetime.now()
def _check_and_fire() -> None: def _check_and_fire() -> None:
@ -29,7 +29,7 @@ def _check_and_fire() -> None:
log.error("scheduler: failed to load schedules: %s", e) log.error("scheduler: failed to load schedules: %s", e)
return return
now = _utcnow() now = _now()
for sched in schedules: for sched in schedules:
try: try:
_maybe_fire(sched, now) _maybe_fire(sched, now)
@ -43,15 +43,15 @@ def _maybe_fire(sched: dict, now: datetime) -> None:
last_str = sched["last_fired_at"] last_str = sched["last_fired_at"]
if last_str: 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: 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) last_dt = now - timedelta(seconds=120)
try: try:
cron = croniter(sched["cron_expr"], last_dt) 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: except CroniterBadCronError:
log.warning("scheduler: invalid cron_expr %r for schedule id=%s — skipping", log.warning("scheduler: invalid cron_expr %r for schedule id=%s — skipping",
sched["cron_expr"], sched["id"]) sched["cron_expr"], sched["id"])

View File

@ -38,7 +38,20 @@ def _fmt_duration(seconds) -> str:
return f"{s // 60}m {s % 60:02d}s" 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["duration"] = _fmt_duration
_templates.env.filters["localtime"] = _fmt_localtime
def mount_web(app: FastAPI) -> None: 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.""" """Attach a 'next_fire_at' string to each schedule dict."""
from croniter import croniter, CroniterBadCronError from croniter import croniter, CroniterBadCronError
from datetime import datetime, timezone from datetime import datetime, timezone
now = datetime.now(timezone.utc) now = datetime.now()
result = [] result = []
for s in schedules: for s in schedules:
s = dict(s) s = dict(s)
try: try:
cron = croniter(s["cron_expr"], now) 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: except CroniterBadCronError:
s["next_fire_at"] = "invalid expression" s["next_fire_at"] = "invalid expression"
result.append(s) result.append(s)

View File

@ -28,7 +28,7 @@
{% for r in recent_runs %} {% for r in recent_runs %}
<tr> <tr>
<td><a href="/group-runs/{{ r.id }}">#{{ r.id }}</a></td> <td><a href="/group-runs/{{ r.id }}">#{{ r.id }}</a></td>
<td class="mono">{{ r.started_at or "—" }}</td> <td class="mono">{{ r.started_at | localtime }}</td>
<td class="mono">{{ r.duration_s | duration }}</td> <td class="mono">{{ r.duration_s | duration }}</td>
<td class="mono">{{ r.triggered_by or "—" }}</td> <td class="mono">{{ r.triggered_by or "—" }}</td>
<td><span class="pill {{ r.status }}">{{ r.status }}</span></td> <td><span class="pill {{ r.status }}">{{ r.status }}</span></td>

View File

@ -31,7 +31,7 @@
<tr> <tr>
<td><a href="/runs/{{ r.id }}">#{{ r.id }}</a></td> <td><a href="/runs/{{ r.id }}">#{{ r.id }}</a></td>
<td><a href="/modules/{{ r.module_id }}">{{ r.module_name }}</a></td> <td><a href="/modules/{{ r.module_id }}">{{ r.module_name }}</a></td>
<td class="mono">{{ r.started_at or "—" }}</td> <td class="mono">{{ r.started_at | localtime }}</td>
<td class="mono">{{ r.duration_s | duration }}</td> <td class="mono">{{ r.duration_s | duration }}</td>
<td><span class="pill {{ r.status }}">{{ r.status }}</span></td> <td><span class="pill {{ r.status }}">{{ r.status }}</span></td>
<td class="mono">{{ r.row_count if r.row_count is not none else "—" }}</td> <td class="mono">{{ r.row_count if r.row_count is not none else "—" }}</td>

View File

@ -21,7 +21,7 @@
{% for r in recent_runs %} {% for r in recent_runs %}
<tr> <tr>
<td><a href="/runs/{{ r.id }}">#{{ r.id }}</a></td> <td><a href="/runs/{{ r.id }}">#{{ r.id }}</a></td>
<td class="mono">{{ r.started_at or '—' }}</td> <td class="mono">{{ r.started_at | localtime }}</td>
<td class="mono">{{ r.duration_s | duration }}</td> <td class="mono">{{ r.duration_s | duration }}</td>
<td><span class="pill {{ r.status }}">{{ r.status }}</span></td> <td><span class="pill {{ r.status }}">{{ r.status }}</span></td>
<td class="mono">{{ r.row_count if r.row_count is not none else "—" }}</td> <td class="mono">{{ r.row_count if r.row_count is not none else "—" }}</td>

View File

@ -67,7 +67,7 @@
<thead> <thead>
<tr> <tr>
<th>cron expression</th> <th>cron expression</th>
<th>next fire (UTC)</th> <th>next fire</th>
<th>last fired</th> <th>last fired</th>
<th>enabled</th> <th>enabled</th>
</tr> </tr>
@ -77,7 +77,7 @@
<tr> <tr>
<td class="mono">{{ s.cron_expr }}</td> <td class="mono">{{ s.cron_expr }}</td>
<td class="mono">{% if s.enabled %}{{ s.next_fire_at }}{% else %}—{% endif %}</td> <td class="mono">{% if s.enabled %}{{ s.next_fire_at }}{% else %}—{% endif %}</td>
<td class="mono">{{ s.last_fired_at or "never" }}</td> <td class="mono">{{ s.last_fired_at | localtime }}</td>
<td> <td>
{% if s.enabled %} {% if s.enabled %}
<span class="pill success">yes</span> <span class="pill success">yes</span>

View File

@ -66,7 +66,7 @@
<div class="panel" style="margin-top:1rem"> <div class="panel" style="margin-top:1rem">
<header> <header>
Schedules Schedules
<span class="subtitle">cron expressions in UTC — e.g. <code>0 4 * * *</code> for daily at 04:00</span> <span class="subtitle">cron expressions in local time — e.g. <code>0 4 * * *</code> for daily at 04:00</span>
<button type="button" class="btn ghost" style="margin-left:auto" <button type="button" class="btn ghost" style="margin-left:auto"
onclick="addScheduleRow()">+ add</button> onclick="addScheduleRow()">+ add</button>
</header> </header>
@ -106,6 +106,25 @@
{% if not schedules %} {% if not schedules %}
<div id="sched-empty" class="empty" style="margin-top:0.4rem">No schedules — group runs manually only.</div> <div id="sched-empty" class="empty" style="margin-top:0.4rem">No schedules — group runs manually only.</div>
{% endif %} {% endif %}
<details style="margin-top:0.8rem">
<summary style="cursor:pointer;color:var(--text-muted);font-size:0.85em">cron expression reference</summary>
<table class="grid" style="margin-top:0.5rem;font-size:0.85em">
<thead><tr><th>expression</th><th>meaning</th></tr></thead>
<tbody>
<tr><td class="mono">0 * * * *</td><td>every hour (on the hour)</td></tr>
<tr><td class="mono">*/15 * * * *</td><td>every 15 minutes</td></tr>
<tr><td class="mono">0 4 * * *</td><td>daily at 04:00</td></tr>
<tr><td class="mono">0 4 * * 1-5</td><td>weekdays at 04:00</td></tr>
<tr><td class="mono">0 4 * * 6,0</td><td>weekends at 04:00</td></tr>
<tr><td class="mono">0 0 * * 1</td><td>every Monday at midnight</td></tr>
<tr><td class="mono">0 6,18 * * *</td><td>twice daily at 06:00 and 18:00</td></tr>
<tr><td class="mono">0 0 1 * *</td><td>first day of each month</td></tr>
</tbody>
</table>
<div style="margin-top:0.4rem;color:var(--text-muted);font-size:0.82em">
Format: <code>minute hour day-of-month month day-of-week</code> &nbsp;·&nbsp; all times local
</div>
</details>
</div> </div>
</div> </div>

View File

@ -8,7 +8,7 @@
Group run #{{ group_run.id }} Group run #{{ group_run.id }}
<span class="subtitle"> <span class="subtitle">
<a href="/groups/{{ group_run.group_id }}">{{ group_run.group_name }}</a> · <a href="/groups/{{ group_run.group_id }}">{{ group_run.group_name }}</a> ·
started {{ group_run.started_at }} started {{ group_run.started_at | localtime }}
</span> </span>
<span style="margin-left:auto"> <span style="margin-left:auto">
<span class="pill {{ group_run.status }}">{{ group_run.status }}</span> <span class="pill {{ group_run.status }}">{{ group_run.status }}</span>
@ -16,8 +16,8 @@
</header> </header>
<div class="body"> <div class="body">
<dl class="keyval"> <dl class="keyval">
<dt>started</dt> <dd class="mono">{{ group_run.started_at }}</dd> <dt>started</dt> <dd class="mono">{{ group_run.started_at | localtime }}</dd>
<dt>finished</dt> <dd class="mono">{{ group_run.finished_at or "—" }}</dd> <dt>finished</dt> <dd class="mono">{{ group_run.finished_at | localtime }}</dd>
<dt>duration</dt> <dd class="mono">{{ group_run.duration_s | duration }}</dd> <dt>duration</dt> <dd class="mono">{{ group_run.duration_s | duration }}</dd>
<dt>triggered by</dt><dd class="mono">{{ group_run.triggered_by or "—" }}</dd> <dt>triggered by</dt><dd class="mono">{{ group_run.triggered_by or "—" }}</dd>
</dl> </dl>
@ -50,7 +50,7 @@
<tr> <tr>
<td><a href="/runs/{{ r.id }}">#{{ r.id }}</a></td> <td><a href="/runs/{{ r.id }}">#{{ r.id }}</a></td>
<td><a href="/modules/{{ r.module_id }}">{{ r.module_name }}</a></td> <td><a href="/modules/{{ r.module_id }}">{{ r.module_name }}</a></td>
<td class="mono">{{ r.started_at or "—" }}</td> <td class="mono">{{ r.started_at | localtime }}</td>
<td class="mono">{{ r.duration_s | duration }}</td> <td class="mono">{{ r.duration_s | duration }}</td>
<td><span class="pill {{ r.status }}">{{ r.status }}</span></td> <td><span class="pill {{ r.status }}">{{ r.status }}</span></td>
<td class="mono">{{ r.row_count if r.row_count is not none else "—" }}</td> <td class="mono">{{ r.row_count if r.row_count is not none else "—" }}</td>

View File

@ -28,7 +28,7 @@
<tr> <tr>
<td><a href="/groups/{{ g.id }}"><strong>{{ g.name }}</strong></a></td> <td><a href="/groups/{{ g.id }}"><strong>{{ g.name }}</strong></a></td>
<td class="mono">{{ g.member_count }}</td> <td class="mono">{{ g.member_count }}</td>
<td class="mono">{{ g.last_run_at or "—" }}</td> <td class="mono">{{ g.last_run_at | localtime }}</td>
<td> <td>
{% if g.last_status %} {% if g.last_status %}
<span class="pill {{ g.last_status }}">{{ g.last_status }}</span> <span class="pill {{ g.last_status }}">{{ g.last_status }}</span>

View File

@ -39,7 +39,7 @@
<a href="/groups/{{ g.group_id }}" class="tag">{{ g.group_name }}</a> <a href="/groups/{{ g.group_id }}" class="tag">{{ g.group_name }}</a>
{% endfor %} {% endfor %}
</td> </td>
<td class="mono">{{ m.last_run_at or "—" }}</td> <td class="mono">{{ m.last_run_at | localtime }}</td>
{% with module=m %}{% include "_module_status_pill.html" %}{% endwith %} {% with module=m %}{% include "_module_status_pill.html" %}{% endwith %}
<td class="mono">{{ m.last_row_count if m.last_row_count is not none else "—" }}</td> <td class="mono">{{ m.last_row_count if m.last_row_count is not none else "—" }}</td>
<td style="text-align:right"> <td style="text-align:right">

View File

@ -8,14 +8,14 @@
Run #{{ run.id }} Run #{{ run.id }}
<span class="subtitle"> <span class="subtitle">
<a href="/modules/{{ run.module_id }}">{{ run.module_name }}</a> · <a href="/modules/{{ run.module_id }}">{{ run.module_name }}</a> ·
started {{ run.started_at }} started {{ run.started_at | localtime }}
</span> </span>
<span style="margin-left:auto"><span class="pill {{ run.status }}">{{ run.status }}</span></span> <span style="margin-left:auto"><span class="pill {{ run.status }}">{{ run.status }}</span></span>
</header> </header>
<div class="body"> <div class="body">
<dl class="keyval"> <dl class="keyval">
<dt>started</dt> <dd class="mono">{{ run.started_at }}</dd> <dt>started</dt> <dd class="mono">{{ run.started_at | localtime }}</dd>
<dt>finished</dt> <dd class="mono">{{ run.finished_at or '—' }}</dd> <dt>finished</dt> <dd class="mono">{{ run.finished_at | localtime }}</dd>
<dt>rows</dt> <dd class="mono">{{ run.row_count if run.row_count is not none else '—' }}</dd> <dt>rows</dt> <dd class="mono">{{ run.row_count if run.row_count is not none else '—' }}</dd>
<dt>watermarks</dt><dd class="mono">{{ run.watermark_values_json or '—' }}</dd> <dt>watermarks</dt><dd class="mono">{{ run.watermark_values_json or '—' }}</dd>
{% if run.error %}<dt>error</dt><dd class="mono" style="color:var(--danger)">{{ run.error }}</dd>{% endif %} {% if run.error %}<dt>error</dt><dd class="mono" style="color:var(--danger)">{{ run.error }}</dd>{% endif %}

View File

@ -33,7 +33,7 @@
<tr> <tr>
<td><a href="/runs/{{ r.id }}">#{{ r.id }}</a></td> <td><a href="/runs/{{ r.id }}">#{{ r.id }}</a></td>
<td><a href="/modules/{{ r.module_id }}">{{ r.module_name }}</a></td> <td><a href="/modules/{{ r.module_id }}">{{ r.module_name }}</a></td>
<td class="mono">{{ r.started_at or '—' }}</td> <td class="mono">{{ r.started_at | localtime }}</td>
<td class="mono">{{ r.duration_s | duration }}</td> <td class="mono">{{ r.duration_s | duration }}</td>
<td><span class="pill {{ r.status }}">{{ r.status }}</span></td> <td><span class="pill {{ r.status }}">{{ r.status }}</span></td>
<td class="mono">{{ r.row_count if r.row_count is not none else "—" }}</td> <td class="mono">{{ r.row_count if r.row_count is not none else "—" }}</td>