Add route diagram page with execution overlay and group-aware APIs
All checks were successful
CI / build (push) Successful in 1m10s
CI / docker (push) Successful in 1m3s
CI / deploy (push) Successful in 31s

Backend: Add group filtering to agent list, search, stats, and timeseries
endpoints. Add diagram lookup by group+routeId. Resolve application group
to agent IDs server-side for ClickHouse IN-clause queries.

Frontend: New route detail page at /apps/{group}/routes/{routeId} with
three tabs (Diagram, Performance, Processor Tree). SVG diagram rendering
with panzoom, execution overlay (glow effects, duration/sequence badges,
flow particles, minimap), and processor detail panel. uPlot charts for
performance tab replacing old SVG sparklines. Ctrl+Click from
ExecutionExplorer navigates to route diagram with overlay.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-14 21:35:42 +01:00
parent b64edaa16f
commit 7778793e7b
41 changed files with 2770 additions and 26 deletions

View File

@@ -12,6 +12,7 @@ import java.sql.Timestamp;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
@@ -90,12 +91,27 @@ public class ClickHouseSearchEngine implements SearchEngine {
@Override
public ExecutionStats stats(Instant from, Instant to) {
return stats(from, to, null, null);
}
@Override
public ExecutionStats stats(Instant from, Instant to, String routeId, List<String> agentIds) {
var conditions = new ArrayList<String>();
var params = new ArrayList<Object>();
conditions.add("start_time >= ?");
params.add(Timestamp.from(from));
conditions.add("start_time <= ?");
params.add(Timestamp.from(to));
addScopeFilters(routeId, agentIds, conditions, params);
String where = " WHERE " + String.join(" AND ", conditions);
String aggregateSql = "SELECT count() AS total_count, " +
"countIf(status = 'FAILED') AS failed_count, " +
"toInt64(ifNotFinite(avg(duration_ms), 0)) AS avg_duration_ms, " +
"toInt64(ifNotFinite(quantile(0.99)(duration_ms), 0)) AS p99_duration_ms, " +
"countIf(status = 'RUNNING') AS active_count " +
"FROM route_executions WHERE start_time >= ? AND start_time <= ?";
"FROM route_executions" + where;
// Current period
record PeriodStats(long totalCount, long failedCount, long avgDurationMs, long p99LatencyMs, long activeCount) {}
@@ -106,26 +122,49 @@ public class ClickHouseSearchEngine implements SearchEngine {
rs.getLong("avg_duration_ms"),
rs.getLong("p99_duration_ms"),
rs.getLong("active_count")),
Timestamp.from(from), Timestamp.from(to));
params.toArray());
// Previous period (same window shifted back 24h)
Duration window = Duration.between(from, to);
Instant prevFrom = from.minus(Duration.ofHours(24));
Instant prevTo = prevFrom.plus(window);
PeriodStats prev = jdbcTemplate.queryForObject(aggregateSql,
var prevParams = new ArrayList<Object>();
var prevConditions = new ArrayList<String>();
prevConditions.add("start_time >= ?");
prevParams.add(Timestamp.from(prevFrom));
prevConditions.add("start_time <= ?");
prevParams.add(Timestamp.from(prevTo));
addScopeFilters(routeId, agentIds, prevConditions, prevParams);
String prevWhere = " WHERE " + String.join(" AND ", prevConditions);
String prevAggregateSql = "SELECT count() AS total_count, " +
"countIf(status = 'FAILED') AS failed_count, " +
"toInt64(ifNotFinite(avg(duration_ms), 0)) AS avg_duration_ms, " +
"toInt64(ifNotFinite(quantile(0.99)(duration_ms), 0)) AS p99_duration_ms, " +
"countIf(status = 'RUNNING') AS active_count " +
"FROM route_executions" + prevWhere;
PeriodStats prev = jdbcTemplate.queryForObject(prevAggregateSql,
(rs, rowNum) -> new PeriodStats(
rs.getLong("total_count"),
rs.getLong("failed_count"),
rs.getLong("avg_duration_ms"),
rs.getLong("p99_duration_ms"),
rs.getLong("active_count")),
Timestamp.from(prevFrom), Timestamp.from(prevTo));
prevParams.toArray());
// Today total (midnight UTC to now)
// Today total (midnight UTC to now) with same scope
Instant todayStart = Instant.now().truncatedTo(java.time.temporal.ChronoUnit.DAYS);
var todayConditions = new ArrayList<String>();
var todayParams = new ArrayList<Object>();
todayConditions.add("start_time >= ?");
todayParams.add(Timestamp.from(todayStart));
addScopeFilters(routeId, agentIds, todayConditions, todayParams);
String todayWhere = " WHERE " + String.join(" AND ", todayConditions);
Long totalToday = jdbcTemplate.queryForObject(
"SELECT count() FROM route_executions WHERE start_time >= ?",
Long.class, Timestamp.from(todayStart));
"SELECT count() FROM route_executions" + todayWhere,
Long.class, todayParams.toArray());
return new ExecutionStats(
current.totalCount, current.failedCount, current.avgDurationMs,
@@ -136,9 +175,25 @@ public class ClickHouseSearchEngine implements SearchEngine {
@Override
public StatsTimeseries timeseries(Instant from, Instant to, int bucketCount) {
return timeseries(from, to, bucketCount, null, null);
}
@Override
public StatsTimeseries timeseries(Instant from, Instant to, int bucketCount,
String routeId, List<String> agentIds) {
long intervalSeconds = Duration.between(from, to).getSeconds() / bucketCount;
if (intervalSeconds < 1) intervalSeconds = 1;
var conditions = new ArrayList<String>();
var params = new ArrayList<Object>();
conditions.add("start_time >= ?");
params.add(Timestamp.from(from));
conditions.add("start_time <= ?");
params.add(Timestamp.from(to));
addScopeFilters(routeId, agentIds, conditions, params);
String where = " WHERE " + String.join(" AND ", conditions);
// Use epoch-based bucketing for DateTime64 compatibility
String sql = "SELECT " +
"toDateTime(intDiv(toUInt32(toDateTime(start_time)), " + intervalSeconds + ") * " + intervalSeconds + ") AS bucket, " +
@@ -147,9 +202,8 @@ public class ClickHouseSearchEngine implements SearchEngine {
"toInt64(ifNotFinite(avg(duration_ms), 0)) AS avg_duration_ms, " +
"toInt64(ifNotFinite(quantile(0.99)(duration_ms), 0)) AS p99_duration_ms, " +
"countIf(status = 'RUNNING') AS active_count " +
"FROM route_executions " +
"WHERE start_time >= ? AND start_time <= ? " +
"GROUP BY bucket " +
"FROM route_executions" + where +
" GROUP BY bucket " +
"ORDER BY bucket";
List<StatsTimeseries.TimeseriesBucket> buckets = jdbcTemplate.query(sql, (rs, rowNum) ->
@@ -161,7 +215,7 @@ public class ClickHouseSearchEngine implements SearchEngine {
rs.getLong("p99_duration_ms"),
rs.getLong("active_count")
),
Timestamp.from(from), Timestamp.from(to));
params.toArray());
return new StatsTimeseries(buckets);
}
@@ -173,7 +227,7 @@ public class ClickHouseSearchEngine implements SearchEngine {
conditions.add("status = ?");
params.add(statuses[0].trim());
} else {
String placeholders = String.join(", ", java.util.Collections.nCopies(statuses.length, "?"));
String placeholders = String.join(", ", Collections.nCopies(statuses.length, "?"));
conditions.add("status IN (" + placeholders + ")");
for (String s : statuses) {
params.add(s.trim());
@@ -208,6 +262,13 @@ public class ClickHouseSearchEngine implements SearchEngine {
conditions.add("agent_id = ?");
params.add(req.agentId());
}
// agentIds from group resolution (takes precedence when agentId is not set)
if ((req.agentId() == null || req.agentId().isBlank())
&& req.agentIds() != null && !req.agentIds().isEmpty()) {
String placeholders = String.join(", ", Collections.nCopies(req.agentIds().size(), "?"));
conditions.add("agent_id IN (" + placeholders + ")");
params.addAll(req.agentIds());
}
if (req.processorType() != null && !req.processorType().isBlank()) {
conditions.add("has(processor_types, ?)");
params.add(req.processorType());
@@ -243,6 +304,22 @@ public class ClickHouseSearchEngine implements SearchEngine {
}
}
/**
* Add route ID and agent IDs scope filters to conditions/params.
*/
private void addScopeFilters(String routeId, List<String> agentIds,
List<String> conditions, List<Object> params) {
if (routeId != null && !routeId.isBlank()) {
conditions.add("route_id = ?");
params.add(routeId);
}
if (agentIds != null && !agentIds.isEmpty()) {
String placeholders = String.join(", ", Collections.nCopies(agentIds.size(), "?"));
conditions.add("agent_id IN (" + placeholders + ")");
params.addAll(agentIds);
}
}
/**
* Escape special LIKE characters to prevent LIKE injection.
*/