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:
parent
95292bd3f8
commit
a66488d1f2
13
CLAUDE.md
13
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
|
||||
|
||||
@ -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"])
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -28,7 +28,7 @@
|
||||
{% for r in recent_runs %}
|
||||
<tr>
|
||||
<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.triggered_by or "—" }}</td>
|
||||
<td><span class="pill {{ r.status }}">{{ r.status }}</span></td>
|
||||
|
||||
@ -31,7 +31,7 @@
|
||||
<tr>
|
||||
<td><a href="/runs/{{ r.id }}">#{{ r.id }}</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><span class="pill {{ r.status }}">{{ r.status }}</span></td>
|
||||
<td class="mono">{{ r.row_count if r.row_count is not none else "—" }}</td>
|
||||
|
||||
@ -21,7 +21,7 @@
|
||||
{% for r in recent_runs %}
|
||||
<tr>
|
||||
<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><span class="pill {{ r.status }}">{{ r.status }}</span></td>
|
||||
<td class="mono">{{ r.row_count if r.row_count is not none else "—" }}</td>
|
||||
|
||||
@ -67,7 +67,7 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>cron expression</th>
|
||||
<th>next fire (UTC)</th>
|
||||
<th>next fire</th>
|
||||
<th>last fired</th>
|
||||
<th>enabled</th>
|
||||
</tr>
|
||||
@ -77,7 +77,7 @@
|
||||
<tr>
|
||||
<td class="mono">{{ s.cron_expr }}</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>
|
||||
{% if s.enabled %}
|
||||
<span class="pill success">yes</span>
|
||||
|
||||
@ -66,7 +66,7 @@
|
||||
<div class="panel" style="margin-top:1rem">
|
||||
<header>
|
||||
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"
|
||||
onclick="addScheduleRow()">+ add</button>
|
||||
</header>
|
||||
@ -106,6 +106,25 @@
|
||||
{% if not schedules %}
|
||||
<div id="sched-empty" class="empty" style="margin-top:0.4rem">No schedules — group runs manually only.</div>
|
||||
{% 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> · all times local
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
Group run #{{ group_run.id }}
|
||||
<span class="subtitle">
|
||||
<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 style="margin-left:auto">
|
||||
<span class="pill {{ group_run.status }}">{{ group_run.status }}</span>
|
||||
@ -16,8 +16,8 @@
|
||||
</header>
|
||||
<div class="body">
|
||||
<dl class="keyval">
|
||||
<dt>started</dt> <dd class="mono">{{ group_run.started_at }}</dd>
|
||||
<dt>finished</dt> <dd class="mono">{{ group_run.finished_at or "—" }}</dd>
|
||||
<dt>started</dt> <dd class="mono">{{ group_run.started_at | localtime }}</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>triggered by</dt><dd class="mono">{{ group_run.triggered_by or "—" }}</dd>
|
||||
</dl>
|
||||
@ -50,7 +50,7 @@
|
||||
<tr>
|
||||
<td><a href="/runs/{{ r.id }}">#{{ r.id }}</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><span class="pill {{ r.status }}">{{ r.status }}</span></td>
|
||||
<td class="mono">{{ r.row_count if r.row_count is not none else "—" }}</td>
|
||||
|
||||
@ -28,7 +28,7 @@
|
||||
<tr>
|
||||
<td><a href="/groups/{{ g.id }}"><strong>{{ g.name }}</strong></a></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>
|
||||
{% if g.last_status %}
|
||||
<span class="pill {{ g.last_status }}">{{ g.last_status }}</span>
|
||||
|
||||
@ -39,7 +39,7 @@
|
||||
<a href="/groups/{{ g.group_id }}" class="tag">{{ g.group_name }}</a>
|
||||
{% endfor %}
|
||||
</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 %}
|
||||
<td class="mono">{{ m.last_row_count if m.last_row_count is not none else "—" }}</td>
|
||||
<td style="text-align:right">
|
||||
|
||||
@ -8,14 +8,14 @@
|
||||
Run #{{ run.id }}
|
||||
<span class="subtitle">
|
||||
<a href="/modules/{{ run.module_id }}">{{ run.module_name }}</a> ·
|
||||
started {{ run.started_at }}
|
||||
started {{ run.started_at | localtime }}
|
||||
</span>
|
||||
<span style="margin-left:auto"><span class="pill {{ run.status }}">{{ run.status }}</span></span>
|
||||
</header>
|
||||
<div class="body">
|
||||
<dl class="keyval">
|
||||
<dt>started</dt> <dd class="mono">{{ run.started_at }}</dd>
|
||||
<dt>finished</dt> <dd class="mono">{{ run.finished_at or '—' }}</dd>
|
||||
<dt>started</dt> <dd class="mono">{{ run.started_at | localtime }}</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>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 %}
|
||||
|
||||
@ -33,7 +33,7 @@
|
||||
<tr>
|
||||
<td><a href="/runs/{{ r.id }}">#{{ r.id }}</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><span class="pill {{ r.status }}">{{ r.status }}</span></td>
|
||||
<td class="mono">{{ r.row_count if r.row_count is not none else "—" }}</td>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user