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();
}
}