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 <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-08-19 09:08:33 -04:00
parent 0f30c4b921
commit 4ae0b99108
4 changed files with 40 additions and 2 deletions

View File

@ -866,13 +866,31 @@ def _schedules_with_next(schedules: list[dict]) -> list[dict]:
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 %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: except CroniterBadCronError:
s["next_fire_at"] = "invalid expression" s["next_fire_at"] = "invalid expression"
s["next_fire_in"] = None
result.append(s) result.append(s)
return result 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: def _sanitize_identifier(name: str) -> str:
"""Lower-case a source column name and replace characters that aren't """Lower-case a source column name and replace characters that aren't
valid in an unquoted identifier with underscores.""" valid in an unquoted identifier with underscores."""
@ -1338,6 +1356,7 @@ def groups_index(request: Request):
g["last_run_at"] = None g["last_run_at"] = None
g["last_status"] = None g["last_status"] = None
g["last_duration_s"] = None g["last_duration_s"] = None
g["schedules"] = _schedules_with_next(repo.list_schedules_for_group(g["id"]))
return _templates.TemplateResponse( return _templates.TemplateResponse(
request, "groups.html", request, "groups.html",
_ctx(groups=groups), _ctx(groups=groups),

View File

@ -162,6 +162,8 @@ table.grid tr:hover td { background: #1c2128; }
} }
.tag:hover { color: var(--accent); } .tag:hover { color: var(--accent); }
.muted { color: var(--text-muted); }
/* Labeled key-value rows (used in detail views) */ /* Labeled key-value rows (used in detail views) */
dl.keyval { dl.keyval {
display: grid; display: grid;

View File

@ -76,7 +76,7 @@
{% for s in schedules %} {% for s in schedules %}
<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 }}{% if s.next_fire_in %} <span class="muted">({{ s.next_fire_in }})</span>{% endif %}{% else %}—{% endif %}</td>
<td class="mono">{{ s.last_fired_at | localtime }}</td> <td class="mono">{{ s.last_fired_at | localtime }}</td>
<td> <td>
{% if s.enabled %} {% if s.enabled %}

View File

@ -18,6 +18,8 @@
<tr> <tr>
<th>name</th> <th>name</th>
<th>members</th> <th>members</th>
<th>schedule</th>
<th>next run</th>
<th>last run</th> <th>last run</th>
<th style="width:7em;text-align:right">duration</th> <th style="width:7em;text-align:right">duration</th>
<th style="width:9em">status</th> <th style="width:9em">status</th>
@ -29,6 +31,21 @@
<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>
{% set scheds = g.schedules if g.schedules is iterable and g.schedules is not string else [] %}
<td class="mono">
{% for s in scheds %}
<div{% if not s.enabled %} class="muted"{% endif %}>{{ s.cron_expr }}{% if not s.enabled %} (disabled){% endif %}</div>
{% else %}
<span class="muted"></span>
{% endfor %}
</td>
<td class="mono">
{% for s in scheds %}
<div>{% if s.enabled %}{{ s.next_fire_at }}{% if s.next_fire_in %} <span class="muted">({{ s.next_fire_in }})</span>{% endif %}{% else %}<span class="muted"></span>{% endif %}</div>
{% else %}
<span class="muted"></span>
{% endfor %}
</td>
<td class="mono">{{ g.last_run_at | localtime }}</td> <td class="mono">{{ g.last_run_at | localtime }}</td>
<td class="mono" style="text-align:right">{{ g.last_duration_s | duration }}</td> <td class="mono" style="text-align:right">{{ g.last_duration_s | duration }}</td>
<td> <td>