Add stat card sparkline graphs with timeseries backend endpoint
All checks were successful
CI / build (push) Successful in 1m0s
CI / docker (push) Successful in 45s
CI / deploy (push) Successful in 23s

New /search/stats/timeseries endpoint returns bucketed counts/metrics
over a time window using ClickHouse toStartOfInterval(). Frontend
Sparkline component renders SVG polyline + gradient fill on each
stat card, driven by a useStatsTimeseries query hook.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-13 18:20:08 +01:00
parent cccd3f07be
commit 9e6e1b350a
10 changed files with 217 additions and 7 deletions

View File

@@ -28,6 +28,27 @@ export function useSearchExecutions(filters: SearchRequest) {
});
}
export function useStatsTimeseries(timeFrom: string | undefined, timeTo: string | undefined) {
return useQuery({
queryKey: ['executions', 'timeseries', timeFrom, timeTo],
queryFn: async () => {
const { data, error } = await api.GET('/search/stats/timeseries', {
params: {
query: {
from: timeFrom!,
to: timeTo || undefined,
buckets: 24,
},
},
});
if (error) throw new Error('Failed to load timeseries');
return data!;
},
enabled: !!timeFrom,
refetchInterval: 30_000,
});
}
export function useExecutionDetail(executionId: string | null) {
return useQuery({
queryKey: ['executions', 'detail', executionId],

View File

@@ -106,6 +106,24 @@ export interface paths {
};
};
};
'/search/stats/timeseries': {
get: {
parameters: {
query: {
from: string;
to?: string;
buckets?: number;
};
};
responses: {
200: {
content: {
'application/json': StatsTimeseries;
};
};
};
};
};
'/agents': {
get: {
parameters: {
@@ -197,6 +215,19 @@ export interface ExecutionStats {
activeCount: number;
}
export interface StatsTimeseries {
buckets: TimeseriesBucket[];
}
export interface TimeseriesBucket {
time: string;
totalCount: number;
failedCount: number;
avgDurationMs: number;
p99DurationMs: number;
activeCount: number;
}
export interface AgentInstance {
id: string;
name: string;

View File

@@ -0,0 +1,57 @@
import { useMemo, useId } from 'react';
interface SparklineProps {
data: number[];
color: string;
}
export function Sparkline({ data, color }: SparklineProps) {
const gradientId = useId();
const { linePath, fillPath } = useMemo(() => {
if (data.length < 2) return { linePath: '', fillPath: '' };
const w = 200;
const h = 24;
const max = Math.max(...data);
const min = Math.min(...data);
const range = max - min || 1;
const step = w / (data.length - 1);
const points = data.map(
(v, i) => `${i * step},${h - ((v - min) / range) * (h - 2) - 1}`,
);
return {
linePath: points.join(' '),
fillPath: `0,${h} ${points.join(' ')} ${w},${h}`,
};
}, [data]);
if (data.length < 2) return null;
return (
<div style={{ marginTop: 10, height: 24 }}>
<svg
viewBox="0 0 200 24"
preserveAspectRatio="none"
style={{ width: '100%', height: '100%' }}
>
<defs>
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={color} stopOpacity={0.3} />
<stop offset="100%" stopColor={color} stopOpacity={0} />
</linearGradient>
</defs>
<polygon points={fillPath} fill={`url(#${gradientId})`} />
<polyline
points={linePath}
fill="none"
stroke={color}
strokeWidth="1.5"
strokeLinejoin="round"
/>
</svg>
</div>
);
}

View File

@@ -1,4 +1,13 @@
import styles from './shared.module.css';
import { Sparkline } from './Sparkline';
const ACCENT_COLORS: Record<string, string> = {
amber: 'var(--amber)',
cyan: 'var(--cyan)',
rose: 'var(--rose)',
green: 'var(--green)',
blue: 'var(--blue)',
};
interface StatCardProps {
label: string;
@@ -6,9 +15,10 @@ interface StatCardProps {
accent: 'amber' | 'cyan' | 'rose' | 'green' | 'blue';
change?: string;
changeDirection?: 'up' | 'down' | 'neutral';
sparkData?: number[];
}
export function StatCard({ label, value, accent, change, changeDirection = 'neutral' }: StatCardProps) {
export function StatCard({ label, value, accent, change, changeDirection = 'neutral', sparkData }: StatCardProps) {
return (
<div className={`${styles.statCard} ${styles[accent]}`}>
<div className={styles.statLabel}>{label}</div>
@@ -16,6 +26,9 @@ export function StatCard({ label, value, accent, change, changeDirection = 'neut
{change && (
<div className={`${styles.statChange} ${styles[changeDirection]}`}>{change}</div>
)}
{sparkData && sparkData.length >= 2 && (
<Sparkline data={sparkData} color={ACCENT_COLORS[accent] ?? ACCENT_COLORS.amber} />
)}
</div>
);
}

View File

@@ -1,4 +1,4 @@
import { useSearchExecutions, useExecutionStats } from '../../api/queries/executions';
import { useSearchExecutions, useExecutionStats, useStatsTimeseries } from '../../api/queries/executions';
import { useExecutionSearch } from './use-execution-search';
import { StatCard } from '../../components/shared/StatCard';
import { Pagination } from '../../components/shared/Pagination';
@@ -11,6 +11,16 @@ export function ExecutionExplorer() {
const searchRequest = toSearchRequest();
const { data, isLoading, isFetching } = useSearchExecutions(searchRequest);
const { data: stats } = useExecutionStats();
const { data: timeseries } = useStatsTimeseries(
searchRequest.timeFrom ?? undefined,
searchRequest.timeTo ?? undefined,
);
const sparkTotal = timeseries?.buckets.map((b) => b.totalCount) ?? [];
const sparkFailed = timeseries?.buckets.map((b) => b.failedCount) ?? [];
const sparkAvgDuration = timeseries?.buckets.map((b) => b.avgDurationMs) ?? [];
const sparkP99 = timeseries?.buckets.map((b) => b.p99DurationMs) ?? [];
const sparkActive = timeseries?.buckets.map((b) => b.activeCount) ?? [];
const total = data?.total ?? 0;
const results = data?.data ?? [];
@@ -40,11 +50,11 @@ export function ExecutionExplorer() {
{/* Stats Bar */}
<div className={styles.statsBar}>
<StatCard label="Total Matches" value={total.toLocaleString()} accent="amber" change={`from current search`} />
<StatCard label="Avg Duration" value={`${avgDuration}ms`} accent="cyan" />
<StatCard label="Failed (page)" value={failedCount.toString()} accent="rose" />
<StatCard label="P99 Latency" value={stats ? `${stats.p99LatencyMs}ms` : '--'} accent="green" change="last hour" />
<StatCard label="Active Now" value={stats ? stats.activeCount.toString() : '--'} accent="blue" change="running executions" />
<StatCard label="Total Matches" value={total.toLocaleString()} accent="amber" change={`from current search`} sparkData={sparkTotal} />
<StatCard label="Avg Duration" value={`${avgDuration}ms`} accent="cyan" sparkData={sparkAvgDuration} />
<StatCard label="Failed (page)" value={failedCount.toString()} accent="rose" sparkData={sparkFailed} />
<StatCard label="P99 Latency" value={stats ? `${stats.p99LatencyMs}ms` : '--'} accent="green" change="last hour" sparkData={sparkP99} />
<StatCard label="Active Now" value={stats ? stats.activeCount.toString() : '--'} accent="blue" change="running executions" sparkData={sparkActive} />
</div>
{/* Filters */}