Merge branch 'localtime'

- Convert all timestamps to local time for display and scheduling
- deploy.sh: detect JAVA_HOME and inject into systemd unit at deploy time
- repo: add duration_s to get_group_run query
This commit is contained in:
Paul Trowbridge 2026-06-03 23:49:43 -04:00
commit a6cc8da83f
15 changed files with 96 additions and 29 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

@ -182,8 +182,31 @@ step "Systemd unit"
if [ ! -f "$UNIT_SRC" ]; then if [ ! -f "$UNIT_SRC" ]; then
echo " WARNING: $UNIT_SRC not found — skipping" echo " WARNING: $UNIT_SRC not found — skipping"
else else
# Detect JAVA_HOME — check PATH first, then search each location individually
JAVA_BIN="$(command -v java 2>/dev/null || true)"
if [ -z "$JAVA_BIN" ]; then
for _dir in /opt /usr/lib/jvm /usr/local/lib/jvm; do
[ -d "$_dir" ] || continue
JAVA_BIN="$(find "$_dir" -maxdepth 3 -name java -type f 2>/dev/null | head -1 || true)"
[ -n "$JAVA_BIN" ] && break
done
fi
if [ -z "$JAVA_BIN" ]; then
echo " WARNING: java not found — JAVA_HOME will not be set in unit"
JAVA_HOME_DETECTED=""
else
JAVA_HOME_DETECTED="$(dirname "$(dirname "$(readlink -f "$JAVA_BIN")")")"
echo " Detected JAVA_HOME: $JAVA_HOME_DETECTED"
fi
echo " Installing $UNIT_DST" echo " Installing $UNIT_DST"
if [ -n "$JAVA_HOME_DETECTED" ]; then
# Inject Environment lines after WorkingDirectory= using printf to handle newlines portably
ENV_BLOCK="$(printf 'Environment=JAVA_HOME=%s\nEnvironment=PATH=%s/bin:/usr/local/bin:/usr/bin:/bin' "$JAVA_HOME_DETECTED" "$JAVA_HOME_DETECTED")"
awk -v env="$ENV_BLOCK" '/^WorkingDirectory=/{print; print env; next}1' "$UNIT_SRC" > "$UNIT_DST"
else
cp "$UNIT_SRC" "$UNIT_DST" cp "$UNIT_SRC" "$UNIT_DST"
fi
echo " Running systemctl daemon-reload" echo " Running systemctl daemon-reload"
systemctl daemon-reload systemctl daemon-reload
echo " Enabling $SERVICE_NAME service" echo " Enabling $SERVICE_NAME service"

View File

@ -662,7 +662,10 @@ def create_group_run(group_id: int, *, triggered_by: str | None = None) -> int:
def get_group_run(group_run_id: int) -> dict | None: def get_group_run(group_run_id: int) -> dict | None:
with db.connect() as c: with db.connect() as c:
return _row(c.execute( return _row(c.execute(
"SELECT gr.*, g.name AS group_name " "SELECT gr.*, g.name AS group_name, "
"CASE WHEN gr.started_at IS NOT NULL AND gr.finished_at IS NOT NULL "
"THEN CAST(ROUND((julianday(gr.finished_at) - julianday(gr.started_at)) * 86400) AS INTEGER) "
"ELSE NULL END AS duration_s "
"FROM group_run gr " "FROM group_run gr "
"JOIN grp g ON gr.group_id=g.id " "JOIN grp g ON gr.group_id=g.id "
"WHERE gr.id=?", "WHERE gr.id=?",

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>