Run buttons stay on page with live status updates via HTMX polling

Module detail and index Run/Dry run buttons no longer redirect to the
run page. The status cell (index) and recent runs panel (detail) poll
every 3s while running and stop automatically when idle. force_poll
ensures polling starts immediately after clicking Run despite the race
between the HTTP response and the background task setting running=1.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-05-22 12:31:14 -04:00
parent bb4f7712d2
commit 780f73021c
5 changed files with 125 additions and 43 deletions

View File

@ -232,9 +232,51 @@ async def module_run_action(module_id: int, request: Request,
raise HTTPException(404, f"module id={module_id} not found") raise HTTPException(404, f"module id={module_id} not found")
run_id = repo.create_run(module_id) run_id = repo.create_run(module_id)
background.add_task(_run_in_background, module_id, run_id, dry) background.add_task(_run_in_background, module_id, run_id, dry)
if request.headers.get("HX-Request"):
hx_target = request.headers.get("HX-Target", "")
if hx_target.startswith("module-status-"):
return _module_status_pill_response(request, module_id, force_poll=True)
module = repo.get_module(module_id)
recent_runs = repo.list_runs(module_id=module_id, limit=10,
exclude_status="dry_run")
return _templates.TemplateResponse(
request, "_module_live.html",
_ctx(module=module, recent_runs=recent_runs, force_poll=True))
return RedirectResponse(url=f"/runs/{run_id}", status_code=303) return RedirectResponse(url=f"/runs/{run_id}", status_code=303)
@_router.get("/modules/{module_id}/live-fragment", response_class=HTMLResponse)
def module_live_fragment(request: Request, module_id: int):
module = repo.get_module(module_id)
if module is None:
raise HTTPException(404, f"module id={module_id} not found")
recent_runs = repo.list_runs(module_id=module_id, limit=10,
exclude_status="dry_run")
return _templates.TemplateResponse(
request, "_module_live.html",
_ctx(module=module, recent_runs=recent_runs, force_poll=False))
@_router.get("/modules/{module_id}/status-pill", response_class=HTMLResponse)
def module_status_pill(request: Request, module_id: int):
if repo.get_module(module_id) is None:
raise HTTPException(404, f"module id={module_id} not found")
return _module_status_pill_response(request, module_id)
def _module_status_pill_response(request: Request, module_id: int,
force_poll: bool = False):
module = repo.get_module(module_id)
recent = repo.list_runs(module_id=module_id, limit=1)
if recent:
module["last_status"] = recent[0]["status"]
else:
module["last_status"] = None
return _templates.TemplateResponse(
request, "_module_status_pill.html",
_ctx(module=module, force_poll=force_poll))
def _run_in_background(module_id: int, run_id: int, dry_run: bool) -> None: def _run_in_background(module_id: int, run_id: int, dry_run: bool) -> None:
try: try:
engine.run_module(module_id, run_id=run_id, dry_run=dry_run) engine.run_module(module_id, run_id=run_id, dry_run=dry_run)

View File

@ -0,0 +1,43 @@
{# Partial: recent runs panel + out-of-band status pill update.
Rendered by both module_detail.html (on load) and the live-fragment endpoint.
Polls every 3s while running; stops automatically when idle. #}
<div id="module-live"
{% if module.running or force_poll %}
hx-get="/modules/{{ module.id }}/live-fragment"
hx-trigger="every 3s"
hx-swap="outerHTML"
{% endif %}>
<div class="panel">
<header>Recent runs
<span class="subtitle">last {{ recent_runs|length }}</span>
<span style="margin-left:auto"><a href="/runs?module_id={{ module.id }}">all →</a></span>
</header>
<div class="body tight">
{% if recent_runs %}
<table class="grid">
<thead><tr><th>id</th><th>started</th><th>duration</th><th>status</th><th>rows</th></tr></thead>
<tbody>
{% for r in recent_runs %}
<tr>
<td><a href="/runs/{{ r.id }}">#{{ r.id }}</a></td>
<td class="mono">{{ r.started_at or '—' }}</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>
{% else %}
<div class="empty">No runs yet.</div>
{% endif %}
</div>
</div>
</div>
{# Out-of-band: keep the status pills in the page header in sync #}
<span id="module-pills" hx-swap-oob="true">
{% if module.running %}<span class="pill running">running</span>{% endif %}
{% if not module.enabled %}<span class="pill disabled">disabled</span>{% endif %}
</span>

View File

@ -0,0 +1,18 @@
{# Partial: status cell for one module row on the index page.
Swaps itself (outerHTML) every 3s while running; stops when idle. #}
<td id="module-status-{{ module.id }}"
{% if module.running or force_poll %}
hx-get="/modules/{{ module.id }}/status-pill"
hx-trigger="every 3s"
hx-swap="outerHTML"
{% endif %}>
{% if module.running %}
<span class="pill running">running</span>
{% elif not module.enabled %}
<span class="pill disabled">disabled</span>
{% elif module.last_status %}
<span class="pill {{ module.last_status }}">{{ module.last_status }}</span>
{% else %}
<span class="pill">never ran</span>
{% endif %}
</td>

View File

@ -8,14 +8,22 @@
{{ module.name }} {{ module.name }}
<span class="subtitle"> <span class="subtitle">
module #{{ module.id }} module #{{ module.id }}
<span id="module-pills">
{% if module.running %}<span class="pill running">running</span>{% endif %} {% if module.running %}<span class="pill running">running</span>{% endif %}
{% if not module.enabled %}<span class="pill disabled">disabled</span>{% endif %} {% if not module.enabled %}<span class="pill disabled">disabled</span>{% endif %}
</span> </span>
</span>
<span style="margin-left:auto" class="actions"> <span style="margin-left:auto" class="actions">
<form class="inline" method="post" action="/modules/{{ module.id }}/run"> <form class="inline"
hx-post="/modules/{{ module.id }}/run"
hx-target="#module-live"
hx-swap="outerHTML">
<button class="primary" type="submit">Run now</button> <button class="primary" type="submit">Run now</button>
</form> </form>
<form class="inline" method="post" action="/modules/{{ module.id }}/run"> <form class="inline"
hx-post="/modules/{{ module.id }}/run"
hx-target="#module-live"
hx-swap="outerHTML">
<input type="hidden" name="dry_run" value="1"> <input type="hidden" name="dry_run" value="1">
<button type="submit">Dry run</button> <button type="submit">Dry run</button>
</form> </form>
@ -179,32 +187,7 @@
</div> </div>
</div> </div>
<div class="panel"> {% include "_module_live.html" %}
<header>Recent runs
<span class="subtitle">last {{ recent_runs|length }}</span>
<span style="margin-left:auto"><a href="/runs?module_id={{ module.id }}">all →</a></span>
</header>
<div class="body tight">
{% if recent_runs %}
<table class="grid">
<thead><tr><th>id</th><th>started</th><th>duration</th><th>status</th><th>rows</th></tr></thead>
<tbody>
{% for r in recent_runs %}
<tr>
<td><a href="/runs/{{ r.id }}">#{{ r.id }}</a></td>
<td class="mono">{{ r.started_at or '—' }}</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>
{% else %}
<div class="empty">No runs yet.</div>
{% endif %}
</div>
</div>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -34,23 +34,19 @@
<td class="mono">{{ m.merge_strategy }}</td> <td class="mono">{{ m.merge_strategy }}</td>
<td class="mono">{{ m.dest_table }}</td> <td class="mono">{{ m.dest_table }}</td>
<td class="mono">{{ m.last_run_at or "—" }}</td> <td class="mono">{{ m.last_run_at or "—" }}</td>
<td> {% with module=m %}{% include "_module_status_pill.html" %}{% endwith %}
{% if m.running %}
<span class="pill running">running</span>
{% elif not m.enabled %}
<span class="pill disabled">disabled</span>
{% elif m.last_status %}
<span class="pill {{ m.last_status }}">{{ m.last_status }}</span>
{% else %}
<span class="pill">never ran</span>
{% endif %}
</td>
<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">
<form class="inline" method="post" action="/modules/{{ m.id }}/run"> <form class="inline"
hx-post="/modules/{{ m.id }}/run"
hx-target="#module-status-{{ m.id }}"
hx-swap="outerHTML">
<button type="submit">Run</button> <button type="submit">Run</button>
</form> </form>
<form class="inline" method="post" action="/modules/{{ m.id }}/run"> <form class="inline"
hx-post="/modules/{{ m.id }}/run"
hx-target="#module-status-{{ m.id }}"
hx-swap="outerHTML">
<input type="hidden" name="dry_run" value="1"> <input type="hidden" name="dry_run" value="1">
<button type="submit" class="ghost">Dry run</button> <button type="submit" class="ghost">Dry run</button>
</form> </form>