diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/SearchController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/SearchController.java index 78cdc966..922e443f 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/SearchController.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/SearchController.java @@ -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 stats() { return ResponseEntity.ok(searchService.stats()); } + + @GetMapping("/stats/timeseries") + @Operation(summary = "Bucketed time-series stats over a time window") + public ResponseEntity 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)); + } } diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/search/ClickHouseSearchEngine.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/search/ClickHouseSearchEngine.java index 094811c1..81f77d2f 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/search/ClickHouseSearchEngine.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/search/ClickHouseSearchEngine.java @@ -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 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 conditions, List params) { if (req.status() != null && !req.status().isBlank()) { String[] statuses = req.status().split(","); diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/search/SearchEngine.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/search/SearchEngine.java index 9a083e60..3d5ee450 100644 --- a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/search/SearchEngine.java +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/search/SearchEngine.java @@ -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); } diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/search/SearchService.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/search/SearchService.java index 56aead53..2673983f 100644 --- a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/search/SearchService.java +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/search/SearchService.java @@ -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); + } } diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/search/StatsTimeseries.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/search/StatsTimeseries.java new file mode 100644 index 00000000..3387571a --- /dev/null +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/search/StatsTimeseries.java @@ -0,0 +1,17 @@ +package com.cameleer3.server.core.search; + +import java.time.Instant; +import java.util.List; + +public record StatsTimeseries( + List buckets +) { + public record TimeseriesBucket( + Instant time, + long totalCount, + long failedCount, + long avgDurationMs, + long p99DurationMs, + long activeCount + ) {} +} diff --git a/ui/src/api/queries/executions.ts b/ui/src/api/queries/executions.ts index a749106c..fb151c01 100644 --- a/ui/src/api/queries/executions.ts +++ b/ui/src/api/queries/executions.ts @@ -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], diff --git a/ui/src/api/schema.d.ts b/ui/src/api/schema.d.ts index ceb406c9..a3077833 100644 --- a/ui/src/api/schema.d.ts +++ b/ui/src/api/schema.d.ts @@ -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; diff --git a/ui/src/components/shared/Sparkline.tsx b/ui/src/components/shared/Sparkline.tsx new file mode 100644 index 00000000..fed0a463 --- /dev/null +++ b/ui/src/components/shared/Sparkline.tsx @@ -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 ( +
+ + + + + + + + + + +
+ ); +} diff --git a/ui/src/components/shared/StatCard.tsx b/ui/src/components/shared/StatCard.tsx index da6c6c0a..90bf1455 100644 --- a/ui/src/components/shared/StatCard.tsx +++ b/ui/src/components/shared/StatCard.tsx @@ -1,4 +1,13 @@ import styles from './shared.module.css'; +import { Sparkline } from './Sparkline'; + +const ACCENT_COLORS: Record = { + 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 (
{label}
@@ -16,6 +26,9 @@ export function StatCard({ label, value, accent, change, changeDirection = 'neut {change && (
{change}
)} + {sparkData && sparkData.length >= 2 && ( + + )}
); } diff --git a/ui/src/pages/executions/ExecutionExplorer.tsx b/ui/src/pages/executions/ExecutionExplorer.tsx index 30ff5ee2..49ba72ee 100644 --- a/ui/src/pages/executions/ExecutionExplorer.tsx +++ b/ui/src/pages/executions/ExecutionExplorer.tsx @@ -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 */}
- - - - - + + + + +
{/* Filters */}