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:
@@ -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>
|
||||
116
web/src/padelnomics/admin/templates/admin/pipeline.html
Normal file
116
web/src/padelnomics/admin/templates/admin/pipeline.html
Normal 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 %}
|
||||
Reference in New Issue
Block a user