fix(pipeline): query shortcuts + schema preview + serving meta fallback

- Add Shift+Enter shortcut to execute query (alongside Cmd/Ctrl+Enter)
- Add ▶ preview button to schema sidebar tables: populates editor with
  SELECT * FROM serving.<table> LIMIT 100 and auto-submits
- Update hint text to show "Shift+Enter to run"
- Overview tab: fall back to information_schema when _serving_meta.json
  is absent instead of showing error message; row counts show "—"
- Dashboard stat cards: same fallback — query DuckDB for table count

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-25 23:32:15 +01:00
parent e61aaa574b
commit c772d814de
3 changed files with 64 additions and 22 deletions

View File

@@ -372,11 +372,22 @@ def _is_stale(run: dict) -> bool:
@role_required("admin") @role_required("admin")
async def pipeline_dashboard(): async def pipeline_dashboard():
"""Main page: health stat cards + tab container.""" """Main page: health stat cards + tab container."""
summary = await asyncio.to_thread(_fetch_extraction_summary_sync) from .analytics import fetch_analytics # noqa: PLC0415
serving_meta = await asyncio.to_thread(_load_serving_meta)
total_serving_tables = len(serving_meta["tables"]) if serving_meta else 0 summary, serving_meta = await asyncio.gather(
last_export = serving_meta.get("exported_at_utc", "")[:19].replace("T", " ") if serving_meta else "" asyncio.to_thread(_fetch_extraction_summary_sync),
asyncio.to_thread(_load_serving_meta),
)
if serving_meta:
total_serving_tables = len(serving_meta.get("tables", {}))
last_export = serving_meta.get("exported_at_utc", "")[:19].replace("T", " ") or ""
else:
schema_rows = await fetch_analytics(
"SELECT COUNT(*) AS n FROM information_schema.tables WHERE table_schema = 'serving'"
)
total_serving_tables = schema_rows[0]["n"] if schema_rows else 0
last_export = ""
success_rate = 0 success_rate = 0
if summary["total"] > 0: if summary["total"] > 0:
@@ -422,12 +433,30 @@ async def pipeline_overview():
# Compute landing zone totals # Compute landing zone totals
total_landing_bytes = sum(s["total_bytes"] for s in landing_stats) total_landing_bytes = sum(s["total_bytes"] for s in landing_stats)
# Build serving tables list: prefer _serving_meta.json (has counts + timestamp),
# fall back to information_schema when file doesn't exist yet.
if serving_meta:
serving_tables = [
{"name": name, "row_count": meta.get("row_count")}
for name, meta in sorted(serving_meta.get("tables", {}).items())
]
last_export = serving_meta.get("exported_at_utc", "")[:19].replace("T", " ") or None
else:
from .analytics import fetch_analytics # noqa: PLC0415
schema_rows = await fetch_analytics(
"SELECT table_name FROM information_schema.tables "
"WHERE table_schema = 'serving' ORDER BY table_name"
)
serving_tables = [{"name": r["table_name"], "row_count": None} for r in schema_rows]
last_export = None
return await render_template( return await render_template(
"admin/partials/pipeline_overview.html", "admin/partials/pipeline_overview.html",
workflow_rows=workflow_rows, workflow_rows=workflow_rows,
landing_stats=landing_stats, landing_stats=landing_stats,
total_landing_bytes=total_landing_bytes, total_landing_bytes=total_landing_bytes,
serving_meta=serving_meta, serving_tables=serving_tables,
last_export=last_export,
format_bytes=_format_bytes, format_bytes=_format_bytes,
) )

View File

@@ -56,10 +56,12 @@
<!-- Serving Freshness --> <!-- Serving Freshness -->
<div class="card"> <div class="card">
<p class="card-header">Serving Tables</p> <p class="card-header">Serving Tables</p>
{% if serving_meta %} {% if last_export %}
<p class="text-xs text-slate mb-3"> <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> Last export: <span class="mono font-semibold text-navy">{{ last_export }}</span>
</p> </p>
{% endif %}
{% if serving_tables %}
<table class="table" style="font-size:0.8125rem"> <table class="table" style="font-size:0.8125rem">
<thead> <thead>
<tr> <tr>
@@ -68,18 +70,18 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for tname, tmeta in serving_meta.tables.items() | sort %} {% for t in serving_tables %}
<tr> <tr>
<td class="mono">serving.{{ tname }}</td> <td class="mono">serving.{{ t.name }}</td>
<td class="mono text-right font-semibold">{{ "{:,}".format(tmeta.row_count) }}</td> <td class="mono text-right font-semibold">
{% if t.row_count is not none %}{{ "{:,}".format(t.row_count) }}{% else %}—{% endif %}
</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
{% else %} {% else %}
<p class="text-sm text-slate"> <p class="text-sm text-slate">No serving tables found — run the pipeline first.</p>
<code>_serving_meta.json</code> not found — run the pipeline to generate it.
</p>
{% endif %} {% endif %}
</div> </div>

View File

@@ -189,7 +189,7 @@
<path d="M21 12a9 9 0 1 1-6.219-8.56"/> <path d="M21 12a9 9 0 1 1-6.219-8.56"/>
</svg> </svg>
<span class="query-limit-note"> <span class="query-limit-note">
{{ max_rows | default(1000) }} row limit · {{ timeout_seconds | default(10) }}s timeout · SELECT only {{ max_rows | default(1000) }} row limit · {{ timeout_seconds | default(10) }}s timeout · SELECT only · Shift+Enter to run
</span> </span>
</div> </div>
</div> </div>
@@ -202,12 +202,18 @@
<div class="schema-table-section"> <div class="schema-table-section">
<button type="button" class="schema-table-toggle" onclick="toggleSchema('{{ tname }}')"> <button type="button" class="schema-table-toggle" onclick="toggleSchema('{{ tname }}')">
{{ tname }} {{ tname }}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" <span style="display:flex;align-items:center;gap:4px;flex-shrink:0">
stroke-width="2" stroke="currentColor" <span title="Preview table" onclick="event.stopPropagation();previewTable('serving.{{ tname }}')"
id="schema-chevron-{{ tname }}" style="padding:1px 5px;font-size:0.6rem;font-weight:600;background:#DBEAFE;color:#1D4ED8;border-radius:4px;font-family:sans-serif;letter-spacing:0">
style="width:10px;height:10px;transition:transform 0.15s;flex-shrink:0">
<path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5"/> </span>
</svg> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
stroke-width="2" stroke="currentColor"
id="schema-chevron-{{ tname }}"
style="width:10px;height:10px;transition:transform 0.15s">
<path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5"/>
</svg>
</span>
</button> </button>
<div id="schema-cols-{{ tname }}" class="schema-cols"> <div id="schema-cols-{{ tname }}" class="schema-cols">
{% for col in cols %} {% for col in cols %}
@@ -241,13 +247,18 @@ document.getElementById('query-sql').addEventListener('keydown', function(e) {
this.value = this.value.substring(0, start) + ' ' + this.value.substring(end); this.value = this.value.substring(0, start) + ' ' + this.value.substring(end);
this.selectionStart = this.selectionEnd = start + 2; this.selectionStart = this.selectionEnd = start + 2;
} }
// Cmd/Ctrl+Enter submits // Shift+Enter or Cmd/Ctrl+Enter submits
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { if ((e.shiftKey || e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
htmx.trigger(document.getElementById('query-form'), 'submit'); htmx.trigger(document.getElementById('query-form'), 'submit');
} }
}); });
function previewTable(fullName) {
document.getElementById('query-sql').value = 'SELECT *\nFROM ' + fullName + '\nLIMIT 100';
htmx.trigger(document.getElementById('query-form'), 'submit');
}
function toggleSchema(name) { function toggleSchema(name) {
var cols = document.getElementById('schema-cols-' + name); var cols = document.getElementById('schema-cols-' + name);
var chevron = document.getElementById('schema-chevron-' + name); var chevron = document.getElementById('schema-chevron-' + name);