feat(pipeline): dashboard + overview tab templates

- pipeline.html: 4 stat cards (total runs, success rate, serving tables,
  last export) + stale-run warning banner + tab bar (Overview/Extractions/
  Catalog/Query) + tab container (lazy-loaded via HTMX on page load)
- partials/pipeline_overview.html: extraction status grid (one card per
  workflow with status dot, schedule, last run timestamp, error preview),
  serving freshness table (row counts per table), landing zone file stats

Subtask 2 of 6

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-25 12:53:02 +01:00
parent 060cb9b32e
commit cac876e48f
2 changed files with 237 additions and 0 deletions

View File

@@ -0,0 +1,121 @@
<!-- Pipeline Overview Tab: extraction status, serving freshness, landing zone -->
<!-- Extraction Status Grid -->
<div class="card mb-4">
<p class="card-header">Extraction Status</p>
{% if workflow_rows %}
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:0.75rem">
{% for row in workflow_rows %}
{% set wf = row.workflow %}
{% set run = row.run %}
{% set stale = row.stale %}
<div style="border:1px solid #E2E8F0;border-radius:10px;padding:0.875rem;background:#FAFAFA">
<div class="flex items-center gap-2 mb-2">
{% if not run %}
<span class="status-dot pending"></span>
{% elif stale %}
<span class="status-dot stale"></span>
{% elif run.status == 'success' %}
<span class="status-dot ok"></span>
{% elif run.status == 'failed' %}
<span class="status-dot failed"></span>
{% else %}
<span class="status-dot running"></span>
{% endif %}
<span class="text-sm font-semibold text-navy">{{ wf.name }}</span>
{% if stale %}
<span class="badge-warning" style="font-size:10px;padding:1px 6px;margin-left:auto">stale</span>
{% endif %}
</div>
<p class="text-xs text-slate">{{ wf.schedule_label }}</p>
{% if run %}
<p class="text-xs mono text-slate-dark mt-1">{{ run.started_at[:16].replace('T', ' ') if run.started_at else '—' }}</p>
{% if run.status == 'failed' and run.error_message %}
<p class="text-xs text-danger mt-1" style="font-family:monospace;word-break:break-all">
{{ run.error_message[:80] }}{% if run.error_message|length > 80 %}…{% endif %}
</p>
{% endif %}
{% if run.files_written %}
<p class="text-xs text-slate mt-1">{{ run.files_written }} file{{ 's' if run.files_written != 1 }},
{{ "{:,}".format(run.bytes_written or 0) }} B</p>
{% endif %}
{% else %}
<p class="text-xs text-slate mt-1">No runs yet</p>
{% endif %}
</div>
{% endfor %}
</div>
{% else %}
<p class="text-sm text-slate">No workflows found. Check that <code>infra/supervisor/workflows.toml</code> exists.</p>
{% endif %}
</div>
<!-- Two-column row: Serving Freshness + Landing Zone -->
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem">
<!-- Serving Freshness -->
<div class="card">
<p class="card-header">Serving Tables</p>
{% if serving_meta %}
<p class="text-xs text-slate mb-3">
Last export: <span class="mono font-semibold text-navy">{{ serving_meta.exported_at_utc[:19].replace('T', ' ') }}</span>
</p>
<table class="table" style="font-size:0.8125rem">
<thead>
<tr>
<th>Table</th>
<th style="text-align:right">Rows</th>
</tr>
</thead>
<tbody>
{% for tname, tmeta in serving_meta.tables.items() | sort %}
<tr>
<td class="mono">serving.{{ tname }}</td>
<td class="mono text-right font-semibold">{{ "{:,}".format(tmeta.row_count) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="text-sm text-slate">
<code>_serving_meta.json</code> not found — run the pipeline to generate it.
</p>
{% endif %}
</div>
<!-- Landing Zone -->
<div class="card">
<p class="card-header">Landing Zone
<span class="text-xs font-normal text-slate ml-2">
Total: <span class="font-semibold">{{ format_bytes(total_landing_bytes) }}</span>
</span>
</p>
{% if landing_stats %}
<table class="table" style="font-size:0.8125rem">
<thead>
<tr>
<th>Source</th>
<th style="text-align:right">Files</th>
<th style="text-align:right">Size</th>
<th style="text-align:right">Latest</th>
</tr>
</thead>
<tbody>
{% for s in landing_stats %}
<tr>
<td class="mono">{{ s.name }}</td>
<td class="text-right text-slate">{{ s.file_count }}</td>
<td class="text-right font-semibold">{{ format_bytes(s.total_bytes) }}</td>
<td class="text-right mono text-xs text-slate">{{ s.latest_mtime or '—' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="text-sm text-slate">
Landing zone empty or not found at <code>data/landing</code>.
</p>
{% endif %}
</div>
</div>

View File

@@ -0,0 +1,116 @@
{% extends "admin/base_admin.html" %}
{% set admin_page = "pipeline" %}
{% block title %}Data Pipeline - Admin - {{ config.APP_NAME }}{% endblock %}
{% block admin_head %}
<style>
.pipeline-tabs {
display: flex; gap: 0; border-bottom: 2px solid #E2E8F0; margin-bottom: 1.5rem;
}
.pipeline-tabs button {
padding: 0.625rem 1.25rem; font-size: 0.8125rem; font-weight: 600;
color: #64748B; background: none; border: none; cursor: pointer;
border-bottom: 2px solid transparent; margin-bottom: -2px; transition: all 0.15s;
}
.pipeline-tabs button:hover { color: #1D4ED8; }
.pipeline-tabs button.active { color: #1D4ED8; border-bottom-color: #1D4ED8; }
/* Status dot */
.status-dot {
display: inline-block; width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
}
.status-dot.ok { background: #16A34A; }
.status-dot.failed { background: #EF4444; }
.status-dot.stale { background: #D97706; }
.status-dot.running { background: #3B82F6; }
.status-dot.pending { background: #CBD5E1; }
</style>
{% endblock %}
{% block admin_content %}
<header class="flex justify-between items-center mb-6">
<div>
<h1 class="text-2xl">Data Pipeline</h1>
<p class="text-sm text-slate mt-1">Extraction status, data catalog, and ad-hoc query editor</p>
</div>
<div class="flex gap-2">
<form method="post" action="{{ url_for('pipeline.pipeline_trigger_extract') }}" class="m-0">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="button" class="btn btn-sm"
onclick="confirmAction('Enqueue a full extraction run? This will run all extractors in the background.', this.closest('form'))">
Run Pipeline
</button>
</form>
<a href="{{ url_for('admin.tasks') }}" class="btn-outline btn-sm">Task Queue</a>
</div>
</header>
<!-- Health stat cards -->
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:0.75rem" class="mb-6">
<div class="card text-center" style="padding:0.875rem">
<p class="text-xs text-slate">Total Runs</p>
<p class="text-2xl font-bold text-navy metric">{{ summary.total | default(0) }}</p>
</div>
<div class="card text-center" style="padding:0.875rem">
<p class="text-xs text-slate">Success Rate</p>
<p class="text-2xl font-bold {% if success_rate >= 90 %}text-accent{% elif success_rate >= 70 %}text-warning{% else %}text-danger{% endif %} metric">
{{ success_rate }}%
</p>
</div>
<div class="card text-center" style="padding:0.875rem">
<p class="text-xs text-slate">Serving Tables</p>
<p class="text-2xl font-bold text-navy metric">{{ total_serving_tables }}</p>
</div>
<div class="card text-center" style="padding:0.875rem">
<p class="text-xs text-slate">Last Export</p>
<p class="text-sm font-semibold text-navy mono mt-1">{{ last_export }}</p>
</div>
</div>
{% if summary.stale > 0 %}
<div class="flash-warning mb-4">
<strong>{{ summary.stale }} stale run{{ 's' if summary.stale != 1 }}.</strong>
An extraction appears to have crashed without updating its status.
Go to the Extractions tab to mark it failed.
</div>
{% endif %}
<!-- Tabs -->
<div class="pipeline-tabs" id="pipeline-tabs">
<button class="active" data-tab="overview"
hx-get="{{ url_for('pipeline.pipeline_overview') }}"
hx-target="#pipeline-tab-content" hx-swap="innerHTML"
hx-trigger="click">Overview</button>
<button data-tab="extractions"
hx-get="{{ url_for('pipeline.pipeline_extractions') }}"
hx-target="#pipeline-tab-content" hx-swap="innerHTML"
hx-trigger="click">Extractions</button>
<button data-tab="catalog"
hx-get="{{ url_for('pipeline.pipeline_catalog') }}"
hx-target="#pipeline-tab-content" hx-swap="innerHTML"
hx-trigger="click">Catalog</button>
<button data-tab="query"
hx-get="{{ url_for('pipeline.pipeline_query_editor') }}"
hx-target="#pipeline-tab-content" hx-swap="innerHTML"
hx-trigger="click">Query</button>
</div>
<!-- Tab content (Overview loads on page load) -->
<div id="pipeline-tab-content"
hx-get="{{ url_for('pipeline.pipeline_overview') }}"
hx-trigger="load"
hx-swap="innerHTML">
<div class="card text-center" style="padding:2rem">
<p class="text-slate">Loading overview…</p>
</div>
</div>
<script>
document.getElementById('pipeline-tabs').addEventListener('click', function(e) {
if (e.target.tagName === 'BUTTON') {
this.querySelectorAll('button').forEach(b => b.classList.remove('active'));
e.target.classList.add('active');
}
});
</script>
{% endblock %}