Add stat card sparkline graphs with timeseries backend endpoint
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:
@@ -5,6 +5,7 @@ import com.cameleer3.server.core.search.ExecutionSummary;
|
||||
import com.cameleer3.server.core.search.SearchRequest;
|
||||
import com.cameleer3.server.core.search.SearchResult;
|
||||
import com.cameleer3.server.core.search.SearchService;
|
||||
import com.cameleer3.server.core.search.StatsTimeseries;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -72,4 +73,14 @@ public class SearchController {
|
||||
public ResponseEntity<ExecutionStats> stats() {
|
||||
return ResponseEntity.ok(searchService.stats());
|
||||
}
|
||||
|
||||
@GetMapping("/stats/timeseries")
|
||||
@Operation(summary = "Bucketed time-series stats over a time window")
|
||||
public ResponseEntity<StatsTimeseries> timeseries(
|
||||
@RequestParam Instant from,
|
||||
@RequestParam(required = false) Instant to,
|
||||
@RequestParam(defaultValue = "24") int buckets) {
|
||||
Instant end = to != null ? to : Instant.now();
|
||||
return ResponseEntity.ok(searchService.timeseries(from, end, buckets));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,11 @@ import com.cameleer3.server.core.search.ExecutionSummary;
|
||||
import com.cameleer3.server.core.search.SearchEngine;
|
||||
import com.cameleer3.server.core.search.SearchRequest;
|
||||
import com.cameleer3.server.core.search.SearchResult;
|
||||
import com.cameleer3.server.core.search.StatsTimeseries;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
@@ -99,6 +101,37 @@ public class ClickHouseSearchEngine implements SearchEngine {
|
||||
active != null ? active : 0L);
|
||||
}
|
||||
|
||||
@Override
|
||||
public StatsTimeseries timeseries(Instant from, Instant to, int bucketCount) {
|
||||
long intervalSeconds = Duration.between(from, to).getSeconds() / bucketCount;
|
||||
if (intervalSeconds < 1) intervalSeconds = 1;
|
||||
|
||||
String sql = "SELECT " +
|
||||
"toStartOfInterval(start_time, INTERVAL " + intervalSeconds + " SECOND) AS bucket, " +
|
||||
"count() AS total_count, " +
|
||||
"countIf(status = 'FAILED') AS failed_count, " +
|
||||
"avg(duration_ms) AS avg_duration_ms, " +
|
||||
"quantile(0.99)(duration_ms) AS p99_duration_ms, " +
|
||||
"countIf(status = 'RUNNING') AS active_count " +
|
||||
"FROM route_executions " +
|
||||
"WHERE start_time >= ? AND start_time <= ? " +
|
||||
"GROUP BY bucket " +
|
||||
"ORDER BY bucket";
|
||||
|
||||
List<StatsTimeseries.TimeseriesBucket> buckets = jdbcTemplate.query(sql, (rs, rowNum) ->
|
||||
new StatsTimeseries.TimeseriesBucket(
|
||||
rs.getTimestamp("bucket").toInstant(),
|
||||
rs.getLong("total_count"),
|
||||
rs.getLong("failed_count"),
|
||||
rs.getLong("avg_duration_ms"),
|
||||
rs.getLong("p99_duration_ms"),
|
||||
rs.getLong("active_count")
|
||||
),
|
||||
Timestamp.from(from), Timestamp.from(to));
|
||||
|
||||
return new StatsTimeseries(buckets);
|
||||
}
|
||||
|
||||
private void buildWhereClause(SearchRequest req, List<String> conditions, List<Object> params) {
|
||||
if (req.status() != null && !req.status().isBlank()) {
|
||||
String[] statuses = req.status().split(",");
|
||||
|
||||
@@ -31,4 +31,14 @@ public interface SearchEngine {
|
||||
* @return execution stats
|
||||
*/
|
||||
ExecutionStats stats();
|
||||
|
||||
/**
|
||||
* Compute bucketed time-series stats over a time window.
|
||||
*
|
||||
* @param from start of the time window
|
||||
* @param to end of the time window
|
||||
* @param bucketCount number of buckets to divide the window into
|
||||
* @return bucketed stats
|
||||
*/
|
||||
StatsTimeseries timeseries(java.time.Instant from, java.time.Instant to, int bucketCount);
|
||||
}
|
||||
|
||||
@@ -35,4 +35,11 @@ public class SearchService {
|
||||
public ExecutionStats stats() {
|
||||
return engine.stats();
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute bucketed time-series stats over a time window.
|
||||
*/
|
||||
public StatsTimeseries timeseries(java.time.Instant from, java.time.Instant to, int bucketCount) {
|
||||
return engine.timeseries(from, to, bucketCount);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.cameleer3.server.core.search;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
public record StatsTimeseries(
|
||||
List<TimeseriesBucket> buckets
|
||||
) {
|
||||
public record TimeseriesBucket(
|
||||
Instant time,
|
||||
long totalCount,
|
||||
long failedCount,
|
||||
long avgDurationMs,
|
||||
long p99DurationMs,
|
||||
long activeCount
|
||||
) {}
|
||||
}
|
||||
@@ -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],
|
||||
|
||||
31
ui/src/api/schema.d.ts
vendored
31
ui/src/api/schema.d.ts
vendored
@@ -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;
|
||||
|
||||
57
ui/src/components/shared/Sparkline.tsx
Normal file
57
ui/src/components/shared/Sparkline.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user