pipekit/pipekit/web/templates/module_detail.html
Paul Trowbridge 2ef68d766c Add module edit page + detect jrunner silent failures.
Modules get a full edit form (name, connections, tables, source query,
merge config, description, enabled); reachable via Edit button on the
detail page and the source-query panel.

jrunner catches SQLException and calls System.exit(0) at every failure
site, so pipekit was marking runs success when the migrate phase had
actually errored. query() and migrate() now scan stdout+stderr for a
Java stack-trace signature and raise JrunnerError. runner.py also
captures the failed jrunner output onto run_log so the stack trace is
visible on the run detail page.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 11:02:45 -04:00

210 lines
9.4 KiB
HTML

{% extends "base.html" %}
{% set section = "modules" %}
{% block title %}{{ module.name }} — Pipekit{% endblock %}
{% block content %}
<div class="panel">
<header>
{{ module.name }}
<span class="subtitle">
module #{{ module.id }}
{% if module.running %}<span class="pill running">running</span>{% endif %}
{% if not module.enabled %}<span class="pill disabled">disabled</span>{% endif %}
</span>
<span style="margin-left:auto" class="actions">
<form class="inline" method="post" action="/modules/{{ module.id }}/run">
<button class="primary" type="submit">Run now</button>
</form>
<form class="inline" method="post" action="/modules/{{ module.id }}/run">
<input type="hidden" name="dry_run" value="1">
<button type="submit">Dry run</button>
</form>
<a class="btn" href="/modules/{{ module.id }}/edit">Edit</a>
<form class="inline" method="post" action="/modules/{{ module.id }}/delete"
onsubmit="return confirm('Delete module {{ module.name }}? This removes the module and its run history. The dest table is NOT dropped.')">
<button type="submit" class="ghost" style="color:var(--danger)">Delete</button>
</form>
</span>
</header>
<div class="body">
<dl class="keyval">
<dt>source</dt> <dd>{{ source_conn.name }} <span style="opacity:.6" class="mono">({{ source_conn.jdbc_url }})</span></dd>
<dt>destination</dt> <dd>{{ dest_conn.name }} <span style="opacity:.6" class="mono">({{ dest_conn.jdbc_url }})</span></dd>
<dt>dest table</dt> <dd class="mono">{{ module.dest_table }}</dd>
<dt>staging table</dt> <dd class="mono">{{ module.staging_table }}</dd>
<dt>merge strategy</dt> <dd class="mono">{{ module.merge_strategy }}</dd>
<dt>merge key</dt> <dd class="mono">{{ module.merge_key or "—" }}</dd>
</dl>
</div>
</div>
<div class="two-col">
<div>
<div class="panel">
<header>Source query
<span class="subtitle">free text with <code>{watermark}</code> placeholders</span>
<span style="margin-left:auto"><a href="/modules/{{ module.id }}/edit">edit</a></span>
</header>
<div class="body"><pre class="sql">{{ module.source_query }}</pre></div>
</div>
{% if schema_cols or module.dest_description %}
<div class="panel">
<header>Schema
<span class="subtitle">{{ schema_cols|length }} column{{ 's' if schema_cols|length != 1 else '' }}</span>
</header>
<div class="body tight">
{% if module.dest_description %}
<p style="margin:0 0 0.6rem 0">{{ module.dest_description }}</p>
{% endif %}
{% if schema_cols %}
<table class="grid">
<thead>
<tr>
<th>source</th>
<th>dest</th>
<th>type</th>
<th>description</th>
</tr>
</thead>
<tbody>
{% for c in schema_cols %}
<tr>
<td class="mono">{{ c.source_name }}</td>
<td class="mono">{{ c.dest_name }}</td>
<td class="mono" style="color:var(--text-muted)">{{ c.dest_type }}</td>
<td>{{ c.description or '' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
</div>
{% endif %}
{% if preview %}
<div class="panel">
<header>Next resolved source SQL
<span class="subtitle">watermarks substituted — this is what will run</span>
</header>
<div class="body"><pre class="sql">{{ preview.resolved_source_sql }}</pre></div>
</div>
<div class="panel">
<header>Merge SQL
<span class="subtitle">runs against destination after staging is loaded</span>
</header>
<div class="body"><pre class="sql">{{ preview.merge_sql }}</pre></div>
</div>
{% else %}
<div class="panel"><header>Preview</header>
<div class="body empty">
{% if preview_error %}
<span class="pill err">error</span> {{ preview_error }}
{% else %}
No preview available.
{% endif %}
</div>
</div>
{% endif %}
</div>
<div>
<div class="panel">
<header>Watermarks
<span class="subtitle">{{ watermarks|length }}</span>
<span style="margin-left:auto">
<a class="btn" href="/modules/{{ module.id }}/watermarks/new">+ add</a>
</span>
</header>
<div class="body tight">
{% if watermarks %}
<table class="grid">
<thead><tr><th>name</th><th>resolved</th><th>default</th><th></th></tr></thead>
<tbody>
{% for w in watermarks %}
<tr>
<td class="mono">{{ w.name }}</td>
<td class="mono">{{ (preview.watermark_values.get(w.name) if preview else '') or '—' }}</td>
<td class="mono">{{ w.default_value or '—' }}</td>
<td style="white-space:nowrap">
<a href="/watermarks/{{ w.id }}/edit">edit</a> ·
<form class="inline" method="post" action="/watermarks/{{ w.id }}/delete"
onsubmit="return confirm('Delete watermark {{ w.name }}?')">
<button class="ghost" type="submit" style="padding:0;border:none;color:var(--danger)">delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty">None — this module doesn't use watermarks.</div>
{% endif %}
</div>
</div>
<div class="panel">
<header>Hooks
<span class="subtitle">{{ hooks|length }} post-merge</span>
<span style="margin-left:auto">
<a class="btn" href="/modules/{{ module.id }}/hooks/new">+ add</a>
</span>
</header>
<div class="body tight">
{% if hooks %}
<table class="grid">
<thead><tr><th style="width:3em">#</th><th>when</th><th>sql</th><th></th></tr></thead>
<tbody>
{% for h in hooks %}
<tr>
<td class="mono">{{ h.run_order }}</td>
<td><span class="pill">{{ h.run_on }}</span></td>
<td class="mono" style="max-width:22rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ h.sql }}</td>
<td style="white-space:nowrap">
<a href="/hooks/{{ h.id }}/edit">edit</a> ·
<form class="inline" method="post" action="/hooks/{{ h.id }}/delete"
onsubmit="return confirm('Delete hook #{{ h.id }}?')">
<button class="ghost" type="submit" style="padding:0;border:none;color:var(--danger)">delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty">No hooks.</div>
{% endif %}
</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>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 }}</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>
{% endblock %}