feat(admin): time-series trend charts on the metrics tabs (0.87.0)

Turn the dormant timing data in crawl_metrics / page_analysis into "is it
healthy over time" views. New bucketed series queries (GROUP BY date_trunc,
hour|day via a closed Bucket enum so the unit can't be attacker-controlled)
behind GET /v1/admin/{crawler,analysis}/metrics/series, with a shared
SeriesParams/resolve_bucket helper (bad bucket → 400) and migration 0030
indexing page_analysis(analyzed_at) for the analysis scan.

Frontend: a dependency-free SVG TrendChart (line+area, null buckets render as
gaps, empty-state, role=img) embedded above the per-op tables in Crawler and
Analysis → Metrics, driven by each panel's existing window selector with
AbortController-cancelled fetches. A buildSeries() util fills the continuous
bucket axis (throughput 0 for empty buckets, success/duration null) — unit
tested alongside the chart and the series API client.

Closes the Phase-1 observability set (audit log · health checks · trends).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-19 11:48:56 +02:00
parent 314fc8738b
commit c6a6d1690d
18 changed files with 856 additions and 10 deletions

View File

@@ -2,8 +2,11 @@
import { onMount } from 'svelte';
import Pager from '$lib/components/Pager.svelte';
import { fmtDuration } from '$lib/format';
import TrendChart from '$lib/components/charts/TrendChart.svelte';
import { buildSeries, type TrendSeries } from '$lib/admin/series';
import {
getCrawlerMetrics,
getCrawlerMetricsSeries,
listCrawlerOps,
type OpSummary,
type OpRow,
@@ -15,6 +18,8 @@
let days = $state(7);
let summary = $state<OpSummary[]>([]);
let summaryLoading = $state(true);
let series = $state<TrendSeries | null>(null);
let seriesCtrl: AbortController | null = null;
let rows = $state<OpRow[]>([]);
let total = $state(0);
@@ -65,6 +70,19 @@
}
}
async function loadSeries() {
seriesCtrl?.abort();
const ctrl = new AbortController();
seriesCtrl = ctrl;
try {
const resp = await getCrawlerMetricsSeries(days, { signal: ctrl.signal });
series = buildSeries(resp.buckets, days);
} catch (e) {
if ((e as Error)?.name === 'AbortError') return;
// A chart failure shouldn't blank the whole panel; leave prior series.
}
}
async function loadOps() {
opsLoading = true;
try {
@@ -86,12 +104,14 @@
onMount(() => {
loadSummary();
loadSeries();
loadOps();
});
function onWindowChange() {
page = 1;
loadSummary();
loadSeries();
loadOps();
}
function onOpsFilter() {
@@ -138,6 +158,24 @@
<p class="error" role="alert">{error}</p>
{/if}
{#if series}
<div class="charts" data-testid="crawler-metrics-charts">
<TrendChart points={series.throughput} label="Throughput (ops/bucket)" />
<TrendChart
points={series.success}
label="Success rate"
format={(v) => `${v.toFixed(0)}%`}
accent="var(--success, #16a34a)"
/>
<TrendChart
points={series.duration}
label="Avg duration"
format={(v) => fmtDuration(v)}
accent="var(--warning, #d97706)"
/>
</div>
{/if}
<h2>Average durations by type</h2>
{#if summaryLoading}
<p class="muted">Loading…</p>
@@ -246,6 +284,13 @@
justify-content: flex-end;
margin-bottom: var(--space-2);
}
.charts {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
gap: var(--space-3) var(--space-4);
margin-bottom: var(--space-4);
max-width: 60rem;
}
label {
font-size: var(--font-sm);
color: var(--text-muted);