Add group runs and fix wizard identifier sanitization for spaced column names

Groups allow multiple modules to be run sequentially in a defined order.
Adds full CRUD (repo, engine orchestrator, web routes, templates) for grp,
group_member, and group_run tables that were previously schema-only. Module
index now shows group membership badges per module. Wizard default dest name
now sanitizes source column names with spaces or special characters to valid
identifiers rather than failing at CREATE TABLE time.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-06-02 00:05:37 -04:00
parent 70e4d79edf
commit 31d670b4e6
13 changed files with 807 additions and 3 deletions

View File

@ -1,3 +1,3 @@
from .runner import LockBusy, RunOutcome, run_module from .runner import GroupRunOutcome, LockBusy, RunOutcome, run_group, run_module
__all__ = ["LockBusy", "RunOutcome", "run_module"] __all__ = ["GroupRunOutcome", "LockBusy", "RunOutcome", "run_group", "run_module"]

View File

@ -33,6 +33,13 @@ class RunOutcome:
merge_sql: str | None merge_sql: str | None
@dataclass
class GroupRunOutcome:
group_run_id: int
status: str # success | error | dry_run
module_outcomes: list
class LockBusy(RuntimeError): class LockBusy(RuntimeError):
"""Raised when a module is already running.""" """Raised when a module is already running."""
@ -150,6 +157,49 @@ def run_module(module_id: int, *, group_run_id: int | None = None,
repo.release_module_lock(module_id) repo.release_module_lock(module_id)
def run_group(group_id: int, *, dry_run: bool = False,
group_run_id: int | None = None) -> GroupRunOutcome:
"""Run all enabled members of a group sequentially in run_order.
Continues past individual module failures so partial progress is visible.
Final status: dry_run if all dry_run; error if any errored; success otherwise.
"""
grp = repo.get_group(group_id)
if grp is None:
raise ValueError(f"group id={group_id} not found")
if group_run_id is None:
group_run_id = repo.create_group_run(group_id, triggered_by="manual")
members = [m for m in repo.list_group_members(group_id) if m["module_enabled"]]
outcomes: list[RunOutcome] = []
for member in members:
try:
outcome = run_module(
member["module_id"],
group_run_id=group_run_id,
dry_run=dry_run,
)
except LockBusy as e:
run_id = repo.create_run(member["module_id"], group_run_id=group_run_id)
repo.finish_run(run_id, status="error", error=str(e))
outcome = RunOutcome(run_id, "error", None, str(e), None, None)
outcomes.append(outcome)
if not outcomes:
final = "dry_run" if dry_run else "success"
elif all(o.status == "dry_run" for o in outcomes):
final = "dry_run"
elif any(o.status == "error" for o in outcomes):
final = "error"
else:
final = "success"
repo.finish_group_run(group_run_id, status=final)
return GroupRunOutcome(group_run_id, final, outcomes)
def _run_hooks(module_id: int, *, fail_fast: bool, run_on_set: set[str]) -> str: def _run_hooks(module_id: int, *, fail_fast: bool, run_on_set: set[str]) -> str:
"""Run hooks whose ``run_on`` is in run_on_set. Returns a text log.""" """Run hooks whose ``run_on`` is in run_on_set. Returns a text log."""
hooks = [h for h in repo.list_hooks(module_id) if h["run_on"] in run_on_set] hooks = [h for h in repo.list_hooks(module_id) if h["run_on"] in run_on_set]

View File

@ -482,6 +482,144 @@ def set_setting(key: str, value: str) -> None:
) )
# ---------------------------------------------------------------------------
# Groups
# ---------------------------------------------------------------------------
def create_group(*, name: str) -> dict:
with db.connect() as c:
cur = c.execute("INSERT INTO grp (name) VALUES (?)", (name,))
return _row(c.execute("SELECT * FROM grp WHERE id=?", (cur.lastrowid,)).fetchone())
def get_group(group_id: int) -> dict | None:
with db.connect() as c:
return _row(c.execute("SELECT * FROM grp WHERE id=?", (group_id,)).fetchone())
def get_group_by_name(name: str) -> dict | None:
with db.connect() as c:
return _row(c.execute("SELECT * FROM grp WHERE name=?", (name,)).fetchone())
def list_groups() -> list[dict]:
with db.connect() as c:
return [dict(r) for r in c.execute("SELECT * FROM grp ORDER BY name")]
def update_group(group_id: int, *, name: str) -> dict | None:
with db.connect() as c:
c.execute("UPDATE grp SET name=? WHERE id=?", (name, group_id))
return get_group(group_id)
def delete_group(group_id: int) -> bool:
with db.connect() as c:
cur = c.execute("DELETE FROM grp WHERE id=?", (group_id,))
return cur.rowcount > 0
# ---------------------------------------------------------------------------
# Group members
# ---------------------------------------------------------------------------
def list_group_members(group_id: int) -> list[dict]:
with db.connect() as c:
return [dict(r) for r in c.execute(
"SELECT gm.*, m.name AS module_name, m.enabled AS module_enabled "
"FROM group_member gm "
"JOIN module m ON gm.module_id=m.id "
"WHERE gm.group_id=? "
"ORDER BY gm.run_order, m.name",
(group_id,))]
def module_group_map() -> dict[int, list[dict]]:
"""Return {module_id: [{group_id, group_name}, ...]} for all members."""
with db.connect() as c:
rows = c.execute(
"SELECT gm.module_id, g.id AS group_id, g.name AS group_name "
"FROM group_member gm "
"JOIN grp g ON gm.group_id=g.id "
"ORDER BY g.name"
).fetchall()
result: dict[int, list[dict]] = {}
for r in rows:
result.setdefault(r["module_id"], []).append(
{"group_id": r["group_id"], "group_name": r["group_name"]}
)
return result
def set_group_members(group_id: int, members: list[dict]) -> None:
"""Replace all members for a group. members: list of {module_id, run_order}."""
with db.connect() as c:
c.execute("DELETE FROM group_member WHERE group_id=?", (group_id,))
for m in members:
c.execute(
"INSERT INTO group_member (group_id, module_id, run_order) "
"VALUES (?, ?, ?)",
(group_id, m["module_id"], m.get("run_order", 0)),
)
# ---------------------------------------------------------------------------
# Group runs
# ---------------------------------------------------------------------------
def create_group_run(group_id: int, *, triggered_by: str | None = None) -> int:
with db.connect() as c:
cur = c.execute(
"INSERT INTO group_run (group_id, triggered_by) VALUES (?, ?)",
(group_id, triggered_by),
)
return int(cur.lastrowid)
def get_group_run(group_run_id: int) -> dict | None:
with db.connect() as c:
return _row(c.execute(
"SELECT gr.*, g.name AS group_name "
"FROM group_run gr "
"JOIN grp g ON gr.group_id=g.id "
"WHERE gr.id=?",
(group_run_id,)).fetchone())
def finish_group_run(group_run_id: int, *, status: str) -> None:
with db.connect() as c:
c.execute(
"UPDATE group_run SET finished_at=datetime('now'), status=? WHERE id=?",
(status, group_run_id),
)
def list_group_runs(group_id: int, limit: int = 20) -> list[dict]:
with db.connect() as c:
return [dict(r) for r in c.execute(
"SELECT gr.*, "
"CASE WHEN gr.started_at IS NOT NULL AND gr.finished_at IS NOT NULL "
"THEN CAST(ROUND((julianday(gr.finished_at) - julianday(gr.started_at)) * 86400) AS INTEGER) "
"ELSE NULL END AS duration_s "
"FROM group_run gr "
"WHERE gr.group_id=? ORDER BY gr.id DESC LIMIT ?",
(group_id, limit))]
def list_runs_for_group_run(group_run_id: int) -> list[dict]:
with db.connect() as c:
return [dict(r) for r in c.execute(
"SELECT r.*, m.name AS module_name, "
"CASE WHEN r.started_at IS NOT NULL AND r.finished_at IS NOT NULL "
"THEN CAST(ROUND((julianday(r.finished_at) - julianday(r.started_at)) * 86400) AS INTEGER) "
"ELSE NULL END AS duration_s "
"FROM run_log r "
"LEFT JOIN module m ON r.module_id=m.id "
"WHERE r.group_run_id=? "
"ORDER BY r.id",
(group_run_id,))]
def list_runs(*, module_id: int | None = None, status: str | None = None, def list_runs(*, module_id: int | None = None, status: str | None = None,
exclude_status: str | None = None, limit: int = 50) -> list[dict]: exclude_status: str | None = None, limit: int = 50) -> list[dict]:
where, params = [], [] where, params = [], []

View File

@ -63,6 +63,7 @@ def home(request: Request):
modules = repo.list_modules() modules = repo.list_modules()
conns_by_id = {c["id"]: c for c in repo.list_connections()} conns_by_id = {c["id"]: c for c in repo.list_connections()}
drivers_by_id = {d["id"]: d for d in repo.list_drivers()} drivers_by_id = {d["id"]: d for d in repo.list_drivers()}
groups_by_module = repo.module_group_map()
# attach last-run summary to each module # attach last-run summary to each module
for m in modules: for m in modules:
@ -76,6 +77,7 @@ def home(request: Request):
m["last_run_at"] = None m["last_run_at"] = None
m["last_status"] = None m["last_status"] = None
m["last_row_count"] = None m["last_row_count"] = None
m["groups"] = groups_by_module.get(m["id"], [])
# group by source connection # group by source connection
grouped: dict[tuple[str, str], list] = {} grouped: dict[tuple[str, str], list] = {}
@ -405,7 +407,7 @@ def wizard_step3(request: Request,
try: try:
for c in drv.get_columns(conn, table, **qvals): for c in drv.get_columns(conn, table, **qvals):
d = c.to_dict() d = c.to_dict()
d["default_dest_name"] = c.name.lower() d["default_dest_name"] = _sanitize_identifier(c.name)
d["default_dest_type"] = drv.map_type(c.type_raw) d["default_dest_type"] = drv.map_type(c.type_raw)
d["default_description"] = c.description or "" d["default_description"] = c.description or ""
columns.append(d) columns.append(d)
@ -650,6 +652,17 @@ def _save_inline_watermarks(form, module_id: int) -> None:
default_value=default_val) default_value=default_val)
def _sanitize_identifier(name: str) -> str:
"""Lower-case a source column name and replace characters that aren't
valid in an unquoted identifier with underscores."""
import re as _re
s = name.lower().strip()
s = _re.sub(r"[^a-z0-9_$#.]", "_", s)
if s and s[0].isdigit():
s = "_" + s
return s or "_"
def _sql_str(v: str) -> str: def _sql_str(v: str) -> str:
"""SQL string literal — PG-style single-quote escaping.""" """SQL string literal — PG-style single-quote escaping."""
return "'" + v.replace("'", "''") + "'" return "'" + v.replace("'", "''") + "'"
@ -978,3 +991,190 @@ def hook_delete(hook_id: int):
module_id = hook["module_id"] module_id = hook["module_id"]
repo.delete_hook(hook_id) repo.delete_hook(hook_id)
return RedirectResponse(url=f"/modules/{module_id}", status_code=303) return RedirectResponse(url=f"/modules/{module_id}", status_code=303)
# ---------------------------------------------------------------------------
# Groups
# ---------------------------------------------------------------------------
@_router.get("/groups", response_class=HTMLResponse)
def groups_index(request: Request):
groups = repo.list_groups()
for g in groups:
members = repo.list_group_members(g["id"])
g["member_count"] = len(members)
recent = repo.list_group_runs(g["id"], limit=1)
if recent:
g["last_run_at"] = recent[0]["started_at"]
g["last_status"] = recent[0]["status"]
else:
g["last_run_at"] = None
g["last_status"] = None
return _templates.TemplateResponse(
request, "groups.html",
_ctx(groups=groups),
)
@_router.get("/groups/new", response_class=HTMLResponse)
def group_new(request: Request):
return _templates.TemplateResponse(
request, "group_form.html",
_ctx(group=None, all_modules=repo.list_modules(), members=[],
form_action="/groups", cancel_url="/groups"),
)
@_router.post("/groups")
async def group_create(request: Request):
form = await request.form()
name = form["name"].strip()
if repo.get_group_by_name(name) is not None:
raise HTTPException(409, f"group name {name!r} already exists")
grp = repo.create_group(name=name)
_save_group_members(form, grp["id"])
return RedirectResponse(url=f"/groups/{grp['id']}", status_code=303)
@_router.get("/groups/{group_id}", response_class=HTMLResponse)
def group_detail(request: Request, group_id: int):
grp = repo.get_group(group_id)
if grp is None:
raise HTTPException(404, f"group id={group_id} not found")
members = repo.list_group_members(group_id)
recent_runs = repo.list_group_runs(group_id, limit=10)
group_running = bool(recent_runs and recent_runs[0]["status"] == "running")
return _templates.TemplateResponse(
request, "group_detail.html",
_ctx(group=grp, members=members, recent_runs=recent_runs,
group_running=group_running),
)
@_router.get("/groups/{group_id}/edit", response_class=HTMLResponse)
def group_edit(request: Request, group_id: int):
grp = repo.get_group(group_id)
if grp is None:
raise HTTPException(404, f"group id={group_id} not found")
members = repo.list_group_members(group_id)
return _templates.TemplateResponse(
request, "group_form.html",
_ctx(group=grp, all_modules=repo.list_modules(), members=members,
form_action=f"/groups/{group_id}", cancel_url=f"/groups/{group_id}",
section="groups"),
)
@_router.post("/groups/{group_id}")
async def group_update(request: Request, group_id: int):
grp = repo.get_group(group_id)
if grp is None:
raise HTTPException(404, f"group id={group_id} not found")
form = await request.form()
name = form["name"].strip()
if name != grp["name"] and repo.get_group_by_name(name) is not None:
raise HTTPException(409, f"group name {name!r} already exists")
repo.update_group(group_id, name=name)
_save_group_members(form, group_id)
return RedirectResponse(url=f"/groups/{group_id}", status_code=303)
@_router.post("/groups/{group_id}/delete")
def group_delete(group_id: int):
if repo.get_group(group_id) is None:
raise HTTPException(404, f"group id={group_id} not found")
repo.delete_group(group_id)
return RedirectResponse(url="/groups", status_code=303)
@_router.post("/groups/{group_id}/run")
async def group_run_action(group_id: int, request: Request,
background: BackgroundTasks):
grp = repo.get_group(group_id)
if grp is None:
raise HTTPException(404, f"group id={group_id} not found")
form = await request.form()
dry = form.get("dry_run") == "1"
group_run_id = repo.create_group_run(group_id, triggered_by="manual")
background.add_task(_run_group_in_background, group_id, group_run_id, dry)
if request.headers.get("HX-Request"):
members = repo.list_group_members(group_id)
recent_runs = repo.list_group_runs(group_id, limit=10)
return _templates.TemplateResponse(
request, "_group_live.html",
_ctx(group=grp, members=members, recent_runs=recent_runs,
group_running=True, force_poll=True))
return RedirectResponse(url=f"/group-runs/{group_run_id}", status_code=303)
@_router.get("/groups/{group_id}/live-fragment", response_class=HTMLResponse)
def group_live_fragment(request: Request, group_id: int):
grp = repo.get_group(group_id)
if grp is None:
raise HTTPException(404, f"group id={group_id} not found")
members = repo.list_group_members(group_id)
recent_runs = repo.list_group_runs(group_id, limit=10)
group_running = bool(recent_runs and recent_runs[0]["status"] == "running")
return _templates.TemplateResponse(
request, "_group_live.html",
_ctx(group=grp, members=members, recent_runs=recent_runs,
group_running=group_running, force_poll=False))
def _run_group_in_background(group_id: int, group_run_id: int,
dry_run: bool) -> None:
try:
engine.run_group(group_id, dry_run=dry_run, group_run_id=group_run_id)
except Exception as e: # noqa: BLE001
repo.finish_group_run(group_run_id, status="error")
# ---------------------------------------------------------------------------
# Group run detail
# ---------------------------------------------------------------------------
@_router.get("/group-runs/{group_run_id}", response_class=HTMLResponse)
def group_run_detail(request: Request, group_run_id: int):
group_run = repo.get_group_run(group_run_id)
if group_run is None:
raise HTTPException(404, f"group run id={group_run_id} not found")
module_runs = repo.list_runs_for_group_run(group_run_id)
return _templates.TemplateResponse(
request, "group_run_detail.html",
_ctx(group_run=group_run, module_runs=module_runs),
)
@_router.get("/group-runs/{group_run_id}/live", response_class=HTMLResponse)
def group_run_live_fragment(request: Request, group_run_id: int):
group_run = repo.get_group_run(group_run_id)
if group_run is None:
raise HTTPException(404, f"group run id={group_run_id} not found")
module_runs = repo.list_runs_for_group_run(group_run_id)
return _templates.TemplateResponse(
request, "_group_run_live.html",
_ctx(group_run=group_run, module_runs=module_runs),
)
def _save_group_members(form, group_id: int) -> None:
module_ids = form.getlist("member_module_id")
run_orders = form.getlist("member_run_order")
members = []
seen = set()
for i, mid_str in enumerate(module_ids):
if not mid_str:
continue
try:
mid = int(mid_str)
except ValueError:
continue
if mid in seen:
continue
seen.add(mid)
try:
order = int(run_orders[i]) if i < len(run_orders) else i
except (ValueError, IndexError):
order = i
members.append({"module_id": mid, "run_order": order})
repo.set_group_members(group_id, members)

View File

@ -140,6 +140,20 @@ table.grid tr:hover td { background: #1c2128; }
.pill.disabled { color: var(--text-muted); } .pill.disabled { color: var(--text-muted); }
.pill.warning { color: var(--warning); } .pill.warning { color: var(--warning); }
/* Group membership tags */
.tag {
display: inline-block;
padding: 0.05rem 0.45rem;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
background: var(--border);
color: var(--text-muted);
text-decoration: none;
margin-right: 0.2rem;
}
.tag:hover { color: var(--accent); }
/* Labeled key-value rows (used in detail views) */ /* Labeled key-value rows (used in detail views) */
dl.keyval { dl.keyval {
display: grid; display: grid;

View File

@ -0,0 +1,44 @@
{# Partial: recent group runs panel for group_detail.html.
Polls every 3s while a group run is in progress. #}
<div id="group-live"
{% if group_running or force_poll %}
hx-get="/groups/{{ group.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>
</header>
<div class="body tight">
{% if recent_runs %}
<table class="grid">
<thead>
<tr>
<th>id</th>
<th>started</th>
<th>duration</th>
<th>triggered by</th>
<th>status</th>
</tr>
</thead>
<tbody>
{% for r in recent_runs %}
<tr>
<td><a href="/group-runs/{{ r.id }}">#{{ r.id }}</a></td>
<td class="mono">{{ r.started_at or "—" }}</td>
<td class="mono">{{ r.duration_s | duration }}</td>
<td class="mono">{{ r.triggered_by or "—" }}</td>
<td><span class="pill {{ r.status }}">{{ r.status }}</span></td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty">No runs yet.</div>
{% endif %}
</div>
</div>
</div>

View File

@ -0,0 +1,49 @@
{# 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">
<span class="pill {{ group_run.status }}">{{ group_run.status }}</span>
{% if group_run.duration_s is not none %}&nbsp;· {{ 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 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>
{% 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>

View File

@ -13,6 +13,7 @@
<nav> <nav>
<a href="/" class="{% if section == 'modules' %}active{% endif %}">Modules</a> <a href="/" class="{% if section == 'modules' %}active{% endif %}">Modules</a>
<a href="/connections" class="{% if section == 'connections' %}active{% endif %}">Connections</a> <a href="/connections" class="{% if section == 'connections' %}active{% endif %}">Connections</a>
<a href="/groups" class="{% if section == 'groups' %}active{% endif %}">Groups</a>
<a href="/runs" class="{% if section == 'runs' %}active{% endif %}">Runs</a> <a href="/runs" class="{% if section == 'runs' %}active{% endif %}">Runs</a>
</nav> </nav>
<span class="right">v{{ version }} &middot; <a href="/docs">API docs</a></span> <span class="right">v{{ version }} &middot; <a href="/docs">API docs</a></span>

View File

@ -0,0 +1,70 @@
{% extends "base.html" %}
{% set section = "groups" %}
{% block title %}{{ group.name }} — Pipekit{% endblock %}
{% block content %}
<div class="panel">
<header>
{{ group.name }}
<span class="subtitle">group #{{ group.id }}</span>
<span style="margin-left:auto; display:flex; gap:0.5rem; align-items:center">
<a class="btn ghost" href="/groups/{{ group.id }}/edit">Edit</a>
<form class="inline"
hx-post="/groups/{{ group.id }}/run"
hx-target="#group-live"
hx-swap="outerHTML">
<button type="submit">Run</button>
</form>
<form class="inline"
hx-post="/groups/{{ group.id }}/run"
hx-target="#group-live"
hx-swap="outerHTML">
<input type="hidden" name="dry_run" value="1">
<button type="submit" class="ghost">Dry run</button>
</form>
</span>
</header>
<div class="body tight">
{% if members %}
<table class="grid">
<thead>
<tr>
<th>order</th>
<th>module</th>
<th>enabled</th>
</tr>
</thead>
<tbody>
{% for m in members %}
<tr>
<td class="mono">{{ m.run_order }}</td>
<td><a href="/modules/{{ m.module_id }}">{{ m.module_name }}</a></td>
<td>
{% if m.module_enabled %}
<span class="pill success">yes</span>
{% else %}
<span class="pill disabled">disabled — skipped</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty">No members yet. <a href="/groups/{{ group.id }}/edit">Add some →</a></div>
{% endif %}
</div>
</div>
{% include "_group_live.html" %}
<div class="panel" style="margin-top:1rem">
<header>Danger zone</header>
<div class="body">
<form method="post" action="/groups/{{ group.id }}/delete"
onsubmit="return confirm('Delete group {{ group.name }}? Module runs are kept.')">
<button type="submit" class="ghost" style="color:var(--danger)">Delete group</button>
</form>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,103 @@
{% extends "base.html" %}
{% set section = "groups" %}
{% block title %}{% if group %}Edit group · {{ group.name }}{% else %}New group{% endif %} — Pipekit{% endblock %}
{% block content %}
<div class="panel">
<header>
{% if group %}Edit group &middot; {{ group.name }}{% else %}New group{% endif %}
<span style="margin-left:auto"><a href="{{ cancel_url }}">&larr; back</a></span>
</header>
<div class="body">
<form method="post" action="{{ form_action }}" id="group-form">
<label class="field">
<span>name</span>
<input type="text" name="name" required value="{{ group.name if group else '' }}">
</label>
<div class="panel" style="margin-top:1rem">
<header>
Members
<span class="subtitle">run in order, lowest first; disabled modules are skipped</span>
<button type="button" class="btn ghost" style="margin-left:auto"
onclick="addMemberRow()">+ add</button>
</header>
<div class="body tight">
<table class="grid" id="members-table">
<thead>
<tr>
<th style="width:5rem">order</th>
<th>module</th>
<th style="width:5rem"></th>
</tr>
</thead>
<tbody id="members-tbody">
{% for m in members %}
<tr>
<td>
<input type="number" name="member_run_order"
value="{{ m.run_order }}" min="0" style="width:4rem">
</td>
<td>
<select name="member_module_id" style="width:100%">
{% for mod in all_modules %}
<option value="{{ mod.id }}"
{% if mod.id == m.module_id %}selected{% endif %}>
{{ mod.name }}{% if not mod.enabled %} (disabled){% endif %}
</option>
{% endfor %}
</select>
</td>
<td>
<button type="button" class="ghost"
style="color:var(--danger);border:none"
onclick="this.closest('tr').remove()">remove</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if not members %}
<div id="members-empty" class="empty" style="margin-top:0.4rem">No members yet.</div>
{% endif %}
</div>
</div>
<div class="actions" style="justify-content:flex-end;margin-top:0.8rem">
<a class="btn ghost" href="{{ cancel_url }}">cancel</a>
<button type="submit" class="primary">{% if group %}save changes{% else %}create group{% endif %}</button>
</div>
</form>
</div>
</div>
<template id="member-row-template">
<tr>
<td>
<input type="number" name="member_run_order" value="0" min="0" style="width:4rem">
</td>
<td>
<select name="member_module_id" style="width:100%">
{% for mod in all_modules %}
<option value="{{ mod.id }}">{{ mod.name }}{% if not mod.enabled %} (disabled){% endif %}</option>
{% endfor %}
</select>
</td>
<td>
<button type="button" class="ghost"
style="color:var(--danger);border:none"
onclick="this.closest('tr').remove()">remove</button>
</td>
</tr>
</template>
<script>
function addMemberRow() {
const tmpl = document.getElementById('member-row-template');
const row = tmpl.content.cloneNode(true);
document.getElementById('members-tbody').appendChild(row);
const empty = document.getElementById('members-empty');
if (empty) empty.style.display = 'none';
}
</script>
{% endblock %}

View File

@ -0,0 +1,69 @@
{% extends "base.html" %}
{% set section = "groups" %}
{% block title %}Group run #{{ group_run.id }} — Pipekit{% endblock %}
{% block content %}
<div class="panel">
<header>
Group run #{{ group_run.id }}
<span class="subtitle">
<a href="/groups/{{ group_run.group_id }}">{{ group_run.group_name }}</a> ·
started {{ group_run.started_at }}
</span>
<span style="margin-left:auto">
<span class="pill {{ group_run.status }}">{{ group_run.status }}</span>
</span>
</header>
<div class="body">
<dl class="keyval">
<dt>started</dt> <dd class="mono">{{ group_run.started_at }}</dd>
<dt>finished</dt> <dd class="mono">{{ group_run.finished_at or "—" }}</dd>
<dt>duration</dt> <dd class="mono">{{ group_run.duration_s | duration }}</dd>
<dt>triggered by</dt><dd class="mono">{{ group_run.triggered_by or "—" }}</dd>
</dl>
</div>
</div>
<div id="group-run-live"
{% if group_run.status == 'running' %}
hx-get="/group-runs/{{ group_run.id }}/live"
hx-trigger="load, every 3s"
hx-swap="outerHTML"
{% endif %}>
<div class="panel">
<header>Module runs</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 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>
{% 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>
{% endblock %}

View File

@ -0,0 +1,60 @@
{% extends "base.html" %}
{% set section = "groups" %}
{% block title %}Groups — Pipekit{% endblock %}
{% block content %}
<div class="panel">
<header>
Groups
<span class="subtitle">{{ groups|length }} total</span>
<span style="margin-left:auto">
<a class="btn" href="/groups/new">New group…</a>
</span>
</header>
<div class="body tight">
{% if groups %}
<table class="grid">
<thead>
<tr>
<th>name</th>
<th>members</th>
<th>last run</th>
<th style="width:9em">status</th>
<th></th>
</tr>
</thead>
<tbody>
{% for g in groups %}
<tr>
<td><a href="/groups/{{ g.id }}"><strong>{{ g.name }}</strong></a></td>
<td class="mono">{{ g.member_count }}</td>
<td class="mono">{{ g.last_run_at or "—" }}</td>
<td>
{% if g.last_status %}
<span class="pill {{ g.last_status }}">{{ g.last_status }}</span>
{% else %}
<span class="pill">never ran</span>
{% endif %}
</td>
<td style="text-align:right">
<form class="inline" method="post" action="/groups/{{ g.id }}/run">
<button type="submit">Run</button>
</form>
<form class="inline" method="post" action="/groups/{{ g.id }}/run">
<input type="hidden" name="dry_run" value="1">
<button type="submit" class="ghost">Dry run</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty">
No groups yet.<br>
<a class="btn" href="/groups/new" style="margin-top:0.7rem; display:inline-block">Create one</a>
</div>
{% endif %}
</div>
</div>
{% endblock %}

View File

@ -21,6 +21,7 @@
<th style="width:30%">name</th> <th style="width:30%">name</th>
<th>strategy</th> <th>strategy</th>
<th>dest</th> <th>dest</th>
<th>groups</th>
<th>last run</th> <th>last run</th>
<th style="width:9em">status</th> <th style="width:9em">status</th>
<th style="width:7em">rows</th> <th style="width:7em">rows</th>
@ -33,6 +34,11 @@
<td><a href="/modules/{{ m.id }}"><strong>{{ m.name }}</strong></a></td> <td><a href="/modules/{{ m.id }}"><strong>{{ m.name }}</strong></a></td>
<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>
{% for g in m.groups %}
<a href="/groups/{{ g.group_id }}" class="tag">{{ g.group_name }}</a>
{% endfor %}
</td>
<td class="mono">{{ m.last_run_at or "—" }}</td> <td class="mono">{{ m.last_run_at or "—" }}</td>
{% with module=m %}{% include "_module_status_pill.html" %}{% endwith %} {% 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 class="mono">{{ m.last_row_count if m.last_row_count is not none else "—" }}</td>