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:
@@ -1,12 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { fmtDuration } from '$lib/format';
|
||||
import { getAnalysisMetrics, type AnalysisMetrics } from '$lib/api/admin';
|
||||
import TrendChart from '$lib/components/charts/TrendChart.svelte';
|
||||
import { buildSeries, type TrendSeries } from '$lib/admin/series';
|
||||
import {
|
||||
getAnalysisMetrics,
|
||||
getAnalysisMetricsSeries,
|
||||
type AnalysisMetrics
|
||||
} from '$lib/api/admin';
|
||||
|
||||
let days = $state(7);
|
||||
let metrics = $state<AnalysisMetrics | null>(null);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let series = $state<TrendSeries | null>(null);
|
||||
let seriesCtrl: AbortController | null = null;
|
||||
|
||||
const successPct = $derived(
|
||||
metrics && metrics.n > 0 ? Math.round((metrics.ok / metrics.n) * 100) : null
|
||||
@@ -22,6 +30,20 @@
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
loadSeries();
|
||||
}
|
||||
|
||||
async function loadSeries() {
|
||||
seriesCtrl?.abort();
|
||||
const ctrl = new AbortController();
|
||||
seriesCtrl = ctrl;
|
||||
try {
|
||||
const resp = await getAnalysisMetricsSeries(days, { signal: ctrl.signal });
|
||||
series = buildSeries(resp.buckets, days);
|
||||
} catch (e) {
|
||||
if ((e as Error)?.name === 'AbortError') return;
|
||||
// Chart failure is non-fatal — keep the tiles/table usable.
|
||||
}
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
@@ -45,6 +67,24 @@
|
||||
{:else if loading}
|
||||
<p class="muted">Loading…</p>
|
||||
{:else if metrics}
|
||||
{#if series}
|
||||
<div class="charts" data-testid="analysis-metrics-charts">
|
||||
<TrendChart points={series.throughput} label="Pages analyzed (per 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}
|
||||
|
||||
<div class="tiles">
|
||||
<div class="tile">
|
||||
<span class="label">Pages analyzed</span>
|
||||
@@ -112,6 +152,13 @@
|
||||
color: var(--text);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.tiles {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
|
||||
|
||||
168
frontend/src/lib/components/charts/TrendChart.svelte
Normal file
168
frontend/src/lib/components/charts/TrendChart.svelte
Normal file
@@ -0,0 +1,168 @@
|
||||
<script lang="ts">
|
||||
// A small, dependency-free line+area sparkline for admin trend series.
|
||||
// Null values render as gaps (a bucket with no data), so a missing point
|
||||
// never draws a misleading line to zero.
|
||||
type Point = { t: string; value: number | null };
|
||||
|
||||
let {
|
||||
points,
|
||||
label,
|
||||
format = (v: number) => v.toFixed(0),
|
||||
height = 72,
|
||||
accent = 'var(--primary, #2563eb)'
|
||||
}: {
|
||||
points: Point[];
|
||||
label: string;
|
||||
format?: (v: number) => string;
|
||||
height?: number;
|
||||
accent?: string;
|
||||
} = $props();
|
||||
|
||||
// Internal coordinate system; the SVG scales to its container width.
|
||||
const W = 600;
|
||||
const PAD = 6;
|
||||
|
||||
const values = $derived(
|
||||
points.map((p) => p.value).filter((v): v is number => v != null)
|
||||
);
|
||||
const hasData = $derived(values.length > 0);
|
||||
const max = $derived(hasData ? Math.max(...values) : 0);
|
||||
const min = $derived(hasData ? Math.min(...values) : 0);
|
||||
const latest = $derived.by(() => {
|
||||
for (let i = points.length - 1; i >= 0; i--) {
|
||||
if (points[i].value != null) return points[i].value as number;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
function x(i: number): number {
|
||||
if (points.length <= 1) return W / 2;
|
||||
return PAD + (i / (points.length - 1)) * (W - 2 * PAD);
|
||||
}
|
||||
function y(v: number): number {
|
||||
const range = max > min ? max - min : 1;
|
||||
const norm = max > min ? (v - min) / range : 0.5;
|
||||
const inner = height - 2 * PAD;
|
||||
return PAD + (1 - norm) * inner;
|
||||
}
|
||||
|
||||
// Break the series into runs of consecutive non-null points so gaps stay
|
||||
// gaps. Each run becomes one line path and one area path.
|
||||
type Run = { i: number; v: number }[];
|
||||
const runs = $derived.by<Run[]>(() => {
|
||||
const out: Run[] = [];
|
||||
let cur: Run = [];
|
||||
points.forEach((p, i) => {
|
||||
if (p.value == null) {
|
||||
if (cur.length) out.push(cur);
|
||||
cur = [];
|
||||
} else {
|
||||
cur.push({ i, v: p.value });
|
||||
}
|
||||
});
|
||||
if (cur.length) out.push(cur);
|
||||
return out;
|
||||
});
|
||||
|
||||
function linePath(run: Run): string {
|
||||
return run.map((pt, k) => `${k === 0 ? 'M' : 'L'} ${x(pt.i).toFixed(1)} ${y(pt.v).toFixed(1)}`).join(' ');
|
||||
}
|
||||
function areaPath(run: Run): string {
|
||||
if (run.length === 0) return '';
|
||||
const base = height - PAD;
|
||||
const top = run.map((pt) => `L ${x(pt.i).toFixed(1)} ${y(pt.v).toFixed(1)}`).join(' ');
|
||||
const x0 = x(run[0].i).toFixed(1);
|
||||
const xn = x(run[run.length - 1].i).toFixed(1);
|
||||
return `M ${x0} ${base} ${top} L ${xn} ${base} Z`;
|
||||
}
|
||||
|
||||
function shortTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric' });
|
||||
}
|
||||
</script>
|
||||
|
||||
<figure class="trend" data-testid="trend-chart">
|
||||
<figcaption>
|
||||
<span class="lbl">{label}</span>
|
||||
{#if hasData}
|
||||
<span class="muted"
|
||||
>peak {format(max)} · latest {latest != null ? format(latest) : '—'}</span
|
||||
>
|
||||
{/if}
|
||||
</figcaption>
|
||||
{#if !hasData}
|
||||
<div class="empty muted" style={`height:${height}px`} data-testid="trend-empty">
|
||||
No data in this window
|
||||
</div>
|
||||
{:else}
|
||||
<svg
|
||||
viewBox={`0 0 ${W} ${height}`}
|
||||
preserveAspectRatio="none"
|
||||
role="img"
|
||||
aria-label={`${label}: ${values.length} points, latest ${latest != null ? format(latest) : 'none'}, peak ${format(max)}`}
|
||||
>
|
||||
{#each runs as run, ri (ri)}
|
||||
<path class="area" d={areaPath(run)} fill={accent} />
|
||||
<path class="line" d={linePath(run)} stroke={accent} />
|
||||
{#if run.length === 1}
|
||||
<circle cx={x(run[0].i)} cy={y(run[0].v)} r="3" fill={accent} />
|
||||
{/if}
|
||||
{/each}
|
||||
</svg>
|
||||
<div class="xaxis muted">
|
||||
<span>{shortTime(points[0].t)}</span>
|
||||
<span>{shortTime(points[points.length - 1].t)}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</figure>
|
||||
|
||||
<style>
|
||||
.trend {
|
||||
margin: 0 0 var(--space-3) 0;
|
||||
}
|
||||
figcaption {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: var(--space-2);
|
||||
font-size: var(--font-sm);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.lbl {
|
||||
font-weight: var(--weight-medium);
|
||||
}
|
||||
svg {
|
||||
width: 100%;
|
||||
display: block;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.area {
|
||||
opacity: 0.12;
|
||||
stroke: none;
|
||||
}
|
||||
.line {
|
||||
fill: none;
|
||||
stroke-width: 1.5;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
.xaxis {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: var(--font-xs);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-sm);
|
||||
}
|
||||
.muted {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
</style>
|
||||
53
frontend/src/lib/components/charts/TrendChart.svelte.test.ts
Normal file
53
frontend/src/lib/components/charts/TrendChart.svelte.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { render, screen, cleanup } from '@testing-library/svelte';
|
||||
import TrendChart from './TrendChart.svelte';
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe('TrendChart', () => {
|
||||
it('renders an empty state when there is no data', () => {
|
||||
render(TrendChart, {
|
||||
props: {
|
||||
points: [
|
||||
{ t: '2026-06-18T09:00:00Z', value: null },
|
||||
{ t: '2026-06-18T10:00:00Z', value: null }
|
||||
],
|
||||
label: 'Throughput'
|
||||
}
|
||||
});
|
||||
expect(screen.getByTestId('trend-empty')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('draws a path and labels peak/latest when data is present', () => {
|
||||
const { container } = render(TrendChart, {
|
||||
props: {
|
||||
points: [
|
||||
{ t: '2026-06-18T09:00:00Z', value: 2 },
|
||||
{ t: '2026-06-18T10:00:00Z', value: 6 },
|
||||
{ t: '2026-06-18T11:00:00Z', value: 4 }
|
||||
],
|
||||
label: 'Throughput'
|
||||
}
|
||||
});
|
||||
const line = container.querySelector('path.line') as SVGPathElement;
|
||||
expect(line).toBeTruthy();
|
||||
expect(line.getAttribute('d')).toMatch(/^M /);
|
||||
// Header summarizes peak + latest.
|
||||
expect(screen.getByText(/peak 6 · latest 4/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('breaks the line across null gaps into separate paths', () => {
|
||||
const { container } = render(TrendChart, {
|
||||
props: {
|
||||
points: [
|
||||
{ t: '2026-06-18T09:00:00Z', value: 2 },
|
||||
{ t: '2026-06-18T10:00:00Z', value: null },
|
||||
{ t: '2026-06-18T11:00:00Z', value: 4 }
|
||||
],
|
||||
label: 'Success'
|
||||
}
|
||||
});
|
||||
// Two runs → two line paths (and two area paths).
|
||||
expect(container.querySelectorAll('path.line').length).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -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