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:
parent
70e4d79edf
commit
31d670b4e6
@ -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"]
|
||||
|
||||
@ -33,6 +33,13 @@ class RunOutcome:
|
||||
merge_sql: str | None
|
||||
|
||||
|
||||
@dataclass
|
||||
class GroupRunOutcome:
|
||||
group_run_id: int
|
||||
status: str # success | error | dry_run
|
||||
module_outcomes: list
|
||||
|
||||
|
||||
class LockBusy(RuntimeError):
|
||||
"""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)
|
||||
|
||||
|
||||
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:
|
||||
"""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]
|
||||
|
||||
138
pipekit/repo.py
138
pipekit/repo.py
@ -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,
|
||||
exclude_status: str | None = None, limit: int = 50) -> list[dict]:
|
||||
where, params = [], []
|
||||
|
||||
@ -63,6 +63,7 @@ def home(request: Request):
|
||||
modules = repo.list_modules()
|
||||
conns_by_id = {c["id"]: c for c in repo.list_connections()}
|
||||
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
|
||||
for m in modules:
|
||||
@ -76,6 +77,7 @@ def home(request: Request):
|
||||
m["last_run_at"] = None
|
||||
m["last_status"] = None
|
||||
m["last_row_count"] = None
|
||||
m["groups"] = groups_by_module.get(m["id"], [])
|
||||
|
||||
# group by source connection
|
||||
grouped: dict[tuple[str, str], list] = {}
|
||||
@ -405,7 +407,7 @@ def wizard_step3(request: Request,
|
||||
try:
|
||||
for c in drv.get_columns(conn, table, **qvals):
|
||||
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_description"] = c.description or ""
|
||||
columns.append(d)
|
||||
@ -650,6 +652,17 @@ def _save_inline_watermarks(form, module_id: int) -> None:
|
||||
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:
|
||||
"""SQL string literal — PG-style single-quote escaping."""
|
||||
return "'" + v.replace("'", "''") + "'"
|
||||
@ -978,3 +991,190 @@ def hook_delete(hook_id: int):
|
||||
module_id = hook["module_id"]
|
||||
repo.delete_hook(hook_id)
|
||||
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)
|
||||
|
||||
@ -140,6 +140,20 @@ table.grid tr:hover td { background: #1c2128; }
|
||||
.pill.disabled { color: var(--text-muted); }
|
||||
.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) */
|
||||
dl.keyval {
|
||||
display: grid;
|
||||
|
||||
44
pipekit/web/templates/_group_live.html
Normal file
44
pipekit/web/templates/_group_live.html
Normal 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>
|
||||
49
pipekit/web/templates/_group_run_live.html
Normal file
49
pipekit/web/templates/_group_run_live.html
Normal 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 %} · {{ 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>
|
||||
@ -13,6 +13,7 @@
|
||||
<nav>
|
||||
<a href="/" class="{% if section == 'modules' %}active{% endif %}">Modules</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>
|
||||
</nav>
|
||||
<span class="right">v{{ version }} · <a href="/docs">API docs</a></span>
|
||||
|
||||
70
pipekit/web/templates/group_detail.html
Normal file
70
pipekit/web/templates/group_detail.html
Normal 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 %}
|
||||
103
pipekit/web/templates/group_form.html
Normal file
103
pipekit/web/templates/group_form.html
Normal 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 · {{ group.name }}{% else %}New group{% endif %}
|
||||
<span style="margin-left:auto"><a href="{{ cancel_url }}">← 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 %}
|
||||
69
pipekit/web/templates/group_run_detail.html
Normal file
69
pipekit/web/templates/group_run_detail.html
Normal 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 %}
|
||||
60
pipekit/web/templates/groups.html
Normal file
60
pipekit/web/templates/groups.html
Normal 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 %}
|
||||
@ -21,6 +21,7 @@
|
||||
<th style="width:30%">name</th>
|
||||
<th>strategy</th>
|
||||
<th>dest</th>
|
||||
<th>groups</th>
|
||||
<th>last run</th>
|
||||
<th style="width:9em">status</th>
|
||||
<th style="width:7em">rows</th>
|
||||
@ -33,6 +34,11 @@
|
||||
<td><a href="/modules/{{ m.id }}"><strong>{{ m.name }}</strong></a></td>
|
||||
<td class="mono">{{ m.merge_strategy }}</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>
|
||||
{% 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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user