The group pages were browser-only, so external schedulers had no way to
trigger a group or read its outcome. Adds /api/groups, /api/group-runs and
their run/list endpoints under Basic auth, following the same async contract
as POST /modules/{id}/run.
Cancellation needed more than an endpoint. run_group deliberately continues
past module failures, so killing the in-flight jrunner alone would just let
the loop march on to the next member. A group-level flag in engine/cancel.py
is now checked before each member, and engine.request_group_cancel sets that
flag *and* signals the running module -- it lives in runner.py rather than
cancel.py because finding the live run needs the DB, and cancel.py is
deliberately DB-free.
Group final status gains 'cancelled', ranked above 'error' since the error is
usually the terminated jrunner process. group_run.status already permitted the
value; no migration needed.
Also adds a "cancel group" button to the group run page (web) and .pill
styling for cancelled/dry_run, neither of which had a color rule.
Note: /runs/{id}/cancel keeps its existing meaning -- cancel one member and
let the group continue -- which is now a deliberate contrast to cancel group.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
56 lines
2.4 KiB
HTML
56 lines
2.4 KiB
HTML
{# Partial: module runs table for group_run_detail.html. Polls while running. #}
|
|
<div id="group-run-live"
|
|
{% if group_run.status == 'running' %}
|
|
hx-get="/group-runs/{{ group_run.id }}/live"
|
|
hx-trigger="every 3s"
|
|
hx-swap="outerHTML"
|
|
{% endif %}>
|
|
<div class="panel">
|
|
<header>
|
|
Module runs
|
|
<span style="margin-left:auto">
|
|
{% if group_run.status == 'running' %}
|
|
<button class="btn ghost" style="margin-right:0.5rem"
|
|
hx-post="/group-runs/{{ group_run.id }}/cancel"
|
|
hx-target="#group-run-live" hx-swap="outerHTML"
|
|
hx-confirm="Cancel this group run? The module in flight is stopped and the remaining modules are skipped.">cancel group</button>
|
|
{% endif %}
|
|
<span class="pill {{ group_run.status }}">{{ group_run.status }}</span>
|
|
{% if group_run.duration_s is not none %} · {{ group_run.duration_s | duration }}{% endif %}
|
|
</span>
|
|
</header>
|
|
<div class="body tight">
|
|
{% if module_runs %}
|
|
<table class="grid">
|
|
<thead>
|
|
<tr>
|
|
<th>run</th>
|
|
<th>module</th>
|
|
<th>started</th>
|
|
<th>duration</th>
|
|
<th>status</th>
|
|
<th>rows</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for r in module_runs %}
|
|
<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 | 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>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
{% elif group_run.status == 'running' %}
|
|
<div class="empty">Waiting for module runs to start…</div>
|
|
{% else %}
|
|
<div class="empty">No module runs recorded.</div>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
</div>
|