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:
parent
bb4f7712d2
commit
780f73021c
@ -232,9 +232,51 @@ async def module_run_action(module_id: int, request: Request,
|
||||
raise HTTPException(404, f"module id={module_id} not found")
|
||||
run_id = repo.create_run(module_id)
|
||||
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)
|
||||
|
||||
|
||||
@_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:
|
||||
try:
|
||||
engine.run_module(module_id, run_id=run_id, dry_run=dry_run)
|
||||
|
||||
43
pipekit/web/templates/_module_live.html
Normal file
43
pipekit/web/templates/_module_live.html
Normal 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>
|
||||
18
pipekit/web/templates/_module_status_pill.html
Normal file
18
pipekit/web/templates/_module_status_pill.html
Normal 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>
|
||||
@ -8,14 +8,22 @@
|
||||
{{ module.name }}
|
||||
<span class="subtitle">
|
||||
module #{{ module.id }}
|
||||
<span id="module-pills">
|
||||
{% if module.running %}<span class="pill running">running</span>{% endif %}
|
||||
{% if not module.enabled %}<span class="pill disabled">disabled</span>{% endif %}
|
||||
</span>
|
||||
</span>
|
||||
<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>
|
||||
</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">
|
||||
<button type="submit">Dry run</button>
|
||||
</form>
|
||||
@ -179,32 +187,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
{% include "_module_live.html" %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@ -34,23 +34,19 @@
|
||||
<td class="mono">{{ m.merge_strategy }}</td>
|
||||
<td class="mono">{{ m.dest_table }}</td>
|
||||
<td class="mono">{{ m.last_run_at or "—" }}</td>
|
||||
<td>
|
||||
{% 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>
|
||||
{% with module=m %}{% include "_module_status_pill.html" %}{% endwith %}
|
||||
<td class="mono">{{ m.last_row_count if m.last_row_count is not none else "—" }}</td>
|
||||
<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>
|
||||
</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">
|
||||
<button type="submit" class="ghost">Dry run</button>
|
||||
</form>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user