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

@@ -170,12 +170,13 @@ public class AgentRegistrationController {
@GetMapping
@Operation(summary = "List all agents",
description = "Returns all registered agents, optionally filtered by status")
description = "Returns all registered agents, optionally filtered by status and/or group")
@ApiResponse(responseCode = "200", description = "Agent list returned")
@ApiResponse(responseCode = "400", description = "Invalid status filter",
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
public ResponseEntity<List<AgentInstanceResponse>> listAgents(
@RequestParam(required = false) String status) {
@RequestParam(required = false) String status,
@RequestParam(required = false) String group) {
List<AgentInfo> agents;
if (status != null) {
@@ -189,6 +190,13 @@ public class AgentRegistrationController {
agents = registryService.findAll();
}
// Apply group filter if specified
if (group != null && !group.isBlank()) {
agents = agents.stream()
.filter(a -> group.equals(a.group()))
.toList();
}
List<AgentInstanceResponse> response = agents.stream()
.map(AgentInstanceResponse::from)
.toList();

View File

@@ -1,6 +1,8 @@
package com.cameleer3.server.app.controller;
import com.cameleer3.common.graph.RouteGraph;
import com.cameleer3.server.core.agent.AgentInfo;
import com.cameleer3.server.core.agent.AgentRegistryService;
import com.cameleer3.server.core.diagram.DiagramLayout;
import com.cameleer3.server.core.diagram.DiagramRenderer;
import com.cameleer3.server.core.storage.DiagramRepository;
@@ -15,8 +17,10 @@ import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Optional;
/**
@@ -37,11 +41,14 @@ public class DiagramRenderController {
private final DiagramRepository diagramRepository;
private final DiagramRenderer diagramRenderer;
private final AgentRegistryService registryService;
public DiagramRenderController(DiagramRepository diagramRepository,
DiagramRenderer diagramRenderer) {
DiagramRenderer diagramRenderer,
AgentRegistryService registryService) {
this.diagramRepository = diagramRepository;
this.diagramRenderer = diagramRenderer;
this.registryService = registryService;
}
@GetMapping("/{contentHash}/render")
@@ -82,6 +89,36 @@ public class DiagramRenderController {
.body(svg);
}
@GetMapping
@Operation(summary = "Find diagram by application group and route ID",
description = "Resolves group to agent IDs and finds the latest diagram for the route")
@ApiResponse(responseCode = "200", description = "Diagram layout returned")
@ApiResponse(responseCode = "404", description = "No diagram found for the given group and route")
public ResponseEntity<DiagramLayout> findByGroupAndRoute(
@RequestParam String group,
@RequestParam String routeId) {
List<String> agentIds = registryService.findByGroup(group).stream()
.map(AgentInfo::id)
.toList();
if (agentIds.isEmpty()) {
return ResponseEntity.notFound().build();
}
Optional<String> contentHash = diagramRepository.findContentHashForRouteByAgents(routeId, agentIds);
if (contentHash.isEmpty()) {
return ResponseEntity.notFound().build();
}
Optional<RouteGraph> graphOpt = diagramRepository.findByContentHash(contentHash.get());
if (graphOpt.isEmpty()) {
return ResponseEntity.notFound().build();
}
DiagramLayout layout = diagramRenderer.layoutJson(graphOpt.get());
return ResponseEntity.ok(layout);
}
/**
* Determine if JSON is the explicitly preferred format.
* <p>

View File

@@ -1,5 +1,7 @@
package com.cameleer3.server.app.controller;
import com.cameleer3.server.core.agent.AgentInfo;
import com.cameleer3.server.core.agent.AgentRegistryService;
import com.cameleer3.server.core.search.ExecutionStats;
import com.cameleer3.server.core.search.ExecutionSummary;
import com.cameleer3.server.core.search.SearchRequest;
@@ -17,6 +19,7 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.Instant;
import java.util.List;
/**
* Search endpoints for querying route executions.
@@ -30,9 +33,11 @@ import java.time.Instant;
public class SearchController {
private final SearchService searchService;
private final AgentRegistryService registryService;
public SearchController(SearchService searchService) {
public SearchController(SearchService searchService, AgentRegistryService registryService) {
this.searchService = searchService;
this.registryService = registryService;
}
@GetMapping("/executions")
@@ -46,17 +51,21 @@ public class SearchController {
@RequestParam(required = false) String routeId,
@RequestParam(required = false) String agentId,
@RequestParam(required = false) String processorType,
@RequestParam(required = false) String group,
@RequestParam(defaultValue = "0") int offset,
@RequestParam(defaultValue = "50") int limit,
@RequestParam(required = false) String sortField,
@RequestParam(required = false) String sortDir) {
List<String> agentIds = resolveGroupToAgentIds(group);
SearchRequest request = new SearchRequest(
status, timeFrom, timeTo,
null, null,
correlationId,
text, null, null, null,
routeId, agentId, processorType,
group, agentIds,
offset, limit,
sortField, sortDir
);
@@ -68,16 +77,28 @@ public class SearchController {
@Operation(summary = "Advanced search with all filters")
public ResponseEntity<SearchResult<ExecutionSummary>> searchPost(
@RequestBody SearchRequest request) {
return ResponseEntity.ok(searchService.search(request));
// Resolve group to agentIds if group is specified but agentIds is not
SearchRequest resolved = request;
if (request.group() != null && !request.group().isBlank()
&& (request.agentIds() == null || request.agentIds().isEmpty())) {
resolved = request.withAgentIds(resolveGroupToAgentIds(request.group()));
}
return ResponseEntity.ok(searchService.search(resolved));
}
@GetMapping("/stats")
@Operation(summary = "Aggregate execution stats (P99 latency, active count)")
public ResponseEntity<ExecutionStats> stats(
@RequestParam Instant from,
@RequestParam(required = false) Instant to) {
@RequestParam(required = false) Instant to,
@RequestParam(required = false) String routeId,
@RequestParam(required = false) String group) {
Instant end = to != null ? to : Instant.now();
return ResponseEntity.ok(searchService.stats(from, end));
List<String> agentIds = resolveGroupToAgentIds(group);
if (routeId == null && agentIds == null) {
return ResponseEntity.ok(searchService.stats(from, end));
}
return ResponseEntity.ok(searchService.stats(from, end, routeId, agentIds));
}
@GetMapping("/stats/timeseries")
@@ -85,8 +106,27 @@ public class SearchController {
public ResponseEntity<StatsTimeseries> timeseries(
@RequestParam Instant from,
@RequestParam(required = false) Instant to,
@RequestParam(defaultValue = "24") int buckets) {
@RequestParam(defaultValue = "24") int buckets,
@RequestParam(required = false) String routeId,
@RequestParam(required = false) String group) {
Instant end = to != null ? to : Instant.now();
return ResponseEntity.ok(searchService.timeseries(from, end, buckets));
List<String> agentIds = resolveGroupToAgentIds(group);
if (routeId == null && agentIds == null) {
return ResponseEntity.ok(searchService.timeseries(from, end, buckets));
}
return ResponseEntity.ok(searchService.timeseries(from, end, buckets, routeId, agentIds));
}
/**
* Resolve an application group name to agent IDs.
* Returns null if group is null/blank (no filtering).
*/
private List<String> resolveGroupToAgentIds(String group) {
if (group == null || group.isBlank()) {
return null;
}
return registryService.findByGroup(group).stream()
.map(AgentInfo::id)
.toList();
}
}

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.
*/

View File

@@ -14,6 +14,8 @@ import org.springframework.stereotype.Repository;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
@@ -94,6 +96,25 @@ public class ClickHouseDiagramRepository implements DiagramRepository {
return Optional.of((String) rows.get(0).get("content_hash"));
}
@Override
public Optional<String> findContentHashForRouteByAgents(String routeId, List<String> agentIds) {
if (agentIds == null || agentIds.isEmpty()) {
return Optional.empty();
}
String placeholders = String.join(", ", Collections.nCopies(agentIds.size(), "?"));
String sql = "SELECT content_hash FROM route_diagrams " +
"WHERE route_id = ? AND agent_id IN (" + placeholders + ") " +
"ORDER BY created_at DESC LIMIT 1";
var params = new ArrayList<Object>();
params.add(routeId);
params.addAll(agentIds);
List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql, params.toArray());
if (rows.isEmpty()) {
return Optional.empty();
}
return Optional.of((String) rows.get(0).get("content_hash"));
}
static String sha256Hex(String input) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");