Add route diagram page with execution overlay and group-aware APIs
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:
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user