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:
51
frontend/src/lib/admin/series.test.ts
Normal file
51
frontend/src/lib/admin/series.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildSeries, unitForDays, truncUtc } from './series';
|
||||
import type { MetricsBucket } from '$lib/api/admin';
|
||||
|
||||
function bucket(t: string, n: number, ok: number, avg: number | null): MetricsBucket {
|
||||
return { t, n, ok, failed: n - ok, avg_ms: avg };
|
||||
}
|
||||
|
||||
describe('series helpers', () => {
|
||||
it('picks hour vs day by window', () => {
|
||||
expect(unitForDays(1)).toBe('hour');
|
||||
expect(unitForDays(2)).toBe('hour');
|
||||
expect(unitForDays(7)).toBe('day');
|
||||
expect(unitForDays(0)).toBe('day');
|
||||
});
|
||||
|
||||
it('truncUtc snaps to hour/day boundaries', () => {
|
||||
const ms = Date.parse('2026-06-18T09:37:42Z');
|
||||
expect(new Date(truncUtc(ms, 'hour')).toISOString()).toBe('2026-06-18T09:00:00.000Z');
|
||||
expect(new Date(truncUtc(ms, 'day')).toISOString()).toBe('2026-06-18T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('fills empty buckets: throughput 0, success/duration null', () => {
|
||||
// days=2 → hourly. Provide data only at 10:00; 11:00 must be a gap.
|
||||
const now = Date.parse('2026-06-18T11:30:00Z');
|
||||
const buckets = [bucket('2026-06-18T10:00:00Z', 4, 3, 200)];
|
||||
const s = buildSeries(buckets, 2, now);
|
||||
// Find the 10:00 and 11:00 slots.
|
||||
const at10 = s.throughput.findIndex((p) => p.t === '2026-06-18T10:00:00.000Z');
|
||||
expect(at10).toBeGreaterThanOrEqual(0);
|
||||
expect(s.throughput[at10].value).toBe(4);
|
||||
expect(s.success[at10].value).toBe(75); // 3/4
|
||||
expect(s.duration[at10].value).toBe(200);
|
||||
|
||||
const at11 = s.throughput.findIndex((p) => p.t === '2026-06-18T11:00:00.000Z');
|
||||
expect(s.throughput[at11].value).toBe(0); // no ops → zero throughput
|
||||
expect(s.success[at11].value).toBeNull(); // no denominator → gap
|
||||
expect(s.duration[at11].value).toBeNull();
|
||||
});
|
||||
|
||||
it('all-time spans only the data present', () => {
|
||||
const buckets = [
|
||||
bucket('2026-06-10T00:00:00Z', 1, 1, 100),
|
||||
bucket('2026-06-12T00:00:00Z', 1, 0, 100)
|
||||
];
|
||||
const s = buildSeries(buckets, 0);
|
||||
// 10th, 11th (gap), 12th → 3 day slots.
|
||||
expect(s.throughput.length).toBe(3);
|
||||
expect(s.throughput[1].value).toBe(0);
|
||||
});
|
||||
});
|
||||
75
frontend/src/lib/admin/series.ts
Normal file
75
frontend/src/lib/admin/series.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
// Turn the backend's sparse, ordered metric buckets into the three
|
||||
// continuous point series the trend charts render. Empty intervals become
|
||||
// explicit points so the chart shows real gaps rather than interpolating
|
||||
// across missing time: throughput is 0 (no ops happened), while success-rate
|
||||
// and duration are null (no denominator / no samples) and render as a gap.
|
||||
|
||||
import type { MetricsBucket } from '$lib/api/admin';
|
||||
|
||||
export type SeriesPoint = { t: string; value: number | null };
|
||||
|
||||
export type TrendSeries = {
|
||||
throughput: SeriesPoint[];
|
||||
success: SeriesPoint[];
|
||||
duration: SeriesPoint[];
|
||||
};
|
||||
|
||||
/** Bucket unit the backend uses for a given window (kept in lockstep with
|
||||
* `resolve_bucket` on the server: short windows → hour, else day). */
|
||||
export function unitForDays(days: number): 'hour' | 'day' {
|
||||
return days > 0 && days <= 2 ? 'hour' : 'day';
|
||||
}
|
||||
|
||||
function stepMs(unit: 'hour' | 'day'): number {
|
||||
return unit === 'hour' ? 3_600_000 : 86_400_000;
|
||||
}
|
||||
|
||||
/** Truncate a UTC timestamp to the start of its hour/day, matching Postgres
|
||||
* `date_trunc` (which the bucket `t` values already are). */
|
||||
export function truncUtc(ms: number, unit: 'hour' | 'day'): number {
|
||||
const d = new Date(ms);
|
||||
if (unit === 'hour') {
|
||||
d.setUTCMinutes(0, 0, 0);
|
||||
} else {
|
||||
d.setUTCHours(0, 0, 0, 0);
|
||||
}
|
||||
return d.getTime();
|
||||
}
|
||||
|
||||
export function buildSeries(
|
||||
buckets: MetricsBucket[],
|
||||
days: number,
|
||||
now: number = Date.now()
|
||||
): TrendSeries {
|
||||
const unit = unitForDays(days);
|
||||
const step = stepMs(unit);
|
||||
const byT = new Map<number, MetricsBucket>();
|
||||
for (const b of buckets) byT.set(truncUtc(new Date(b.t).getTime(), unit), b);
|
||||
|
||||
let startMs: number;
|
||||
let endMs: number;
|
||||
if (days > 0) {
|
||||
endMs = truncUtc(now, unit);
|
||||
startMs = truncUtc(now - days * 86_400_000, unit);
|
||||
} else {
|
||||
// All-time: span only the data we have.
|
||||
if (buckets.length === 0) return { throughput: [], success: [], duration: [] };
|
||||
const times = buckets.map((b) => truncUtc(new Date(b.t).getTime(), unit));
|
||||
startMs = Math.min(...times);
|
||||
endMs = Math.max(...times);
|
||||
}
|
||||
|
||||
const throughput: SeriesPoint[] = [];
|
||||
const success: SeriesPoint[] = [];
|
||||
const duration: SeriesPoint[] = [];
|
||||
// Guard against an absurd point count (defensive; backend caps days at 365).
|
||||
let guard = 0;
|
||||
for (let t = startMs; t <= endMs && guard < 5000; t += step, guard++) {
|
||||
const b = byT.get(t);
|
||||
const iso = new Date(t).toISOString();
|
||||
throughput.push({ t: iso, value: b ? b.n : 0 });
|
||||
success.push({ t: iso, value: b && b.n > 0 ? (b.ok / b.n) * 100 : null });
|
||||
duration.push({ t: iso, value: b && b.avg_ms != null ? b.avg_ms : null });
|
||||
}
|
||||
return { throughput, success, duration };
|
||||
}
|
||||
Reference in New Issue
Block a user