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")
|
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)
|
||||||
|
|||||||
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 }}
|
{{ 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 %}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user