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(",");
|
||||
|
||||
Reference in New Issue
Block a user