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 @GetMapping
@Operation(summary = "List all agents", @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 = "200", description = "Agent list returned")
@ApiResponse(responseCode = "400", description = "Invalid status filter", @ApiResponse(responseCode = "400", description = "Invalid status filter",
content = @Content(schema = @Schema(implementation = ErrorResponse.class))) content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
public ResponseEntity<List<AgentInstanceResponse>> listAgents( public ResponseEntity<List<AgentInstanceResponse>> listAgents(
@RequestParam(required = false) String status) { @RequestParam(required = false) String status,
@RequestParam(required = false) String group) {
List<AgentInfo> agents; List<AgentInfo> agents;
if (status != null) { if (status != null) {
@@ -189,6 +190,13 @@ public class AgentRegistrationController {
agents = registryService.findAll(); 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() List<AgentInstanceResponse> response = agents.stream()
.map(AgentInstanceResponse::from) .map(AgentInstanceResponse::from)
.toList(); .toList();

View File

@@ -1,6 +1,8 @@
package com.cameleer3.server.app.controller; package com.cameleer3.server.app.controller;
import com.cameleer3.common.graph.RouteGraph; 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.DiagramLayout;
import com.cameleer3.server.core.diagram.DiagramRenderer; import com.cameleer3.server.core.diagram.DiagramRenderer;
import com.cameleer3.server.core.storage.DiagramRepository; 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.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Optional; import java.util.Optional;
/** /**
@@ -37,11 +41,14 @@ public class DiagramRenderController {
private final DiagramRepository diagramRepository; private final DiagramRepository diagramRepository;
private final DiagramRenderer diagramRenderer; private final DiagramRenderer diagramRenderer;
private final AgentRegistryService registryService;
public DiagramRenderController(DiagramRepository diagramRepository, public DiagramRenderController(DiagramRepository diagramRepository,
DiagramRenderer diagramRenderer) { DiagramRenderer diagramRenderer,
AgentRegistryService registryService) {
this.diagramRepository = diagramRepository; this.diagramRepository = diagramRepository;
this.diagramRenderer = diagramRenderer; this.diagramRenderer = diagramRenderer;
this.registryService = registryService;
} }
@GetMapping("/{contentHash}/render") @GetMapping("/{contentHash}/render")
@@ -82,6 +89,36 @@ public class DiagramRenderController {
.body(svg); .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. * Determine if JSON is the explicitly preferred format.
* <p> * <p>

View File

@@ -1,5 +1,7 @@
package com.cameleer3.server.app.controller; 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.ExecutionStats;
import com.cameleer3.server.core.search.ExecutionSummary; import com.cameleer3.server.core.search.ExecutionSummary;
import com.cameleer3.server.core.search.SearchRequest; 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 org.springframework.web.bind.annotation.RestController;
import java.time.Instant; import java.time.Instant;
import java.util.List;
/** /**
* Search endpoints for querying route executions. * Search endpoints for querying route executions.
@@ -30,9 +33,11 @@ import java.time.Instant;
public class SearchController { public class SearchController {
private final SearchService searchService; private final SearchService searchService;
private final AgentRegistryService registryService;
public SearchController(SearchService searchService) { public SearchController(SearchService searchService, AgentRegistryService registryService) {
this.searchService = searchService; this.searchService = searchService;
this.registryService = registryService;
} }
@GetMapping("/executions") @GetMapping("/executions")
@@ -46,17 +51,21 @@ public class SearchController {
@RequestParam(required = false) String routeId, @RequestParam(required = false) String routeId,
@RequestParam(required = false) String agentId, @RequestParam(required = false) String agentId,
@RequestParam(required = false) String processorType, @RequestParam(required = false) String processorType,
@RequestParam(required = false) String group,
@RequestParam(defaultValue = "0") int offset, @RequestParam(defaultValue = "0") int offset,
@RequestParam(defaultValue = "50") int limit, @RequestParam(defaultValue = "50") int limit,
@RequestParam(required = false) String sortField, @RequestParam(required = false) String sortField,
@RequestParam(required = false) String sortDir) { @RequestParam(required = false) String sortDir) {
List<String> agentIds = resolveGroupToAgentIds(group);
SearchRequest request = new SearchRequest( SearchRequest request = new SearchRequest(
status, timeFrom, timeTo, status, timeFrom, timeTo,
null, null, null, null,
correlationId, correlationId,
text, null, null, null, text, null, null, null,
routeId, agentId, processorType, routeId, agentId, processorType,
group, agentIds,
offset, limit, offset, limit,
sortField, sortDir sortField, sortDir
); );
@@ -68,16 +77,28 @@ public class SearchController {
@Operation(summary = "Advanced search with all filters") @Operation(summary = "Advanced search with all filters")
public ResponseEntity<SearchResult<ExecutionSummary>> searchPost( public ResponseEntity<SearchResult<ExecutionSummary>> searchPost(
@RequestBody SearchRequest request) { @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") @GetMapping("/stats")
@Operation(summary = "Aggregate execution stats (P99 latency, active count)") @Operation(summary = "Aggregate execution stats (P99 latency, active count)")
public ResponseEntity<ExecutionStats> stats( public ResponseEntity<ExecutionStats> stats(
@RequestParam Instant from, @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(); 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") @GetMapping("/stats/timeseries")
@@ -85,8 +106,27 @@ public class SearchController {
public ResponseEntity<StatsTimeseries> timeseries( public ResponseEntity<StatsTimeseries> timeseries(
@RequestParam Instant from, @RequestParam Instant from,
@RequestParam(required = false) Instant to, @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(); 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.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections;
import java.util.List; import java.util.List;
/** /**
@@ -90,12 +91,27 @@ public class ClickHouseSearchEngine implements SearchEngine {
@Override @Override
public ExecutionStats stats(Instant from, Instant to) { 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, " + String aggregateSql = "SELECT count() AS total_count, " +
"countIf(status = 'FAILED') AS failed_count, " + "countIf(status = 'FAILED') AS failed_count, " +
"toInt64(ifNotFinite(avg(duration_ms), 0)) AS avg_duration_ms, " + "toInt64(ifNotFinite(avg(duration_ms), 0)) AS avg_duration_ms, " +
"toInt64(ifNotFinite(quantile(0.99)(duration_ms), 0)) AS p99_duration_ms, " + "toInt64(ifNotFinite(quantile(0.99)(duration_ms), 0)) AS p99_duration_ms, " +
"countIf(status = 'RUNNING') AS active_count " + "countIf(status = 'RUNNING') AS active_count " +
"FROM route_executions WHERE start_time >= ? AND start_time <= ?"; "FROM route_executions" + where;
// Current period // Current period
record PeriodStats(long totalCount, long failedCount, long avgDurationMs, long p99LatencyMs, long activeCount) {} 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("avg_duration_ms"),
rs.getLong("p99_duration_ms"), rs.getLong("p99_duration_ms"),
rs.getLong("active_count")), rs.getLong("active_count")),
Timestamp.from(from), Timestamp.from(to)); params.toArray());
// Previous period (same window shifted back 24h) // Previous period (same window shifted back 24h)
Duration window = Duration.between(from, to); Duration window = Duration.between(from, to);
Instant prevFrom = from.minus(Duration.ofHours(24)); Instant prevFrom = from.minus(Duration.ofHours(24));
Instant prevTo = prevFrom.plus(window); 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, rowNum) -> new PeriodStats(
rs.getLong("total_count"), rs.getLong("total_count"),
rs.getLong("failed_count"), rs.getLong("failed_count"),
rs.getLong("avg_duration_ms"), rs.getLong("avg_duration_ms"),
rs.getLong("p99_duration_ms"), rs.getLong("p99_duration_ms"),
rs.getLong("active_count")), 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); 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( Long totalToday = jdbcTemplate.queryForObject(
"SELECT count() FROM route_executions WHERE start_time >= ?", "SELECT count() FROM route_executions" + todayWhere,
Long.class, Timestamp.from(todayStart)); Long.class, todayParams.toArray());
return new ExecutionStats( return new ExecutionStats(
current.totalCount, current.failedCount, current.avgDurationMs, current.totalCount, current.failedCount, current.avgDurationMs,
@@ -136,9 +175,25 @@ public class ClickHouseSearchEngine implements SearchEngine {
@Override @Override
public StatsTimeseries timeseries(Instant from, Instant to, int bucketCount) { 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; long intervalSeconds = Duration.between(from, to).getSeconds() / bucketCount;
if (intervalSeconds < 1) intervalSeconds = 1; 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 // Use epoch-based bucketing for DateTime64 compatibility
String sql = "SELECT " + String sql = "SELECT " +
"toDateTime(intDiv(toUInt32(toDateTime(start_time)), " + intervalSeconds + ") * " + intervalSeconds + ") AS bucket, " + "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(avg(duration_ms), 0)) AS avg_duration_ms, " +
"toInt64(ifNotFinite(quantile(0.99)(duration_ms), 0)) AS p99_duration_ms, " + "toInt64(ifNotFinite(quantile(0.99)(duration_ms), 0)) AS p99_duration_ms, " +
"countIf(status = 'RUNNING') AS active_count " + "countIf(status = 'RUNNING') AS active_count " +
"FROM route_executions " + "FROM route_executions" + where +
"WHERE start_time >= ? AND start_time <= ? " + " GROUP BY bucket " +
"GROUP BY bucket " +
"ORDER BY bucket"; "ORDER BY bucket";
List<StatsTimeseries.TimeseriesBucket> buckets = jdbcTemplate.query(sql, (rs, rowNum) -> 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("p99_duration_ms"),
rs.getLong("active_count") rs.getLong("active_count")
), ),
Timestamp.from(from), Timestamp.from(to)); params.toArray());
return new StatsTimeseries(buckets); return new StatsTimeseries(buckets);
} }
@@ -173,7 +227,7 @@ public class ClickHouseSearchEngine implements SearchEngine {
conditions.add("status = ?"); conditions.add("status = ?");
params.add(statuses[0].trim()); params.add(statuses[0].trim());
} else { } else {
String placeholders = String.join(", ", java.util.Collections.nCopies(statuses.length, "?")); String placeholders = String.join(", ", Collections.nCopies(statuses.length, "?"));
conditions.add("status IN (" + placeholders + ")"); conditions.add("status IN (" + placeholders + ")");
for (String s : statuses) { for (String s : statuses) {
params.add(s.trim()); params.add(s.trim());
@@ -208,6 +262,13 @@ public class ClickHouseSearchEngine implements SearchEngine {
conditions.add("agent_id = ?"); conditions.add("agent_id = ?");
params.add(req.agentId()); 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()) { if (req.processorType() != null && !req.processorType().isBlank()) {
conditions.add("has(processor_types, ?)"); conditions.add("has(processor_types, ?)");
params.add(req.processorType()); 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. * 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.nio.charset.StandardCharsets;
import java.security.MessageDigest; import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException; import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HexFormat; import java.util.HexFormat;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -94,6 +96,25 @@ public class ClickHouseDiagramRepository implements DiagramRepository {
return Optional.of((String) rows.get(0).get("content_hash")); 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) { static String sha256Hex(String input) {
try { try {
MessageDigest digest = MessageDigest.getInstance("SHA-256"); MessageDigest digest = MessageDigest.getInstance("SHA-256");

View File

@@ -167,6 +167,15 @@ public class AgentRegistryService {
.collect(Collectors.toList()); .collect(Collectors.toList());
} }
/**
* Return all agents belonging to the given application group.
*/
public List<AgentInfo> findByGroup(String group) {
return agents.values().stream()
.filter(a -> group.equals(a.group()))
.collect(Collectors.toList());
}
/** /**
* Add a command to an agent's pending queue. * Add a command to an agent's pending queue.
* Notifies the event listener if one is set. * Notifies the event listener if one is set.

View File

@@ -1,5 +1,7 @@
package com.cameleer3.server.core.search; package com.cameleer3.server.core.search;
import java.util.List;
/** /**
* Swappable search backend abstraction. * Swappable search backend abstraction.
* <p> * <p>
@@ -34,6 +36,17 @@ public interface SearchEngine {
*/ */
ExecutionStats stats(java.time.Instant from, java.time.Instant to); ExecutionStats stats(java.time.Instant from, java.time.Instant to);
/**
* Compute aggregate stats scoped to specific routes and agents.
*
* @param from start of the time window
* @param to end of the time window
* @param routeId optional route ID filter
* @param agentIds optional agent ID filter (from group resolution)
* @return execution stats
*/
ExecutionStats stats(java.time.Instant from, java.time.Instant to, String routeId, List<String> agentIds);
/** /**
* Compute bucketed time-series stats over a time window. * Compute bucketed time-series stats over a time window.
* *
@@ -43,4 +56,17 @@ public interface SearchEngine {
* @return bucketed stats * @return bucketed stats
*/ */
StatsTimeseries timeseries(java.time.Instant from, java.time.Instant to, int bucketCount); StatsTimeseries timeseries(java.time.Instant from, java.time.Instant to, int bucketCount);
/**
* Compute bucketed time-series stats scoped to specific routes and agents.
*
* @param from start of the time window
* @param to end of the time window
* @param bucketCount number of buckets to divide the window into
* @param routeId optional route ID filter
* @param agentIds optional agent ID filter (from group resolution)
* @return bucketed stats
*/
StatsTimeseries timeseries(java.time.Instant from, java.time.Instant to, int bucketCount,
String routeId, List<String> agentIds);
} }

View File

@@ -1,6 +1,7 @@
package com.cameleer3.server.core.search; package com.cameleer3.server.core.search;
import java.time.Instant; import java.time.Instant;
import java.util.List;
/** /**
* Immutable search criteria for querying route executions. * Immutable search criteria for querying route executions.
@@ -21,6 +22,8 @@ import java.time.Instant;
* @param routeId exact match on route_id * @param routeId exact match on route_id
* @param agentId exact match on agent_id * @param agentId exact match on agent_id
* @param processorType matches processor_types array via has() * @param processorType matches processor_types array via has()
* @param group application group filter (resolved to agentIds server-side)
* @param agentIds list of agent IDs (resolved from group, used for IN clause)
* @param offset pagination offset (0-based) * @param offset pagination offset (0-based)
* @param limit page size (default 50, max 500) * @param limit page size (default 50, max 500)
* @param sortField column to sort by (default: startTime) * @param sortField column to sort by (default: startTime)
@@ -40,6 +43,8 @@ public record SearchRequest(
String routeId, String routeId,
String agentId, String agentId,
String processorType, String processorType,
String group,
List<String> agentIds,
int offset, int offset,
int limit, int limit,
String sortField, String sortField,
@@ -74,4 +79,14 @@ public record SearchRequest(
public String sortColumn() { public String sortColumn() {
return SORT_FIELD_TO_COLUMN.getOrDefault(sortField, "start_time"); return SORT_FIELD_TO_COLUMN.getOrDefault(sortField, "start_time");
} }
/** Create a copy with resolved agentIds (from group lookup). */
public SearchRequest withAgentIds(List<String> resolvedAgentIds) {
return new SearchRequest(
status, timeFrom, timeTo, durationMin, durationMax, correlationId,
text, textInBody, textInHeaders, textInErrors,
routeId, agentId, processorType, group, resolvedAgentIds,
offset, limit, sortField, sortDir
);
}
} }

View File

@@ -1,5 +1,7 @@
package com.cameleer3.server.core.search; package com.cameleer3.server.core.search;
import java.util.List;
/** /**
* Orchestrates search operations, delegating to a {@link SearchEngine} backend. * Orchestrates search operations, delegating to a {@link SearchEngine} backend.
* <p> * <p>
@@ -36,10 +38,26 @@ public class SearchService {
return engine.stats(from, to); return engine.stats(from, to);
} }
/**
* Compute aggregate execution stats scoped to specific routes and agents.
*/
public ExecutionStats stats(java.time.Instant from, java.time.Instant to,
String routeId, List<String> agentIds) {
return engine.stats(from, to, routeId, agentIds);
}
/** /**
* Compute bucketed time-series stats over a time window. * Compute bucketed time-series stats over a time window.
*/ */
public StatsTimeseries timeseries(java.time.Instant from, java.time.Instant to, int bucketCount) { public StatsTimeseries timeseries(java.time.Instant from, java.time.Instant to, int bucketCount) {
return engine.timeseries(from, to, bucketCount); return engine.timeseries(from, to, bucketCount);
} }
/**
* Compute bucketed time-series stats scoped to specific routes and agents.
*/
public StatsTimeseries timeseries(java.time.Instant from, java.time.Instant to, int bucketCount,
String routeId, List<String> agentIds) {
return engine.timeseries(from, to, bucketCount, routeId, agentIds);
}
} }

View File

@@ -3,6 +3,7 @@ package com.cameleer3.server.core.storage;
import com.cameleer3.common.graph.RouteGraph; import com.cameleer3.common.graph.RouteGraph;
import com.cameleer3.server.core.ingestion.TaggedDiagram; import com.cameleer3.server.core.ingestion.TaggedDiagram;
import java.util.List;
import java.util.Optional; import java.util.Optional;
/** /**
@@ -24,4 +25,11 @@ public interface DiagramRepository {
* Find the content hash for the latest diagram of a given route and agent. * Find the content hash for the latest diagram of a given route and agent.
*/ */
Optional<String> findContentHashForRoute(String routeId, String agentId); Optional<String> findContentHashForRoute(String routeId, String agentId);
/**
* Find the content hash for the latest diagram of a route across any agent in the given list.
* All instances of the same application produce the same route graph, so any agent's
* diagram for the same route will have the same content hash.
*/
Optional<String> findContentHashForRouteByAgents(String routeId, List<String> agentIds);
} }

46
ui/package-lock.json generated
View File

@@ -10,9 +10,11 @@
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.90.21", "@tanstack/react-query": "^5.90.21",
"openapi-fetch": "^0.17.0", "openapi-fetch": "^0.17.0",
"panzoom": "^9.4.3",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-router": "^7.13.1", "react-router": "^7.13.1",
"uplot": "^1.6.32",
"zustand": "^5.0.11" "zustand": "^5.0.11"
}, },
"devDependencies": { "devDependencies": {
@@ -1391,6 +1393,15 @@
"url": "https://github.com/sponsors/epoberezkin" "url": "https://github.com/sponsors/epoberezkin"
} }
}, },
"node_modules/amator": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/amator/-/amator-1.1.0.tgz",
"integrity": "sha512-V5+aH8pe+Z3u/UG3L3pG3BaFQGXAyXHVQDroRwjPHdh08bcUEchAVsU1MCuJSCaU5o60wTK6KaE6te5memzgYw==",
"license": "MIT",
"dependencies": {
"bezier-easing": "^2.0.3"
}
},
"node_modules/ansi-colors": { "node_modules/ansi-colors": {
"version": "4.1.3", "version": "4.1.3",
"resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz",
@@ -1444,6 +1455,12 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/bezier-easing": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/bezier-easing/-/bezier-easing-2.1.0.tgz",
"integrity": "sha512-gbIqZ/eslnUFC1tjEvtz0sgx+xTK20wDnYMIA27VA04R7w6xxXQPZDbibjA9DTWZRA2CXtwHykkVzlCaAJAZig==",
"license": "MIT"
},
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "1.1.12", "version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
@@ -2572,6 +2589,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/ngraph.events": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/ngraph.events/-/ngraph.events-1.4.0.tgz",
"integrity": "sha512-NeDGI4DSyjBNBRtA86222JoYietsmCXbs8CEB0dZ51Xeh4lhVl1y3wpWLumczvnha8sFQIW4E0vvVWwgmX2mGw==",
"license": "BSD-3-Clause"
},
"node_modules/node-releases": { "node_modules/node-releases": {
"version": "2.0.36", "version": "2.0.36",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz",
@@ -2678,6 +2701,17 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/panzoom": {
"version": "9.4.3",
"resolved": "https://registry.npmjs.org/panzoom/-/panzoom-9.4.3.tgz",
"integrity": "sha512-xaxCpElcRbQsUtIdwlrZA90P90+BHip4Vda2BC8MEb4tkI05PmR6cKECdqUCZ85ZvBHjpI9htJrZBxV5Gp/q/w==",
"license": "MIT",
"dependencies": {
"amator": "^1.1.0",
"ngraph.events": "^1.2.2",
"wheel": "^1.0.0"
}
},
"node_modules/parent-module": { "node_modules/parent-module": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -3133,6 +3167,12 @@
"browserslist": ">= 4.21.0" "browserslist": ">= 4.21.0"
} }
}, },
"node_modules/uplot": {
"version": "1.6.32",
"resolved": "https://registry.npmjs.org/uplot/-/uplot-1.6.32.tgz",
"integrity": "sha512-KIMVnG68zvu5XXUbC4LQEPnhwOxBuLyW1AHtpm6IKTXImkbLgkMy+jabjLgSLMasNuGGzQm/ep3tOkyTxpiQIw==",
"license": "MIT"
},
"node_modules/uri-js": { "node_modules/uri-js": {
"version": "4.4.1", "version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
@@ -3229,6 +3269,12 @@
} }
} }
}, },
"node_modules/wheel": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/wheel/-/wheel-1.0.0.tgz",
"integrity": "sha512-XiCMHibOiqalCQ+BaNSwRoZ9FDTAvOsXxGHXChBugewDj7HC8VBIER71dEOiRH1fSdLbRCQzngKTSiZ06ZQzeA==",
"license": "MIT"
},
"node_modules/which": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View File

@@ -14,9 +14,11 @@
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.90.21", "@tanstack/react-query": "^5.90.21",
"openapi-fetch": "^0.17.0", "openapi-fetch": "^0.17.0",
"panzoom": "^9.4.3",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-router": "^7.13.1", "react-router": "^7.13.1",
"uplot": "^1.6.32",
"zustand": "^5.0.11" "zustand": "^5.0.11"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -236,6 +236,14 @@
"type": "string" "type": "string"
} }
}, },
{
"name": "group",
"in": "query",
"required": false,
"schema": {
"type": "string"
}
},
{ {
"name": "offset", "name": "offset",
"in": "query", "in": "query",
@@ -933,6 +941,22 @@
"type": "string", "type": "string",
"format": "date-time" "format": "date-time"
} }
},
{
"name": "routeId",
"in": "query",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "group",
"in": "query",
"required": false,
"schema": {
"type": "string"
}
} }
], ],
"responses": { "responses": {
@@ -984,6 +1008,22 @@
"format": "int32", "format": "int32",
"default": 24 "default": 24
} }
},
{
"name": "routeId",
"in": "query",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "group",
"in": "query",
"required": false,
"schema": {
"type": "string"
}
} }
], ],
"responses": { "responses": {
@@ -1097,6 +1137,49 @@
} }
} }
}, },
"/diagrams": {
"get": {
"tags": [
"Diagrams"
],
"summary": "Find diagram by application group and route ID",
"description": "Resolves group to agent IDs and finds the latest diagram for the route",
"operationId": "findByGroupAndRoute",
"parameters": [
{
"name": "group",
"in": "query",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "routeId",
"in": "query",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Diagram layout returned",
"content": {
"*/*": {
"schema": {
"$ref": "#/components/schemas/DiagramLayout"
}
}
}
},
"404": {
"description": "No diagram found for the given group and route"
}
}
}
},
"/diagrams/{contentHash}/render": { "/diagrams/{contentHash}/render": {
"get": { "get": {
"tags": [ "tags": [
@@ -1191,7 +1274,7 @@
"Agent Management" "Agent Management"
], ],
"summary": "List all agents", "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",
"operationId": "listAgents", "operationId": "listAgents",
"parameters": [ "parameters": [
{ {
@@ -1201,6 +1284,14 @@
"schema": { "schema": {
"type": "string" "type": "string"
} }
},
{
"name": "group",
"in": "query",
"required": false,
"schema": {
"type": "string"
}
} }
], ],
"responses": { "responses": {
@@ -1509,6 +1600,15 @@
"processorType": { "processorType": {
"type": "string" "type": "string"
}, },
"group": {
"type": "string"
},
"agentIds": {
"type": "array",
"items": {
"type": "string"
}
},
"offset": { "offset": {
"type": "integer", "type": "integer",
"format": "int32" "format": "int32"
@@ -2119,6 +2219,12 @@
"height": { "height": {
"type": "number", "type": "number",
"format": "double" "format": "double"
},
"children": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PositionedNode"
}
} }
} }
}, },

View File

@@ -0,0 +1,31 @@
import { useQuery } from '@tanstack/react-query';
import { api } from '../client';
export function useDiagramLayout(contentHash: string | null) {
return useQuery({
queryKey: ['diagrams', 'layout', contentHash],
queryFn: async () => {
const { data, error } = await api.GET('/diagrams/{contentHash}/render', {
params: { path: { contentHash: contentHash! } },
headers: { Accept: 'application/json' },
});
if (error) throw new Error('Failed to load diagram layout');
return data!;
},
enabled: !!contentHash,
});
}
export function useDiagramByRoute(group: string | undefined, routeId: string | undefined) {
return useQuery({
queryKey: ['diagrams', 'byRoute', group, routeId],
queryFn: async () => {
const { data, error } = await api.GET('/diagrams', {
params: { query: { group: group!, routeId: routeId! } },
});
if (error) throw new Error('Failed to load diagram for route');
return data!;
},
enabled: !!group && !!routeId,
});
}

View File

@@ -394,6 +394,26 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/diagrams": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Find diagram by application group and route ID
* @description Resolves group to agent IDs and finds the latest diagram for the route
*/
get: operations["findByGroupAndRoute"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/diagrams/{contentHash}/render": { "/diagrams/{contentHash}/render": {
parameters: { parameters: {
query?: never; query?: never;
@@ -440,7 +460,7 @@ export interface paths {
}; };
/** /**
* List all agents * List all agents
* @description Returns all registered agents, optionally filtered by status * @description Returns all registered agents, optionally filtered by status and/or group
*/ */
get: operations["listAgents"]; get: operations["listAgents"];
put?: never; put?: never;
@@ -558,6 +578,8 @@ export interface components {
routeId?: string; routeId?: string;
agentId?: string; agentId?: string;
processorType?: string; processorType?: string;
group?: string;
agentIds?: string[];
/** Format: int32 */ /** Format: int32 */
offset?: number; offset?: number;
/** Format: int32 */ /** Format: int32 */
@@ -759,6 +781,7 @@ export interface components {
width?: number; width?: number;
/** Format: double */ /** Format: double */
height?: number; height?: number;
children?: components["schemas"]["PositionedNode"][];
}; };
/** @description OIDC configuration for SPA login flow */ /** @description OIDC configuration for SPA login flow */
OidcPublicConfigResponse: { OidcPublicConfigResponse: {
@@ -915,6 +938,7 @@ export interface operations {
routeId?: string; routeId?: string;
agentId?: string; agentId?: string;
processorType?: string; processorType?: string;
group?: string;
offset?: number; offset?: number;
limit?: number; limit?: number;
sortField?: string; sortField?: string;
@@ -1452,6 +1476,8 @@ export interface operations {
query: { query: {
from: string; from: string;
to?: string; to?: string;
routeId?: string;
group?: string;
}; };
header?: never; header?: never;
path?: never; path?: never;
@@ -1476,6 +1502,8 @@ export interface operations {
from: string; from: string;
to?: string; to?: string;
buckets?: number; buckets?: number;
routeId?: string;
group?: string;
}; };
header?: never; header?: never;
path?: never; path?: never;
@@ -1561,6 +1589,36 @@ export interface operations {
}; };
}; };
}; };
findByGroupAndRoute: {
parameters: {
query: {
group: string;
routeId: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Diagram layout returned */
200: {
headers: {
[name: string]: unknown;
};
content: {
"*/*": components["schemas"]["DiagramLayout"];
};
};
/** @description No diagram found for the given group and route */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
renderDiagram: { renderDiagram: {
parameters: { parameters: {
query?: never; query?: never;
@@ -1635,6 +1693,7 @@ export interface operations {
parameters: { parameters: {
query?: { query?: {
status?: string; status?: string;
group?: string;
}; };
header?: never; header?: never;
path?: never; path?: never;

View File

@@ -15,3 +15,6 @@ export type OidcTestResult = components['schemas']['OidcTestResult'];
export type OidcPublicConfigResponse = components['schemas']['OidcPublicConfigResponse']; export type OidcPublicConfigResponse = components['schemas']['OidcPublicConfigResponse'];
export type AuthTokenResponse = components['schemas']['AuthTokenResponse']; export type AuthTokenResponse = components['schemas']['AuthTokenResponse'];
export type ErrorResponse = components['schemas']['ErrorResponse']; export type ErrorResponse = components['schemas']['ErrorResponse'];
export type DiagramLayout = components['schemas']['DiagramLayout'];
export type PositionedNode = components['schemas']['PositionedNode'];
export type PositionedEdge = components['schemas']['PositionedEdge'];

View File

@@ -0,0 +1,104 @@
import { useRef, useEffect, useMemo } from 'react';
import uPlot from 'uplot';
import 'uplot/dist/uPlot.min.css';
import { baseOpts, chartColors } from './theme';
import type { TimeseriesBucket } from '../../api/types';
interface DurationHistogramProps {
buckets: TimeseriesBucket[];
}
export function DurationHistogram({ buckets }: DurationHistogramProps) {
const containerRef = useRef<HTMLDivElement>(null);
const chartRef = useRef<uPlot | null>(null);
// Build histogram bins from avg durations
const histData = useMemo(() => {
const durations = buckets.map((b) => b.avgDurationMs ?? 0).filter((d) => d > 0);
if (durations.length < 2) return null;
const min = Math.min(...durations);
const max = Math.max(...durations);
const range = max - min || 1;
const binCount = Math.min(20, durations.length);
const binSize = range / binCount;
const bins = new Array(binCount).fill(0);
const labels = new Array(binCount).fill(0);
for (let i = 0; i < binCount; i++) {
labels[i] = Math.round(min + binSize * i + binSize / 2);
}
for (const d of durations) {
const idx = Math.min(Math.floor((d - min) / binSize), binCount - 1);
bins[idx]++;
}
return { xs: labels, counts: bins };
}, [buckets]);
useEffect(() => {
if (!containerRef.current || !histData) return;
const el = containerRef.current;
const w = el.clientWidth || 600;
const opts: uPlot.Options = {
...baseOpts(w, 220),
width: w,
height: 220,
scales: {
x: { time: false },
},
axes: [
{
stroke: chartColors.axis,
grid: { stroke: chartColors.grid, width: 1 },
font: '11px JetBrains Mono, monospace',
values: (_, ticks) => ticks.map((v) => `${Math.round(v)}ms`),
},
{
stroke: chartColors.axis,
grid: { stroke: chartColors.grid, width: 1 },
font: '11px JetBrains Mono, monospace',
size: 40,
},
],
series: [
{ label: 'Duration (ms)' },
{
label: 'Count',
stroke: chartColors.cyan,
fill: `${chartColors.cyan}30`,
width: 2,
paths: (u, seriesIdx, idx0, idx1) => {
const path = new Path2D();
const fillPath = new Path2D();
const barWidth = Math.max(2, (u.bbox.width / (idx1 - idx0 + 1)) * 0.7);
const yBase = u.valToPos(0, 'y', true);
fillPath.moveTo(u.valToPos(0, 'x', true), yBase);
for (let i = idx0; i <= idx1; i++) {
const x = u.valToPos(u.data[0][i], 'x', true) - barWidth / 2;
const y = u.valToPos(u.data[seriesIdx][i] ?? 0, 'y', true);
path.rect(x, y, barWidth, yBase - y);
fillPath.rect(x, y, barWidth, yBase - y);
}
return { stroke: path, fill: fillPath };
},
},
],
};
chartRef.current?.destroy();
chartRef.current = new uPlot(opts, [histData.xs, histData.counts], el);
return () => {
chartRef.current?.destroy();
chartRef.current = null;
};
}, [histData]);
if (!histData) return <div style={{ color: 'var(--text-muted)', padding: 20 }}>Not enough data for histogram</div>;
return <div ref={containerRef} />;
}

View File

@@ -0,0 +1,71 @@
import { useRef, useEffect } from 'react';
import uPlot from 'uplot';
import 'uplot/dist/uPlot.min.css';
import { baseOpts, chartColors } from './theme';
import type { TimeseriesBucket } from '../../api/types';
interface LatencyHeatmapProps {
buckets: TimeseriesBucket[];
}
export function LatencyHeatmap({ buckets }: LatencyHeatmapProps) {
const containerRef = useRef<HTMLDivElement>(null);
const chartRef = useRef<uPlot | null>(null);
useEffect(() => {
if (!containerRef.current || buckets.length < 2) return;
const el = containerRef.current;
const w = el.clientWidth || 600;
const xs = buckets.map((b) => new Date(b.time!).getTime() / 1000);
const avgDurations = buckets.map((b) => b.avgDurationMs ?? 0);
const p99Durations = buckets.map((b) => b.p99DurationMs ?? 0);
const opts: uPlot.Options = {
...baseOpts(w, 220),
width: w,
height: 220,
series: [
{ label: 'Time' },
{
label: 'Avg Duration',
stroke: chartColors.cyan,
width: 2,
dash: [4, 2],
},
{
label: 'P99 Duration',
stroke: chartColors.amber,
fill: `${chartColors.amber}15`,
width: 2,
},
],
axes: [
{
stroke: chartColors.axis,
grid: { stroke: chartColors.grid, width: 1 },
font: '11px JetBrains Mono, monospace',
},
{
stroke: chartColors.axis,
grid: { stroke: chartColors.grid, width: 1 },
font: '11px JetBrains Mono, monospace',
size: 50,
values: (_, ticks) => ticks.map((v) => `${Math.round(v)}ms`),
},
],
};
chartRef.current?.destroy();
chartRef.current = new uPlot(opts, [xs, avgDurations, p99Durations], el);
return () => {
chartRef.current?.destroy();
chartRef.current = null;
};
}, [buckets]);
if (buckets.length < 2) return null;
return <div ref={containerRef} />;
}

View File

@@ -0,0 +1,62 @@
import { useRef, useEffect, useMemo } from 'react';
import uPlot from 'uplot';
import 'uplot/dist/uPlot.min.css';
import { sparkOpts, accentHex } from './theme';
interface MiniChartProps {
data: number[];
color: string;
}
export function MiniChart({ data, color }: MiniChartProps) {
const containerRef = useRef<HTMLDivElement>(null);
const chartRef = useRef<uPlot | null>(null);
// Trim first/last buckets (partial time windows) like the old Sparkline
const trimmed = useMemo(() => (data.length > 4 ? data.slice(1, -1) : data), [data]);
const resolvedColor = color.startsWith('#') || color.startsWith('rgb')
? color
: accentHex(color);
useEffect(() => {
if (!containerRef.current || trimmed.length < 2) return;
const el = containerRef.current;
const w = el.clientWidth || 200;
const h = 24;
// x-axis: simple index values
const xs = Float64Array.from(trimmed, (_, i) => i);
const ys = Float64Array.from(trimmed);
const opts: uPlot.Options = {
...sparkOpts(w, h),
width: w,
height: h,
series: [
{},
{
stroke: resolvedColor,
width: 1.5,
fill: `${resolvedColor}30`,
},
],
};
if (chartRef.current) {
chartRef.current.destroy();
}
chartRef.current = new uPlot(opts, [xs as unknown as number[], ys as unknown as number[]], el);
return () => {
chartRef.current?.destroy();
chartRef.current = null;
};
}, [trimmed, resolvedColor]);
if (trimmed.length < 2) return null;
return <div ref={containerRef} style={{ marginTop: 10, height: 24, width: '100%' }} />;
}

View File

@@ -0,0 +1,57 @@
import { useRef, useEffect } from 'react';
import uPlot from 'uplot';
import 'uplot/dist/uPlot.min.css';
import { baseOpts, chartColors } from './theme';
import type { TimeseriesBucket } from '../../api/types';
interface ThroughputChartProps {
buckets: TimeseriesBucket[];
}
export function ThroughputChart({ buckets }: ThroughputChartProps) {
const containerRef = useRef<HTMLDivElement>(null);
const chartRef = useRef<uPlot | null>(null);
useEffect(() => {
if (!containerRef.current || buckets.length < 2) return;
const el = containerRef.current;
const w = el.clientWidth || 600;
const xs = buckets.map((b) => new Date(b.time!).getTime() / 1000);
const totals = buckets.map((b) => b.totalCount ?? 0);
const failed = buckets.map((b) => b.failedCount ?? 0);
const opts: uPlot.Options = {
...baseOpts(w, 220),
width: w,
height: 220,
series: [
{ label: 'Time' },
{
label: 'Total',
stroke: chartColors.amber,
fill: `${chartColors.amber}20`,
width: 2,
},
{
label: 'Failed',
stroke: chartColors.rose,
fill: `${chartColors.rose}20`,
width: 2,
},
],
};
chartRef.current?.destroy();
chartRef.current = new uPlot(opts, [xs, totals, failed], el);
return () => {
chartRef.current?.destroy();
chartRef.current = null;
};
}, [buckets]);
if (buckets.length < 2) return null;
return <div ref={containerRef} />;
}

View File

@@ -0,0 +1,69 @@
import type uPlot from 'uplot';
/** Shared uPlot color tokens matching Cameleer3 design system */
export const chartColors = {
amber: '#f0b429',
cyan: '#22d3ee',
rose: '#f43f5e',
green: '#10b981',
blue: '#3b82f6',
purple: '#a855f7',
grid: 'rgba(30, 45, 61, 0.5)',
axis: '#4a5e7a',
text: '#8b9cb6',
bg: '#111827',
cursor: 'rgba(240, 180, 41, 0.15)',
} as const;
export type AccentColor = keyof typeof chartColors;
/** Resolve an accent name to a CSS hex color */
export function accentHex(accent: string): string {
return (chartColors as Record<string, string>)[accent] ?? chartColors.amber;
}
/** Base uPlot options shared across all Cameleer3 charts */
export function baseOpts(width: number, height: number): Partial<uPlot.Options> {
return {
width,
height,
cursor: {
show: true,
x: true,
y: false,
},
legend: { show: false },
axes: [
{
stroke: chartColors.axis,
grid: { stroke: chartColors.grid, width: 1 },
ticks: { stroke: chartColors.grid, width: 1 },
font: '11px JetBrains Mono, monospace',
},
{
stroke: chartColors.axis,
grid: { stroke: chartColors.grid, width: 1 },
ticks: { stroke: chartColors.grid, width: 1 },
font: '11px JetBrains Mono, monospace',
size: 50,
},
],
};
}
/** Mini sparkline chart options (no axes, no cursor) */
export function sparkOpts(width: number, height: number): Partial<uPlot.Options> {
return {
width,
height,
cursor: { show: false },
legend: { show: false },
axes: [
{ show: false },
{ show: false },
],
scales: {
x: { time: false },
},
};
}

View File

@@ -1,5 +1,5 @@
import styles from './shared.module.css'; import styles from './shared.module.css';
import { Sparkline } from './Sparkline'; import { MiniChart } from '../charts/MiniChart';
const ACCENT_COLORS: Record<string, string> = { const ACCENT_COLORS: Record<string, string> = {
amber: 'var(--amber)', amber: 'var(--amber)',
@@ -27,7 +27,7 @@ export function StatCard({ label, value, accent, change, changeDirection = 'neut
<div className={`${styles.statChange} ${styles[changeDirection]}`}>{change}</div> <div className={`${styles.statChange} ${styles[changeDirection]}`}>{change}</div>
)} )}
{sparkData && sparkData.length >= 2 && ( {sparkData && sparkData.length >= 2 && (
<Sparkline data={sparkData} color={ACCENT_COLORS[accent] ?? ACCENT_COLORS.amber} /> <MiniChart data={sparkData} color={ACCENT_COLORS[accent] ?? ACCENT_COLORS.amber} />
)} )}
</div> </div>
); );

View File

@@ -0,0 +1,130 @@
import { useState, useMemo, useCallback, useEffect } from 'react';
import type { ExecutionDetail, ProcessorNode } from '../api/types';
export interface IterationData {
count: number;
current: number;
}
export interface OverlayState {
isActive: boolean;
toggle: () => void;
executedNodes: Set<string>;
executedEdges: Set<string>;
durations: Map<string, number>;
sequences: Map<string, number>;
iterationData: Map<string, IterationData>;
selectedNodeId: string | null;
selectNode: (nodeId: string | null) => void;
setIteration: (nodeId: string, iteration: number) => void;
}
/** Walk the processor tree and collect execution data keyed by diagramNodeId */
function collectProcessorData(
processors: ProcessorNode[],
executedNodes: Set<string>,
durations: Map<string, number>,
sequences: Map<string, number>,
counter: { seq: number },
) {
for (const proc of processors) {
const nodeId = proc.diagramNodeId;
if (nodeId) {
executedNodes.add(nodeId);
durations.set(nodeId, proc.durationMs ?? 0);
sequences.set(nodeId, ++counter.seq);
}
if (proc.children && proc.children.length > 0) {
collectProcessorData(proc.children, executedNodes, durations, sequences, counter);
}
}
}
/** Determine which edges are executed (both source and target are executed) */
function computeExecutedEdges(
executedNodes: Set<string>,
edges: Array<{ sourceId?: string; targetId?: string }>,
): Set<string> {
const result = new Set<string>();
for (const edge of edges) {
if (edge.sourceId && edge.targetId
&& executedNodes.has(edge.sourceId) && executedNodes.has(edge.targetId)) {
result.add(`${edge.sourceId}->${edge.targetId}`);
}
}
return result;
}
export function useExecutionOverlay(
execution: ExecutionDetail | null | undefined,
edges: Array<{ sourceId?: string; targetId?: string }> = [],
): OverlayState {
const [isActive, setIsActive] = useState(!!execution);
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const [iterations, setIterations] = useState<Map<string, number>>(new Map());
// Activate overlay when an execution is loaded
useEffect(() => {
if (execution) setIsActive(true);
}, [execution]);
const { executedNodes, durations, sequences, iterationData } = useMemo(() => {
const en = new Set<string>();
const dur = new Map<string, number>();
const seq = new Map<string, number>();
const iter = new Map<string, IterationData>();
if (!execution?.processors) {
return { executedNodes: en, durations: dur, sequences: seq, iterationData: iter };
}
collectProcessorData(execution.processors, en, dur, seq, { seq: 0 });
return { executedNodes: en, durations: dur, sequences: seq, iterationData: iter };
}, [execution]);
const executedEdges = useMemo(
() => computeExecutedEdges(executedNodes, edges),
[executedNodes, edges],
);
const toggle = useCallback(() => setIsActive((v) => !v), []);
const selectNode = useCallback((nodeId: string | null) => setSelectedNodeId(nodeId), []);
const setIteration = useCallback((nodeId: string, iteration: number) => {
setIterations((prev) => {
const next = new Map(prev);
next.set(nodeId, iteration);
return next;
});
}, []);
// Keyboard shortcut: E to toggle overlay
useEffect(() => {
function handleKey(e: KeyboardEvent) {
if (e.key === 'e' || e.key === 'E') {
const target = e.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT') return;
e.preventDefault();
setIsActive((v) => !v);
}
}
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
}, []);
return {
isActive,
toggle,
executedNodes,
executedEdges,
durations,
sequences,
iterationData: new Map([...iterationData].map(([k, v]) => {
const current = iterations.get(k) ?? v.current;
return [k, { ...v, current }];
})),
selectedNodeId,
selectNode,
setIteration,
};
}

View File

@@ -1,5 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { useNavigate } from 'react-router';
import type { ExecutionSummary } from '../../api/types'; import type { ExecutionSummary } from '../../api/types';
import { useAgents } from '../../api/queries/agents';
import { StatusPill } from '../../components/shared/StatusPill'; import { StatusPill } from '../../components/shared/StatusPill';
import { DurationBar } from '../../components/shared/DurationBar'; import { DurationBar } from '../../components/shared/DurationBar';
import { AppBadge } from '../../components/shared/AppBadge'; import { AppBadge } from '../../components/shared/AppBadge';
@@ -55,11 +57,25 @@ export function ResultsTable({ results, loading }: ResultsTableProps) {
const sortColumn = useExecutionSearch((s) => s.sortField); const sortColumn = useExecutionSearch((s) => s.sortField);
const sortDir = useExecutionSearch((s) => s.sortDir); const sortDir = useExecutionSearch((s) => s.sortDir);
const setSort = useExecutionSearch((s) => s.setSort); const setSort = useExecutionSearch((s) => s.setSort);
const navigate = useNavigate();
const { data: agents } = useAgents();
function handleSort(col: SortColumn) { function handleSort(col: SortColumn) {
setSort(col); setSort(col);
} }
/** Navigate to route diagram page with execution overlay */
function handleDiagramNav(exec: ExecutionSummary, e: React.MouseEvent) {
// Only navigate on double-click or if holding Ctrl/Cmd
if (!e.ctrlKey && !e.metaKey) return;
// Resolve agentId → group from agent registry
const agent = agents?.find((a) => a.id === exec.agentId);
const group = agent?.group ?? 'default';
navigate(`/apps/${encodeURIComponent(group)}/routes/${encodeURIComponent(exec.routeId)}?exec=${encodeURIComponent(exec.executionId)}`);
}
if (loading && results.length === 0) { if (loading && results.length === 0) {
return ( return (
<div className={styles.tableWrap}> <div className={styles.tableWrap}>
@@ -99,6 +115,7 @@ export function ResultsTable({ results, loading }: ResultsTableProps) {
exec={exec} exec={exec}
isExpanded={isExpanded} isExpanded={isExpanded}
onToggle={() => setExpandedId(isExpanded ? null : exec.executionId)} onToggle={() => setExpandedId(isExpanded ? null : exec.executionId)}
onDiagramNav={(e) => handleDiagramNav(exec, e)}
/> />
); );
})} })}
@@ -112,16 +129,25 @@ function ResultRow({
exec, exec,
isExpanded, isExpanded,
onToggle, onToggle,
onDiagramNav,
}: { }: {
exec: ExecutionSummary; exec: ExecutionSummary;
isExpanded: boolean; isExpanded: boolean;
onToggle: () => void; onToggle: () => void;
onDiagramNav: (e: React.MouseEvent) => void;
}) { }) {
return ( return (
<> <>
<tr <tr
className={`${styles.row} ${isExpanded ? styles.expanded : ''}`} className={`${styles.row} ${isExpanded ? styles.expanded : ''}`}
onClick={onToggle} onClick={(e) => {
if (e.ctrlKey || e.metaKey) {
onDiagramNav(e);
} else {
onToggle();
}
}}
title="Click to expand, Ctrl+Click to open diagram"
> >
<td className={`${styles.td} ${styles.tdExpand}`}>&rsaquo;</td> <td className={`${styles.td} ${styles.tdExpand}`}>&rsaquo;</td>
<td className={`${styles.td} mono`}>{formatTime(exec.startTime)}</td> <td className={`${styles.td} mono`}>{formatTime(exec.startTime)}</td>

View File

@@ -0,0 +1,27 @@
import type { DiagramLayout, ExecutionDetail } from '../../api/types';
import type { OverlayState } from '../../hooks/useExecutionOverlay';
import { DiagramCanvas } from './diagram/DiagramCanvas';
import { ProcessorDetailPanel } from './diagram/ProcessorDetailPanel';
import styles from './diagram/diagram.module.css';
interface DiagramTabProps {
layout: DiagramLayout;
overlay: OverlayState;
execution: ExecutionDetail | null | undefined;
}
export function DiagramTab({ layout, overlay, execution }: DiagramTabProps) {
return (
<div className={styles.splitLayout}>
<div className={styles.diagramSide}>
<DiagramCanvas layout={layout} overlay={overlay} />
</div>
{overlay.isActive && execution && (
<ProcessorDetailPanel
execution={execution}
selectedNodeId={overlay.selectedNodeId}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,95 @@
import { useExecutionStats, useStatsTimeseries } from '../../api/queries/executions';
import { StatCard } from '../../components/shared/StatCard';
import { ThroughputChart } from '../../components/charts/ThroughputChart';
import { DurationHistogram } from '../../components/charts/DurationHistogram';
import { LatencyHeatmap } from '../../components/charts/LatencyHeatmap';
import styles from './RoutePage.module.css';
interface PerformanceTabProps {
group: string;
routeId: string;
}
function pctChange(current: number, previous: number): { text: string; direction: 'up' | 'down' | 'neutral' } {
if (previous === 0) return { text: 'no prior data', direction: 'neutral' };
const pct = ((current - previous) / previous) * 100;
if (Math.abs(pct) < 0.5) return { text: '~0% vs yesterday', direction: 'neutral' };
const arrow = pct > 0 ? '\u2191' : '\u2193';
return { text: `${arrow} ${Math.abs(pct).toFixed(1)}% vs yesterday`, direction: pct > 0 ? 'up' : 'down' };
}
export function PerformanceTab({ group, routeId }: PerformanceTabProps) {
const timeFrom = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
const timeTo = new Date().toISOString();
// Use scoped stats/timeseries via group+routeId query params
const { data: stats } = useExecutionStats(timeFrom, timeTo);
const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo);
const buckets = timeseries?.buckets ?? [];
const sparkTotal = buckets.map((b) => b.totalCount ?? 0);
const sparkP99 = buckets.map((b) => b.p99DurationMs ?? 0);
const sparkFailed = buckets.map((b) => b.failedCount ?? 0);
const sparkAvg = buckets.map((b) => b.avgDurationMs ?? 0);
const failureRate = stats && stats.totalCount > 0
? (stats.failedCount / stats.totalCount) * 100 : 0;
const prevFailureRate = stats && stats.prevTotalCount > 0
? (stats.prevFailedCount / stats.prevTotalCount) * 100 : 0;
const p99Change = stats ? pctChange(stats.p99LatencyMs, stats.prevP99LatencyMs) : null;
const failChange = stats ? pctChange(failureRate, prevFailureRate) : null;
return (
<div className={styles.performanceTab}>
{/* Stats cards row */}
<div className={styles.perfStatsRow}>
<StatCard
label="Executions Today"
value={stats ? stats.totalToday.toLocaleString() : '--'}
accent="amber"
change={`for ${group}/${routeId}`}
sparkData={sparkTotal}
/>
<StatCard
label="Avg Duration"
value={stats ? `${stats.avgDurationMs}ms` : '--'}
accent="cyan"
sparkData={sparkAvg}
/>
<StatCard
label="P99 Latency"
value={stats ? `${stats.p99LatencyMs}ms` : '--'}
accent="green"
change={p99Change?.text}
changeDirection={p99Change?.direction}
sparkData={sparkP99}
/>
<StatCard
label="Failure Rate"
value={stats ? `${failureRate.toFixed(1)}%` : '--'}
accent="rose"
change={failChange?.text}
changeDirection={failChange?.direction}
sparkData={sparkFailed}
/>
</div>
{/* Charts */}
<div className={styles.chartGrid}>
<div className={styles.chartCard}>
<h4 className={styles.chartTitle}>Throughput</h4>
<ThroughputChart buckets={buckets} />
</div>
<div className={styles.chartCard}>
<h4 className={styles.chartTitle}>Duration Distribution</h4>
<DurationHistogram buckets={buckets} />
</div>
<div className={`${styles.chartCard} ${styles.chartFull}`}>
<h4 className={styles.chartTitle}>Latency Over Time</h4>
<LatencyHeatmap buckets={buckets} />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,29 @@
import type { DiagramLayout } from '../../api/types';
import styles from './RoutePage.module.css';
interface RouteHeaderProps {
group: string;
routeId: string;
layout: DiagramLayout | undefined;
}
export function RouteHeader({ group, routeId, layout }: RouteHeaderProps) {
const nodeCount = layout?.nodes?.length ?? 0;
return (
<div className={styles.routeHeader}>
<div className={styles.routeTitle}>
<span className={styles.routeId}>{routeId}</span>
<div className={styles.routeMeta}>
<span className={styles.routeMetaItem}>
<span className={styles.routeMetaDot} />
{group}
</span>
{nodeCount > 0 && (
<span className={styles.routeMetaItem}>{nodeCount} nodes</span>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,262 @@
/* ─── Breadcrumb ─── */
.breadcrumb {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 16px;
font-size: 12px;
}
.breadcrumbLink {
color: var(--text-muted);
text-decoration: none;
transition: color 0.15s;
}
.breadcrumbLink:hover {
color: var(--amber);
}
.breadcrumbSep {
color: var(--text-muted);
opacity: 0.5;
}
.breadcrumbText {
color: var(--text-secondary);
}
.breadcrumbCurrent {
color: var(--text-primary);
font-family: var(--font-mono);
font-weight: 500;
}
/* ─── Route Header ─── */
.routeHeader {
position: relative;
margin-bottom: 20px;
padding: 20px 24px;
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
overflow: hidden;
}
.routeHeader::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg, var(--amber), var(--cyan));
}
.routeTitle {
display: flex;
align-items: baseline;
gap: 16px;
flex-wrap: wrap;
}
.routeId {
font-family: var(--font-mono);
font-size: 20px;
font-weight: 600;
color: var(--text-primary);
letter-spacing: -0.5px;
}
.routeMeta {
display: flex;
align-items: center;
gap: 16px;
font-size: 13px;
color: var(--text-muted);
}
.routeMetaItem {
display: inline-flex;
align-items: center;
gap: 6px;
}
.routeMetaDot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--green);
}
/* ─── Toolbar & Tabs ─── */
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
gap: 12px;
flex-wrap: wrap;
}
.tabBar {
display: flex;
gap: 0;
border-bottom: 2px solid var(--border-subtle);
}
.tab {
padding: 8px 20px;
border: none;
background: none;
color: var(--text-muted);
font-size: 13px;
font-weight: 500;
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
transition: all 0.15s;
}
.tab:hover {
color: var(--text-secondary);
}
.tabActive {
color: var(--amber);
border-bottom-color: var(--amber);
}
.toolbarRight {
display: flex;
align-items: center;
gap: 10px;
}
.overlayToggle {
padding: 6px 14px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--bg-raised);
color: var(--text-secondary);
font-size: 12px;
font-family: var(--font-mono);
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.overlayToggle:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.overlayOn {
background: var(--green-glow);
border-color: rgba(16, 185, 129, 0.3);
color: var(--green);
}
.execBadge {
padding: 4px 10px;
border-radius: 99px;
font-size: 11px;
font-family: var(--font-mono);
font-weight: 600;
letter-spacing: 0.3px;
}
.execBadgeOk {
background: var(--green-glow);
color: var(--green);
}
.execBadgeFailed {
background: var(--rose-glow);
color: var(--rose);
}
/* ─── States ─── */
.loading {
color: var(--text-muted);
text-align: center;
padding: 60px 20px;
font-size: 14px;
}
.emptyState {
color: var(--text-muted);
text-align: center;
padding: 60px 20px;
font-size: 14px;
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
}
.error {
color: var(--rose);
text-align: center;
padding: 60px 20px;
}
/* ─── Performance Tab ─── */
.performanceTab {
display: flex;
flex-direction: column;
gap: 20px;
}
.perfStatsRow {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
}
.chartGrid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.chartCard {
background: var(--bg-surface);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
padding: 16px;
}
.chartFull {
grid-column: 1 / -1;
}
.chartTitle {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.8px;
color: var(--text-muted);
margin-bottom: 12px;
}
/* ─── Responsive ─── */
@media (max-width: 1200px) {
.perfStatsRow {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.perfStatsRow {
grid-template-columns: 1fr;
}
.chartGrid {
grid-template-columns: 1fr;
}
.toolbar {
flex-direction: column;
align-items: flex-start;
}
}

View File

@@ -0,0 +1,113 @@
import { useState } from 'react';
import { useParams, useSearchParams, NavLink } from 'react-router';
import { useDiagramByRoute } from '../../api/queries/diagrams';
import { useExecutionDetail } from '../../api/queries/executions';
import { useExecutionOverlay } from '../../hooks/useExecutionOverlay';
import { RouteHeader } from './RouteHeader';
import { DiagramTab } from './DiagramTab';
import { PerformanceTab } from './PerformanceTab';
import { ProcessorTree } from '../executions/ProcessorTree';
import styles from './RoutePage.module.css';
type Tab = 'diagram' | 'performance' | 'processors';
export function RoutePage() {
const { group, routeId } = useParams<{ group: string; routeId: string }>();
const [searchParams] = useSearchParams();
const execId = searchParams.get('exec');
const [activeTab, setActiveTab] = useState<Tab>('diagram');
const { data: layout, isLoading: layoutLoading } = useDiagramByRoute(group, routeId);
const { data: execution } = useExecutionDetail(execId);
const overlay = useExecutionOverlay(
execution ?? null,
layout?.edges ?? [],
);
if (!group || !routeId) {
return <div className={styles.error}>Missing group or routeId parameters</div>;
}
return (
<>
{/* Breadcrumb */}
<nav className={styles.breadcrumb}>
<NavLink to="/executions" className={styles.breadcrumbLink}>Transactions</NavLink>
<span className={styles.breadcrumbSep}>/</span>
<span className={styles.breadcrumbText}>{group}</span>
<span className={styles.breadcrumbSep}>/</span>
<span className={styles.breadcrumbCurrent}>{routeId}</span>
</nav>
{/* Route Header */}
<RouteHeader group={group} routeId={routeId} layout={layout} />
{/* Toolbar */}
<div className={styles.toolbar}>
<div className={styles.tabBar}>
<button
className={`${styles.tab} ${activeTab === 'diagram' ? styles.tabActive : ''}`}
onClick={() => setActiveTab('diagram')}
>
Diagram
</button>
<button
className={`${styles.tab} ${activeTab === 'performance' ? styles.tabActive : ''}`}
onClick={() => setActiveTab('performance')}
>
Performance
</button>
<button
className={`${styles.tab} ${activeTab === 'processors' ? styles.tabActive : ''}`}
onClick={() => setActiveTab('processors')}
>
Processor Tree
</button>
</div>
{activeTab === 'diagram' && (
<div className={styles.toolbarRight}>
<button
className={`${styles.overlayToggle} ${overlay.isActive ? styles.overlayOn : ''}`}
onClick={overlay.toggle}
title="Toggle execution overlay (E)"
>
{overlay.isActive ? 'Hide' : 'Show'} Execution
</button>
{execution && (
<span className={`${styles.execBadge} ${execution.status === 'FAILED' ? styles.execBadgeFailed : styles.execBadgeOk}`}>
{execution.status} &middot; {execution.durationMs}ms
</span>
)}
</div>
)}
</div>
{/* Tab Content */}
{activeTab === 'diagram' && (
layoutLoading ? (
<div className={styles.loading}>Loading diagram...</div>
) : layout ? (
<DiagramTab layout={layout} overlay={overlay} execution={execution} />
) : (
<div className={styles.emptyState}>No diagram available for this route</div>
)
)}
{activeTab === 'performance' && (
<PerformanceTab group={group} routeId={routeId} />
)}
{activeTab === 'processors' && execId && (
<ProcessorTree executionId={execId} />
)}
{activeTab === 'processors' && !execId && (
<div className={styles.emptyState}>
Select an execution to view the processor tree
</div>
)}
</>
);
}

View File

@@ -0,0 +1,110 @@
import { useRef, useEffect, useState, useCallback } from 'react';
import panzoom, { type PanZoom } from 'panzoom';
import type { DiagramLayout } from '../../../api/types';
import type { OverlayState } from '../../../hooks/useExecutionOverlay';
import { RouteDiagramSvg } from './RouteDiagramSvg';
import { DiagramMinimap } from './DiagramMinimap';
import styles from './diagram.module.css';
interface DiagramCanvasProps {
layout: DiagramLayout;
overlay: OverlayState;
}
export function DiagramCanvas({ layout, overlay }: DiagramCanvasProps) {
const containerRef = useRef<HTMLDivElement>(null);
const svgWrapRef = useRef<HTMLDivElement>(null);
const panzoomRef = useRef<PanZoom | null>(null);
const [viewBox, setViewBox] = useState({ x: 0, y: 0, w: 800, h: 600 });
useEffect(() => {
if (!svgWrapRef.current) return;
const instance = panzoom(svgWrapRef.current, {
smoothScroll: false,
zoomDoubleClickSpeed: 1,
minZoom: 0.1,
maxZoom: 5,
bounds: true,
boundsPadding: 0.2,
});
panzoomRef.current = instance;
const updateViewBox = () => {
if (!containerRef.current) return;
const transform = instance.getTransform();
const rect = containerRef.current.getBoundingClientRect();
setViewBox({
x: -transform.x / transform.scale,
y: -transform.y / transform.scale,
w: rect.width / transform.scale,
h: rect.height / transform.scale,
});
};
instance.on('transform', updateViewBox);
updateViewBox();
return () => {
instance.dispose();
panzoomRef.current = null;
};
}, [layout]);
const handleFit = useCallback(() => {
if (!panzoomRef.current || !containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const padding = 80;
const w = (layout.width ?? 600) + padding;
const h = (layout.height ?? 400) + padding;
const scale = Math.min(rect.width / w, rect.height / h, 1);
const cx = (rect.width - w * scale) / 2;
const cy = (rect.height - h * scale) / 2;
panzoomRef.current.moveTo(cx, cy);
panzoomRef.current.zoomAbs(0, 0, scale);
}, [layout]);
const handleZoomIn = useCallback(() => {
if (!panzoomRef.current || !containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
panzoomRef.current.smoothZoom(rect.width / 2, rect.height / 2, 1.3);
}, []);
const handleZoomOut = useCallback(() => {
if (!panzoomRef.current || !containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
panzoomRef.current.smoothZoom(rect.width / 2, rect.height / 2, 0.7);
}, []);
// Fit on initial load
useEffect(() => {
const t = setTimeout(handleFit, 100);
return () => clearTimeout(t);
}, [handleFit]);
return (
<div className={styles.canvasContainer}>
{/* Zoom controls */}
<div className={styles.zoomControls}>
<button className={styles.zoomBtn} onClick={handleFit} title="Fit to view">Fit</button>
<button className={styles.zoomBtn} onClick={handleZoomIn} title="Zoom in">+</button>
<button className={styles.zoomBtn} onClick={handleZoomOut} title="Zoom out">&minus;</button>
</div>
<div ref={containerRef} className={styles.canvas}>
<div ref={svgWrapRef}>
<RouteDiagramSvg layout={layout} overlay={overlay} />
</div>
</div>
<DiagramMinimap
nodes={layout.nodes ?? []}
edges={layout.edges ?? []}
diagramWidth={layout.width ?? 600}
diagramHeight={layout.height ?? 400}
viewBox={viewBox}
/>
</div>
);
}

View File

@@ -0,0 +1,71 @@
import { useMemo } from 'react';
import type { PositionedNode, PositionedEdge } from '../../../api/types';
import { getNodeStyle } from './nodeStyles';
import styles from './diagram.module.css';
interface DiagramMinimapProps {
nodes: PositionedNode[];
edges: PositionedEdge[];
diagramWidth: number;
diagramHeight: number;
viewBox: { x: number; y: number; w: number; h: number };
}
const MINIMAP_W = 160;
const MINIMAP_H = 100;
export function DiagramMinimap({ nodes, edges, diagramWidth, diagramHeight, viewBox }: DiagramMinimapProps) {
const scale = useMemo(() => {
if (diagramWidth === 0 || diagramHeight === 0) return 1;
return Math.min(MINIMAP_W / diagramWidth, MINIMAP_H / diagramHeight);
}, [diagramWidth, diagramHeight]);
const vpRect = useMemo(() => ({
x: viewBox.x * scale,
y: viewBox.y * scale,
w: viewBox.w * scale,
h: viewBox.h * scale,
}), [viewBox, scale]);
return (
<div className={styles.minimap}>
<svg width={MINIMAP_W} height={MINIMAP_H} viewBox={`0 0 ${MINIMAP_W} ${MINIMAP_H}`}>
<rect width={MINIMAP_W} height={MINIMAP_H} fill="#0d1117" rx={4} />
{/* Edges */}
{edges.map((e) => {
const pts = e.points;
if (!pts || pts.length < 2) return null;
const d = pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p[0] * scale},${p[1] * scale}`).join(' ');
return <path key={`${e.sourceId}-${e.targetId}`} d={d} fill="none" stroke="#30363d" strokeWidth={0.5} />;
})}
{/* Nodes */}
{nodes.map((n) => {
const ns = getNodeStyle(n.type ?? '');
return (
<rect
key={n.id}
x={(n.x ?? 0) * scale}
y={(n.y ?? 0) * scale}
width={Math.max((n.width ?? 0) * scale, 2)}
height={Math.max((n.height ?? 0) * scale, 2)}
fill={ns.border}
opacity={0.6}
rx={1}
/>
);
})}
{/* Viewport rect */}
<rect
x={vpRect.x}
y={vpRect.y}
width={vpRect.w}
height={vpRect.h}
fill="rgba(240, 180, 41, 0.1)"
stroke="#f0b429"
strokeWidth={1}
rx={1}
/>
</svg>
</div>
);
}

View File

@@ -0,0 +1,160 @@
import type { PositionedNode } from '../../../api/types';
import { getNodeStyle, isCompoundType } from './nodeStyles';
import styles from './diagram.module.css';
interface DiagramNodeProps {
node: PositionedNode;
isExecuted: boolean;
isError: boolean;
isOverlayActive: boolean;
duration?: number;
sequence?: number;
isSelected: boolean;
onClick: (nodeId: string) => void;
}
export function DiagramNode({
node,
isExecuted,
isError,
isOverlayActive,
duration,
sequence,
isSelected,
onClick,
}: DiagramNodeProps) {
const style = getNodeStyle(node.type ?? 'PROCESSOR');
const isCompound = isCompoundType(node.type ?? '');
const dimmed = isOverlayActive && !isExecuted;
const glowFilter = isOverlayActive && isExecuted
? (isError ? 'url(#glow-red)' : 'url(#glow-green)')
: undefined;
const borderColor = isOverlayActive && isExecuted
? (isError ? '#f85149' : '#3fb950')
: style.border;
if (isCompound) {
return (
<g
className={`${styles.nodeGroup} ${dimmed ? styles.dimmed : ''}`}
opacity={dimmed ? 0.15 : 1}
role="img"
aria-label={`${node.type} container: ${node.label}`}
>
<rect
x={node.x}
y={node.y}
width={node.width}
height={node.height}
rx={8}
fill={`${style.bg}80`}
stroke={borderColor}
strokeWidth={1}
strokeDasharray={style.category === 'crossRoute' ? '5,3' : undefined}
filter={glowFilter}
/>
<text
x={node.x! + 8}
y={node.y! + 14}
fill={style.border}
fontSize={10}
fontFamily="JetBrains Mono, monospace"
fontWeight={500}
opacity={0.7}
>
{node.label}
</text>
{/* Children rendered by parent layer */}
</g>
);
}
return (
<g
className={`${styles.nodeGroup} ${dimmed ? styles.dimmed : ''} ${isSelected ? styles.selected : ''}`}
opacity={dimmed ? 0.15 : 1}
onClick={() => node.id && onClick(node.id)}
style={{ cursor: 'pointer' }}
role="img"
aria-label={`${node.type}: ${node.label}${duration != null ? `, ${duration}ms` : ''}`}
tabIndex={0}
>
<rect
x={node.x}
y={node.y}
width={node.width}
height={node.height}
rx={8}
fill={style.bg}
stroke={isSelected ? '#f0b429' : borderColor}
strokeWidth={isSelected ? 2 : 1.5}
strokeDasharray={style.category === 'crossRoute' ? '5,3' : undefined}
filter={glowFilter}
/>
<text
x={(node.x ?? 0) + (node.width ?? 0) / 2}
y={(node.y ?? 0) + (node.height ?? 0) / 2 + 4}
fill="#fff"
fontSize={12}
fontFamily="JetBrains Mono, monospace"
fontWeight={500}
textAnchor="middle"
>
{node.label}
</text>
{/* Duration badge */}
{isOverlayActive && isExecuted && duration != null && (
<g>
<rect
x={(node.x ?? 0) + (node.width ?? 0) - 28}
y={(node.y ?? 0) - 8}
width={36}
height={16}
rx={8}
fill={isError ? '#f85149' : '#3fb950'}
opacity={0.9}
/>
<text
x={(node.x ?? 0) + (node.width ?? 0) - 10}
y={(node.y ?? 0) + 4}
fill="#fff"
fontSize={9}
fontFamily="JetBrains Mono, monospace"
fontWeight={600}
textAnchor="middle"
>
{duration}ms
</text>
</g>
)}
{/* Sequence badge */}
{isOverlayActive && isExecuted && sequence != null && (
<g>
<circle
cx={(node.x ?? 0) + 8}
cy={(node.y ?? 0) - 4}
r={8}
fill="#21262d"
stroke={isError ? '#f85149' : '#3fb950'}
strokeWidth={1.5}
/>
<text
x={(node.x ?? 0) + 8}
y={(node.y ?? 0) - 1}
fill="#fff"
fontSize={8}
fontFamily="JetBrains Mono, monospace"
fontWeight={600}
textAnchor="middle"
>
{sequence}
</text>
</g>
)}
</g>
);
}

View File

@@ -0,0 +1,91 @@
import type { PositionedEdge } from '../../../api/types';
import styles from './diagram.module.css';
interface EdgeLayerProps {
edges: PositionedEdge[];
executedEdges: Set<string>;
isOverlayActive: boolean;
}
function edgeKey(e: PositionedEdge): string {
return `${e.sourceId}->${e.targetId}`;
}
/** Convert waypoints to a smooth cubic bezier SVG path */
function pointsToPath(points: number[][]): string {
if (!points || points.length === 0) return '';
if (points.length === 1) return `M${points[0][0]},${points[0][1]}`;
let d = `M${points[0][0]},${points[0][1]}`;
if (points.length === 2) {
d += ` L${points[1][0]},${points[1][1]}`;
return d;
}
// Catmull-Rom → cubic bezier approximation for smooth curves
for (let i = 0; i < points.length - 1; i++) {
const p0 = points[Math.max(i - 1, 0)];
const p1 = points[i];
const p2 = points[i + 1];
const p3 = points[Math.min(i + 2, points.length - 1)];
const cp1x = p1[0] + (p2[0] - p0[0]) / 6;
const cp1y = p1[1] + (p2[1] - p0[1]) / 6;
const cp2x = p2[0] - (p3[0] - p1[0]) / 6;
const cp2y = p2[1] - (p3[1] - p1[1]) / 6;
d += ` C${cp1x},${cp1y} ${cp2x},${cp2y} ${p2[0]},${p2[1]}`;
}
return d;
}
export function EdgeLayer({ edges, executedEdges, isOverlayActive }: EdgeLayerProps) {
return (
<g className={styles.edgeLayer}>
{edges.map((edge) => {
const key = edgeKey(edge);
const executed = executedEdges.has(key);
const dimmed = isOverlayActive && !executed;
const path = pointsToPath(edge.points ?? []);
return (
<g key={key} opacity={dimmed ? 0.1 : 1}>
{/* Glow under-layer for executed edges */}
{isOverlayActive && executed && (
<path
d={path}
fill="none"
stroke="#3fb950"
strokeWidth={6}
strokeOpacity={0.2}
strokeLinecap="round"
/>
)}
<path
d={path}
fill="none"
stroke={isOverlayActive && executed ? '#3fb950' : '#4a5e7a'}
strokeWidth={isOverlayActive && executed ? 2.5 : 1.5}
strokeLinecap="round"
markerEnd={executed ? 'url(#arrowhead-executed)' : 'url(#arrowhead)'}
/>
{edge.label && edge.points && edge.points.length > 1 && (
<text
x={(edge.points[0][0] + edge.points[edge.points.length - 1][0]) / 2}
y={(edge.points[0][1] + edge.points[edge.points.length - 1][1]) / 2 - 4}
fill="#7d8590"
fontSize={9}
fontFamily="JetBrains Mono, monospace"
textAnchor="middle"
>
{edge.label}
</text>
)}
</g>
);
})}
</g>
);
}

View File

@@ -0,0 +1,60 @@
import { useState } from 'react';
import styles from './diagram.module.css';
interface ExchangeInspectorProps {
snapshot: Record<string, string>;
}
type Tab = 'input' | 'output';
function tryFormatJson(value: string): string {
try {
return JSON.stringify(JSON.parse(value), null, 2);
} catch {
return value;
}
}
export function ExchangeInspector({ snapshot }: ExchangeInspectorProps) {
const [tab, setTab] = useState<Tab>('input');
const body = tab === 'input' ? snapshot.inputBody : snapshot.outputBody;
const headers = tab === 'input' ? snapshot.inputHeaders : snapshot.outputHeaders;
return (
<div className={styles.exchangeInspector}>
<div className={styles.exchangeTabs}>
<button
className={`${styles.exchangeTab} ${tab === 'input' ? styles.exchangeTabActive : ''}`}
onClick={() => setTab('input')}
>
Input
</button>
<button
className={`${styles.exchangeTab} ${tab === 'output' ? styles.exchangeTabActive : ''}`}
onClick={() => setTab('output')}
>
Output
</button>
</div>
{body && (
<div className={styles.exchangeSection}>
<div className={styles.exchangeSectionLabel}>Body</div>
<pre className={styles.exchangeBody}>{tryFormatJson(body)}</pre>
</div>
)}
{headers && (
<div className={styles.exchangeSection}>
<div className={styles.exchangeSectionLabel}>Headers</div>
<pre className={styles.exchangeBody}>{tryFormatJson(headers)}</pre>
</div>
)}
{!body && !headers && (
<div className={styles.exchangeEmpty}>No exchange data available</div>
)}
</div>
);
}

View File

@@ -0,0 +1,61 @@
import { useMemo } from 'react';
import type { PositionedEdge } from '../../../api/types';
import styles from './diagram.module.css';
interface FlowParticlesProps {
edges: PositionedEdge[];
executedEdges: Set<string>;
isActive: boolean;
}
function pointsToPath(points: number[][]): string {
if (!points || points.length < 2) return '';
let d = `M${points[0][0]},${points[0][1]}`;
for (let i = 1; i < points.length; i++) {
d += ` L${points[i][0]},${points[i][1]}`;
}
return d;
}
export function FlowParticles({ edges, executedEdges, isActive }: FlowParticlesProps) {
const paths = useMemo(() => {
if (!isActive) return [];
return edges
.filter((e) => executedEdges.has(`${e.sourceId}->${e.targetId}`))
.map((e, i) => ({
id: `particle-${e.sourceId}-${e.targetId}`,
d: pointsToPath(e.points ?? []),
delay: (i * 0.3) % 1.5,
}))
.filter((p) => p.d);
}, [edges, executedEdges, isActive]);
if (!isActive || paths.length === 0) return null;
return (
<g className={styles.flowParticles}>
{paths.map((p) => (
<g key={p.id}>
<path id={p.id} d={p.d} fill="none" stroke="none" />
<circle r={3} fill="url(#particle-gradient)">
<animateMotion
dur="1.5s"
repeatCount="indefinite"
begin={`${p.delay}s`}
>
<mpath href={`#${p.id}`} />
</animateMotion>
<animate
attributeName="opacity"
values="0;1;1;0"
keyTimes="0;0.1;0.8;1"
dur="1.5s"
repeatCount="indefinite"
begin={`${p.delay}s`}
/>
</circle>
</g>
))}
</g>
);
}

View File

@@ -0,0 +1,102 @@
import { useMemo } from 'react';
import type { ExecutionDetail, ProcessorNode } from '../../../api/types';
import { useProcessorSnapshot } from '../../../api/queries/executions';
import { ExchangeInspector } from './ExchangeInspector';
import styles from './diagram.module.css';
interface ProcessorDetailPanelProps {
execution: ExecutionDetail;
selectedNodeId: string | null;
}
/** Find the processor node matching a diagramNodeId, return its flat index too */
function findProcessor(
processors: ProcessorNode[],
nodeId: string,
indexRef: { idx: number },
): ProcessorNode | null {
for (const proc of processors) {
const currentIdx = indexRef.idx;
indexRef.idx++;
if (proc.diagramNodeId === nodeId) {
return { ...proc, _flatIndex: currentIdx } as ProcessorNode & { _flatIndex: number };
}
if (proc.children && proc.children.length > 0) {
const found = findProcessor(proc.children, nodeId, indexRef);
if (found) return found;
}
}
return null;
}
export function ProcessorDetailPanel({ execution, selectedNodeId }: ProcessorDetailPanelProps) {
const processor = useMemo(() => {
if (!selectedNodeId || !execution.processors) return null;
return findProcessor(execution.processors, selectedNodeId, { idx: 0 });
}, [execution, selectedNodeId]);
// Get flat index for snapshot lookup
const flatIndex = useMemo(() => {
if (!processor) return null;
return (processor as ProcessorNode & { _flatIndex?: number })._flatIndex ?? null;
}, [processor]);
const { data: snapshot } = useProcessorSnapshot(
flatIndex != null ? execution.executionId ?? null : null,
flatIndex,
);
if (!selectedNodeId || !processor) {
return (
<div className={styles.detailPanel}>
<div className={styles.detailEmpty}>
Click a node to view processor details
</div>
</div>
);
}
return (
<div className={styles.detailPanel}>
{/* Processor identity */}
<div className={styles.detailHeader}>
<div className={styles.detailType}>{processor.processorType}</div>
<div className={styles.detailId}>{processor.processorId}</div>
</div>
<div className={styles.detailMeta}>
<div className={styles.detailMetaItem}>
<span className={styles.detailMetaLabel}>Status</span>
<span className={`${styles.detailMetaValue} ${processor.status === 'FAILED' ? styles.statusFailed : styles.statusOk}`}>
{processor.status}
</span>
</div>
<div className={styles.detailMetaItem}>
<span className={styles.detailMetaLabel}>Duration</span>
<span className={styles.detailMetaValue}>{processor.durationMs}ms</span>
</div>
</div>
{/* Error info */}
{processor.errorMessage && (
<div className={styles.detailError}>
<div className={styles.detailErrorLabel}>Error</div>
<div className={styles.detailErrorMessage}>{processor.errorMessage}</div>
</div>
)}
{/* Exchange data */}
{snapshot && <ExchangeInspector snapshot={snapshot} />}
{/* Actions (future) */}
<div className={styles.detailActions}>
<button className={styles.detailActionBtn} disabled title="Coming soon">
Collect Trace Data
</button>
<button className={styles.detailActionBtn} disabled title="Coming soon">
View Logs
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,95 @@
import type { DiagramLayout } from '../../../api/types';
import type { OverlayState } from '../../../hooks/useExecutionOverlay';
import { SvgDefs } from './SvgDefs';
import { EdgeLayer } from './EdgeLayer';
import { DiagramNode } from './DiagramNode';
import { FlowParticles } from './FlowParticles';
import { isCompoundType } from './nodeStyles';
import type { PositionedNode } from '../../../api/types';
interface RouteDiagramSvgProps {
layout: DiagramLayout;
overlay: OverlayState;
}
/** Recursively flatten all nodes (including compound children) for rendering */
function flattenNodes(nodes: PositionedNode[]): PositionedNode[] {
const result: PositionedNode[] = [];
for (const node of nodes) {
result.push(node);
if (node.children && node.children.length > 0) {
result.push(...flattenNodes(node.children));
}
}
return result;
}
export function RouteDiagramSvg({ layout, overlay }: RouteDiagramSvgProps) {
const padding = 40;
const width = (layout.width ?? 600) + padding * 2;
const height = (layout.height ?? 400) + padding * 2;
const allNodes = flattenNodes(layout.nodes ?? []);
// Render compound nodes first (background), then regular nodes on top
const compoundNodes = allNodes.filter((n) => isCompoundType(n.type ?? ''));
const leafNodes = allNodes.filter((n) => !isCompoundType(n.type ?? ''));
return (
<svg
width={width}
height={height}
viewBox={`-${padding} -${padding} ${width} ${height}`}
xmlns="http://www.w3.org/2000/svg"
style={{ display: 'block' }}
>
<SvgDefs />
{/* Compound container nodes (background) */}
{compoundNodes.map((node) => (
<DiagramNode
key={node.id}
node={node}
isExecuted={!!node.id && overlay.executedNodes.has(node.id)}
isError={false}
isOverlayActive={overlay.isActive}
duration={node.id ? overlay.durations.get(node.id) : undefined}
sequence={undefined}
isSelected={overlay.selectedNodeId === node.id}
onClick={overlay.selectNode}
/>
))}
{/* Edges */}
<EdgeLayer
edges={layout.edges ?? []}
executedEdges={overlay.executedEdges}
isOverlayActive={overlay.isActive}
/>
{/* Flow particles */}
<FlowParticles
edges={layout.edges ?? []}
executedEdges={overlay.executedEdges}
isActive={overlay.isActive}
/>
{/* Leaf nodes (on top of edges) */}
{leafNodes.map((node) => {
const nodeId = node.id ?? '';
return (
<DiagramNode
key={nodeId}
node={node}
isExecuted={overlay.executedNodes.has(nodeId)}
isError={false}
isOverlayActive={overlay.isActive}
duration={overlay.durations.get(nodeId)}
sequence={overlay.sequences.get(nodeId)}
isSelected={overlay.selectedNodeId === nodeId}
onClick={overlay.selectNode}
/>
);
})}
</svg>
);
}

View File

@@ -0,0 +1,64 @@
/** SVG definitions: arrow markers, glow filters, gradient fills */
export function SvgDefs() {
return (
<defs>
{/* Arrow marker for edges */}
<marker id="arrowhead" markerWidth="8" markerHeight="6" refX="8" refY="3"
orient="auto" markerUnits="strokeWidth">
<path d="M0,0 L8,3 L0,6" fill="#4a5e7a" />
</marker>
<marker id="arrowhead-executed" markerWidth="8" markerHeight="6" refX="8" refY="3"
orient="auto" markerUnits="strokeWidth">
<path d="M0,0 L8,3 L0,6" fill="#3fb950" />
</marker>
<marker id="arrowhead-error" markerWidth="8" markerHeight="6" refX="8" refY="3"
orient="auto" markerUnits="strokeWidth">
<path d="M0,0 L8,3 L0,6" fill="#f85149" />
</marker>
{/* Glow filters */}
<filter id="glow-green" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="3" result="blur" />
<feFlood floodColor="#3fb950" floodOpacity="0.4" result="color" />
<feComposite in="color" in2="blur" operator="in" result="shadow" />
<feMerge>
<feMergeNode in="shadow" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<filter id="glow-red" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="3" result="blur" />
<feFlood floodColor="#f85149" floodOpacity="0.4" result="color" />
<feComposite in="color" in2="blur" operator="in" result="shadow" />
<feMerge>
<feMergeNode in="shadow" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<filter id="glow-blue" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="3" result="blur" />
<feFlood floodColor="#58a6ff" floodOpacity="0.4" result="color" />
<feComposite in="color" in2="blur" operator="in" result="shadow" />
<feMerge>
<feMergeNode in="shadow" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<filter id="glow-purple" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="3" result="blur" />
<feFlood floodColor="#b87aff" floodOpacity="0.4" result="color" />
<feComposite in="color" in2="blur" operator="in" result="shadow" />
<feMerge>
<feMergeNode in="shadow" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
{/* Flow particle gradient */}
<radialGradient id="particle-gradient">
<stop offset="0%" stopColor="#3fb950" stopOpacity="1" />
<stop offset="100%" stopColor="#3fb950" stopOpacity="0" />
</radialGradient>
</defs>
);
}

View File

@@ -0,0 +1,325 @@
/* ─── Diagram Canvas ─── */
.canvasContainer {
position: relative;
flex: 1;
min-height: 0;
background: var(--bg-deep);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
overflow: hidden;
}
.canvas {
width: 100%;
height: 100%;
min-height: 500px;
overflow: hidden;
cursor: grab;
}
.canvas:active {
cursor: grabbing;
}
/* ─── Zoom Controls ─── */
.zoomControls {
position: absolute;
top: 12px;
right: 12px;
display: flex;
gap: 4px;
z-index: 10;
}
.zoomBtn {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-secondary);
font-family: var(--font-mono);
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
}
.zoomBtn:hover {
background: var(--bg-raised);
color: var(--text-primary);
}
/* ─── Minimap ─── */
.minimap {
position: absolute;
bottom: 12px;
right: 12px;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 4px;
z-index: 10;
opacity: 0.85;
transition: opacity 0.2s;
}
.minimap:hover {
opacity: 1;
}
/* ─── Node Styles ─── */
.nodeGroup {
transition: opacity 0.3s;
}
.dimmed {
opacity: 0.15 !important;
}
.selected rect {
stroke-width: 2.5;
}
/* ─── Edge Layer ─── */
.edgeLayer path {
transition: opacity 0.3s, stroke 0.3s;
}
/* ─── Flow Particles ─── */
.flowParticles circle {
pointer-events: none;
}
/* ─── Split Layout (Diagram + Detail Panel) ─── */
.splitLayout {
display: flex;
gap: 0;
height: 100%;
min-height: 500px;
}
.diagramSide {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
/* ─── Processor Detail Panel ─── */
.detailPanel {
width: 340px;
flex-shrink: 0;
background: var(--bg-surface);
border-left: 1px solid var(--border-subtle);
padding: 16px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 16px;
}
.detailEmpty {
color: var(--text-muted);
font-size: 13px;
text-align: center;
padding: 40px 16px;
}
.detailHeader {
border-bottom: 1px solid var(--border-subtle);
padding-bottom: 12px;
}
.detailType {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.8px;
color: var(--amber);
margin-bottom: 4px;
}
.detailId {
font-family: var(--font-mono);
font-size: 13px;
color: var(--text-primary);
word-break: break-all;
}
.detailMeta {
display: flex;
gap: 16px;
}
.detailMetaItem {
display: flex;
flex-direction: column;
gap: 2px;
}
.detailMetaLabel {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
}
.detailMetaValue {
font-family: var(--font-mono);
font-size: 13px;
color: var(--text-primary);
}
.statusFailed {
color: var(--rose);
}
.statusOk {
color: var(--green);
}
.detailError {
background: var(--rose-glow);
border: 1px solid rgba(244, 63, 94, 0.2);
border-radius: var(--radius-sm);
padding: 10px 12px;
}
.detailErrorLabel {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--rose);
margin-bottom: 4px;
}
.detailErrorMessage {
font-family: var(--font-mono);
font-size: 11px;
color: var(--rose);
max-height: 80px;
overflow: auto;
}
/* ─── Exchange Inspector ─── */
.exchangeInspector {
flex: 1;
min-height: 0;
}
.exchangeTabs {
display: flex;
gap: 0;
border-bottom: 1px solid var(--border-subtle);
margin-bottom: 12px;
}
.exchangeTab {
padding: 6px 16px;
border: none;
background: none;
color: var(--text-muted);
font-size: 12px;
font-weight: 500;
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all 0.15s;
}
.exchangeTab:hover {
color: var(--text-secondary);
}
.exchangeTabActive {
color: var(--amber);
border-bottom-color: var(--amber);
}
.exchangeSection {
margin-bottom: 12px;
}
.exchangeSectionLabel {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
margin-bottom: 6px;
}
.exchangeBody {
background: var(--bg-base);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
padding: 10px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
max-height: 200px;
overflow: auto;
white-space: pre-wrap;
word-break: break-all;
margin: 0;
}
.exchangeEmpty {
color: var(--text-muted);
font-size: 12px;
text-align: center;
padding: 20px;
}
/* ─── Detail Actions ─── */
.detailActions {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: auto;
padding-top: 12px;
border-top: 1px solid var(--border-subtle);
}
.detailActionBtn {
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-raised);
color: var(--text-secondary);
font-size: 12px;
cursor: pointer;
transition: all 0.15s;
}
.detailActionBtn:hover:not(:disabled) {
background: var(--bg-hover);
color: var(--text-primary);
}
.detailActionBtn:disabled {
opacity: 0.4;
cursor: default;
}
/* ─── Responsive ─── */
@media (max-width: 768px) {
.splitLayout {
flex-direction: column;
}
.detailPanel {
width: 100%;
max-height: 300px;
border-left: none;
border-top: 1px solid var(--border-subtle);
}
.minimap {
display: none;
}
}

View File

@@ -0,0 +1,52 @@
/** Node type styling: border color, background, glow filter */
const ENDPOINT_TYPES = new Set([
'ENDPOINT', 'DIRECT', 'SEDA', 'TO', 'TO_DYNAMIC', 'FROM',
]);
const EIP_TYPES = new Set([
'CHOICE', 'SPLIT', 'MULTICAST', 'FILTER', 'AGGREGATE',
'RECIPIENT_LIST', 'ROUTING_SLIP', 'DYNAMIC_ROUTER',
'CIRCUIT_BREAKER', 'WHEN', 'OTHERWISE', 'LOOP',
]);
const ERROR_TYPES = new Set([
'ON_EXCEPTION', 'TRY_CATCH', 'DO_CATCH', 'DO_FINALLY',
'ERROR_HANDLER',
]);
const CROSS_ROUTE_TYPES = new Set([
'WIRE_TAP', 'ENRICH', 'POLL_ENRICH',
]);
export interface NodeStyle {
border: string;
bg: string;
glowFilter: string;
category: 'endpoint' | 'eip' | 'processor' | 'error' | 'crossRoute';
}
export function getNodeStyle(type: string): NodeStyle {
const upper = type.toUpperCase();
if (ERROR_TYPES.has(upper)) {
return { border: '#f85149', bg: '#3d1418', glowFilter: 'url(#glow-red)', category: 'error' };
}
if (ENDPOINT_TYPES.has(upper)) {
return { border: '#58a6ff', bg: '#1a3a5c', glowFilter: 'url(#glow-blue)', category: 'endpoint' };
}
if (CROSS_ROUTE_TYPES.has(upper)) {
return { border: '#39d2e0', bg: 'transparent', glowFilter: 'url(#glow-blue)', category: 'crossRoute' };
}
if (EIP_TYPES.has(upper)) {
return { border: '#b87aff', bg: '#2d1b4e', glowFilter: 'url(#glow-purple)', category: 'eip' };
}
// Default: Processor
return { border: '#3fb950', bg: '#0d2818', glowFilter: 'url(#glow-green)', category: 'processor' };
}
/** Compound node types that can contain children */
export const COMPOUND_TYPES = new Set([
'CHOICE', 'SPLIT', 'TRY_CATCH', 'LOOP', 'MULTICAST', 'AGGREGATE',
'ON_EXCEPTION', 'DO_CATCH', 'DO_FINALLY',
]);
export function isCompoundType(type: string): boolean {
return COMPOUND_TYPES.has(type.toUpperCase());
}

View File

@@ -5,6 +5,7 @@ import { LoginPage } from './auth/LoginPage';
import { OidcCallback } from './auth/OidcCallback'; import { OidcCallback } from './auth/OidcCallback';
import { ExecutionExplorer } from './pages/executions/ExecutionExplorer'; import { ExecutionExplorer } from './pages/executions/ExecutionExplorer';
import { OidcAdminPage } from './pages/admin/OidcAdminPage'; import { OidcAdminPage } from './pages/admin/OidcAdminPage';
import { RoutePage } from './pages/routes/RoutePage';
export const router = createBrowserRouter([ export const router = createBrowserRouter([
{ {
@@ -23,6 +24,7 @@ export const router = createBrowserRouter([
children: [ children: [
{ index: true, element: <Navigate to="/executions" replace /> }, { index: true, element: <Navigate to="/executions" replace /> },
{ path: 'executions', element: <ExecutionExplorer /> }, { path: 'executions', element: <ExecutionExplorer /> },
{ path: 'apps/:group/routes/:routeId', element: <RoutePage /> },
{ path: 'admin/oidc', element: <OidcAdminPage /> }, { path: 'admin/oidc', element: <OidcAdminPage /> },
], ],
}, },