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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -167,6 +167,15 @@ public class AgentRegistryService {
.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.
* Notifies the event listener if one is set.

View File

@@ -1,5 +1,7 @@
package com.cameleer3.server.core.search;
import java.util.List;
/**
* Swappable search backend abstraction.
* <p>
@@ -34,6 +36,17 @@ public interface SearchEngine {
*/
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.
*
@@ -43,4 +56,17 @@ public interface SearchEngine {
* @return bucketed stats
*/
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;
import java.time.Instant;
import java.util.List;
/**
* Immutable search criteria for querying route executions.
@@ -21,6 +22,8 @@ import java.time.Instant;
* @param routeId exact match on route_id
* @param agentId exact match on agent_id
* @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 limit page size (default 50, max 500)
* @param sortField column to sort by (default: startTime)
@@ -40,6 +43,8 @@ public record SearchRequest(
String routeId,
String agentId,
String processorType,
String group,
List<String> agentIds,
int offset,
int limit,
String sortField,
@@ -74,4 +79,14 @@ public record SearchRequest(
public String sortColumn() {
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;
import java.util.List;
/**
* Orchestrates search operations, delegating to a {@link SearchEngine} backend.
* <p>
@@ -36,10 +38,26 @@ public class SearchService {
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.
*/
public StatsTimeseries timeseries(java.time.Instant from, java.time.Instant to, int 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.server.core.ingestion.TaggedDiagram;
import java.util.List;
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.
*/
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": {
"@tanstack/react-query": "^5.90.21",
"openapi-fetch": "^0.17.0",
"panzoom": "^9.4.3",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router": "^7.13.1",
"uplot": "^1.6.32",
"zustand": "^5.0.11"
},
"devDependencies": {
@@ -1391,6 +1393,15 @@
"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": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz",
@@ -1444,6 +1455,12 @@
"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": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
@@ -2572,6 +2589,12 @@
"dev": true,
"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": {
"version": "2.0.36",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz",
@@ -2678,6 +2701,17 @@
"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": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -3133,6 +3167,12 @@
"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": {
"version": "4.4.1",
"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": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View File

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

View File

@@ -236,6 +236,14 @@
"type": "string"
}
},
{
"name": "group",
"in": "query",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "offset",
"in": "query",
@@ -933,6 +941,22 @@
"type": "string",
"format": "date-time"
}
},
{
"name": "routeId",
"in": "query",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "group",
"in": "query",
"required": false,
"schema": {
"type": "string"
}
}
],
"responses": {
@@ -984,6 +1008,22 @@
"format": "int32",
"default": 24
}
},
{
"name": "routeId",
"in": "query",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "group",
"in": "query",
"required": false,
"schema": {
"type": "string"
}
}
],
"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": {
"get": {
"tags": [
@@ -1191,7 +1274,7 @@
"Agent Management"
],
"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",
"parameters": [
{
@@ -1201,6 +1284,14 @@
"schema": {
"type": "string"
}
},
{
"name": "group",
"in": "query",
"required": false,
"schema": {
"type": "string"
}
}
],
"responses": {
@@ -1509,6 +1600,15 @@
"processorType": {
"type": "string"
},
"group": {
"type": "string"
},
"agentIds": {
"type": "array",
"items": {
"type": "string"
}
},
"offset": {
"type": "integer",
"format": "int32"
@@ -2119,6 +2219,12 @@
"height": {
"type": "number",
"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;
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": {
parameters: {
query?: never;
@@ -440,7 +460,7 @@ export interface paths {
};
/**
* 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"];
put?: never;
@@ -558,6 +578,8 @@ export interface components {
routeId?: string;
agentId?: string;
processorType?: string;
group?: string;
agentIds?: string[];
/** Format: int32 */
offset?: number;
/** Format: int32 */
@@ -759,6 +781,7 @@ export interface components {
width?: number;
/** Format: double */
height?: number;
children?: components["schemas"]["PositionedNode"][];
};
/** @description OIDC configuration for SPA login flow */
OidcPublicConfigResponse: {
@@ -915,6 +938,7 @@ export interface operations {
routeId?: string;
agentId?: string;
processorType?: string;
group?: string;
offset?: number;
limit?: number;
sortField?: string;
@@ -1452,6 +1476,8 @@ export interface operations {
query: {
from: string;
to?: string;
routeId?: string;
group?: string;
};
header?: never;
path?: never;
@@ -1476,6 +1502,8 @@ export interface operations {
from: string;
to?: string;
buckets?: number;
routeId?: string;
group?: string;
};
header?: 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: {
parameters: {
query?: never;
@@ -1635,6 +1693,7 @@ export interface operations {
parameters: {
query?: {
status?: string;
group?: string;
};
header?: never;
path?: never;

View File

@@ -15,3 +15,6 @@ export type OidcTestResult = components['schemas']['OidcTestResult'];
export type OidcPublicConfigResponse = components['schemas']['OidcPublicConfigResponse'];
export type AuthTokenResponse = components['schemas']['AuthTokenResponse'];
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 { Sparkline } from './Sparkline';
import { MiniChart } from '../charts/MiniChart';
const ACCENT_COLORS: Record<string, string> = {
amber: 'var(--amber)',
@@ -27,7 +27,7 @@ export function StatCard({ label, value, accent, change, changeDirection = 'neut
<div className={`${styles.statChange} ${styles[changeDirection]}`}>{change}</div>
)}
{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>
);

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 { useNavigate } from 'react-router';
import type { ExecutionSummary } from '../../api/types';
import { useAgents } from '../../api/queries/agents';
import { StatusPill } from '../../components/shared/StatusPill';
import { DurationBar } from '../../components/shared/DurationBar';
import { AppBadge } from '../../components/shared/AppBadge';
@@ -55,11 +57,25 @@ export function ResultsTable({ results, loading }: ResultsTableProps) {
const sortColumn = useExecutionSearch((s) => s.sortField);
const sortDir = useExecutionSearch((s) => s.sortDir);
const setSort = useExecutionSearch((s) => s.setSort);
const navigate = useNavigate();
const { data: agents } = useAgents();
function handleSort(col: SortColumn) {
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) {
return (
<div className={styles.tableWrap}>
@@ -99,6 +115,7 @@ export function ResultsTable({ results, loading }: ResultsTableProps) {
exec={exec}
isExpanded={isExpanded}
onToggle={() => setExpandedId(isExpanded ? null : exec.executionId)}
onDiagramNav={(e) => handleDiagramNav(exec, e)}
/>
);
})}
@@ -112,16 +129,25 @@ function ResultRow({
exec,
isExpanded,
onToggle,
onDiagramNav,
}: {
exec: ExecutionSummary;
isExpanded: boolean;
onToggle: () => void;
onDiagramNav: (e: React.MouseEvent) => void;
}) {
return (
<>
<tr
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} 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 { ExecutionExplorer } from './pages/executions/ExecutionExplorer';
import { OidcAdminPage } from './pages/admin/OidcAdminPage';
import { RoutePage } from './pages/routes/RoutePage';
export const router = createBrowserRouter([
{
@@ -23,6 +24,7 @@ export const router = createBrowserRouter([
children: [
{ index: true, element: <Navigate to="/executions" replace /> },
{ path: 'executions', element: <ExecutionExplorer /> },
{ path: 'apps/:group/routes/:routeId', element: <RoutePage /> },
{ path: 'admin/oidc', element: <OidcAdminPage /> },
],
},