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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user