From 4ae0b991088727351cb4531b69d30539c8327575 Mon Sep 17 00:00:00 2001 From: Paul Trowbridge Date: Wed, 19 Aug 2026 09:08:33 -0400 Subject: [PATCH] Show cron expression and next run time on the groups page The groups list only showed last-run info, so you had to open each group to see whether it was scheduled. Attach each group's schedules (with the next fire time) in groups_index and render them as new "schedule" and "next run" columns; multiple schedules stack, disabled ones are greyed with no next fire. _schedules_with_next now also computes a relative next_fire_in label ("in 23h 43m"), shown on both the groups list and the group detail page, and drops the empty %Z from the timestamp. The groups template tolerates a missing g.schedules so the currently running process (which auto-reloads templates but not app.py) keeps rendering until it is restarted. Co-Authored-By: Claude Opus 5 --- pipekit/web/app.py | 21 ++++++++++++++++++++- pipekit/web/static/style.css | 2 ++ pipekit/web/templates/group_detail.html | 2 +- pipekit/web/templates/groups.html | 17 +++++++++++++++++ 4 files changed, 40 insertions(+), 2 deletions(-) diff --git a/pipekit/web/app.py b/pipekit/web/app.py index 4d951de..e93e90d 100644 --- a/pipekit/web/app.py +++ b/pipekit/web/app.py @@ -866,13 +866,31 @@ def _schedules_with_next(schedules: list[dict]) -> list[dict]: s = dict(s) try: cron = croniter(s["cron_expr"], now) - s["next_fire_at"] = cron.get_next(datetime).strftime("%Y-%m-%d %H:%M %Z") + nxt = cron.get_next(datetime) + s["next_fire_at"] = nxt.strftime("%Y-%m-%d %H:%M") + s["next_fire_in"] = _humanize_delta((nxt - now).total_seconds()) except CroniterBadCronError: s["next_fire_at"] = "invalid expression" + s["next_fire_in"] = None result.append(s) return result +def _humanize_delta(seconds: float) -> str: + """'in 3h 12m' style relative label for a positive second count.""" + secs = int(max(seconds, 0)) + days, rem = divmod(secs, 86400) + hours, rem = divmod(rem, 3600) + mins = rem // 60 + if days: + return f"in {days}d {hours}h" + if hours: + return f"in {hours}h {mins}m" + if mins: + return f"in {mins}m" + return "in <1m" + + def _sanitize_identifier(name: str) -> str: """Lower-case a source column name and replace characters that aren't valid in an unquoted identifier with underscores.""" @@ -1338,6 +1356,7 @@ def groups_index(request: Request): g["last_run_at"] = None g["last_status"] = None g["last_duration_s"] = None + g["schedules"] = _schedules_with_next(repo.list_schedules_for_group(g["id"])) return _templates.TemplateResponse( request, "groups.html", _ctx(groups=groups), diff --git a/pipekit/web/static/style.css b/pipekit/web/static/style.css index a862f89..5428053 100644 --- a/pipekit/web/static/style.css +++ b/pipekit/web/static/style.css @@ -162,6 +162,8 @@ table.grid tr:hover td { background: #1c2128; } } .tag:hover { color: var(--accent); } +.muted { color: var(--text-muted); } + /* Labeled key-value rows (used in detail views) */ dl.keyval { display: grid; diff --git a/pipekit/web/templates/group_detail.html b/pipekit/web/templates/group_detail.html index 777c0bf..a67e9d8 100644 --- a/pipekit/web/templates/group_detail.html +++ b/pipekit/web/templates/group_detail.html @@ -76,7 +76,7 @@ {% for s in schedules %} {{ s.cron_expr }} - {% if s.enabled %}{{ s.next_fire_at }}{% else %}—{% endif %} + {% if s.enabled %}{{ s.next_fire_at }}{% if s.next_fire_in %} ({{ s.next_fire_in }}){% endif %}{% else %}—{% endif %} {{ s.last_fired_at | localtime }} {% if s.enabled %} diff --git a/pipekit/web/templates/groups.html b/pipekit/web/templates/groups.html index 41668d1..54ed2f8 100644 --- a/pipekit/web/templates/groups.html +++ b/pipekit/web/templates/groups.html @@ -18,6 +18,8 @@ name members + schedule + next run last run duration status @@ -29,6 +31,21 @@ {{ g.name }} {{ g.member_count }} + {% set scheds = g.schedules if g.schedules is iterable and g.schedules is not string else [] %} + + {% for s in scheds %} + {{ s.cron_expr }}{% if not s.enabled %} (disabled){% endif %} + {% else %} + + {% endfor %} + + + {% for s in scheds %} +
{% if s.enabled %}{{ s.next_fire_at }}{% if s.next_fire_in %} ({{ s.next_fire_in }}){% endif %}{% else %}{% endif %}
+ {% else %} + + {% endfor %} + {{ g.last_run_at | localtime }} {{ g.last_duration_s | duration }}