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:
parent
0f30c4b921
commit
4ae0b99108
@ -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),
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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 %}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user