From 7778793e7b0f1f53e4a4fd496f5da0a759f9c5de Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sat, 14 Mar 2026 21:35:42 +0100 Subject: [PATCH] Add route diagram page with execution overlay and group-aware APIs Backend: Add group filtering to agent list, search, stats, and timeseries endpoints. Add diagram lookup by group+routeId. Resolve application group to agent IDs server-side for ClickHouse IN-clause queries. Frontend: New route detail page at /apps/{group}/routes/{routeId} with three tabs (Diagram, Performance, Processor Tree). SVG diagram rendering with panzoom, execution overlay (glow effects, duration/sequence badges, flow particles, minimap), and processor detail panel. uPlot charts for performance tab replacing old SVG sparklines. Ctrl+Click from ExecutionExplorer navigates to route diagram with overlay. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../AgentRegistrationController.java | 12 +- .../controller/DiagramRenderController.java | 39 ++- .../app/controller/SearchController.java | 52 ++- .../app/search/ClickHouseSearchEngine.java | 101 +++++- .../storage/ClickHouseDiagramRepository.java | 21 ++ .../core/agent/AgentRegistryService.java | 9 + .../server/core/search/SearchEngine.java | 26 ++ .../server/core/search/SearchRequest.java | 15 + .../server/core/search/SearchService.java | 18 + .../core/storage/DiagramRepository.java | 8 + ui/package-lock.json | 46 +++ ui/package.json | 2 + ui/src/api/openapi.json | 108 +++++- ui/src/api/queries/diagrams.ts | 31 ++ ui/src/api/schema.d.ts | 61 +++- ui/src/api/types.ts | 3 + .../components/charts/DurationHistogram.tsx | 104 ++++++ ui/src/components/charts/LatencyHeatmap.tsx | 71 ++++ ui/src/components/charts/MiniChart.tsx | 62 ++++ ui/src/components/charts/ThroughputChart.tsx | 57 +++ ui/src/components/charts/theme.ts | 69 ++++ ui/src/components/shared/StatCard.tsx | 4 +- ui/src/hooks/useExecutionOverlay.ts | 130 +++++++ ui/src/pages/executions/ResultsTable.tsx | 28 +- ui/src/pages/routes/DiagramTab.tsx | 27 ++ ui/src/pages/routes/PerformanceTab.tsx | 95 +++++ ui/src/pages/routes/RouteHeader.tsx | 29 ++ ui/src/pages/routes/RoutePage.module.css | 262 ++++++++++++++ ui/src/pages/routes/RoutePage.tsx | 113 ++++++ ui/src/pages/routes/diagram/DiagramCanvas.tsx | 110 ++++++ .../pages/routes/diagram/DiagramMinimap.tsx | 71 ++++ ui/src/pages/routes/diagram/DiagramNode.tsx | 160 +++++++++ ui/src/pages/routes/diagram/EdgeLayer.tsx | 91 +++++ .../routes/diagram/ExchangeInspector.tsx | 60 ++++ ui/src/pages/routes/diagram/FlowParticles.tsx | 61 ++++ .../routes/diagram/ProcessorDetailPanel.tsx | 102 ++++++ .../pages/routes/diagram/RouteDiagramSvg.tsx | 95 +++++ ui/src/pages/routes/diagram/SvgDefs.tsx | 64 ++++ .../pages/routes/diagram/diagram.module.css | 325 ++++++++++++++++++ ui/src/pages/routes/diagram/nodeStyles.ts | 52 +++ ui/src/router.tsx | 2 + 41 files changed, 2770 insertions(+), 26 deletions(-) create mode 100644 ui/src/api/queries/diagrams.ts create mode 100644 ui/src/components/charts/DurationHistogram.tsx create mode 100644 ui/src/components/charts/LatencyHeatmap.tsx create mode 100644 ui/src/components/charts/MiniChart.tsx create mode 100644 ui/src/components/charts/ThroughputChart.tsx create mode 100644 ui/src/components/charts/theme.ts create mode 100644 ui/src/hooks/useExecutionOverlay.ts create mode 100644 ui/src/pages/routes/DiagramTab.tsx create mode 100644 ui/src/pages/routes/PerformanceTab.tsx create mode 100644 ui/src/pages/routes/RouteHeader.tsx create mode 100644 ui/src/pages/routes/RoutePage.module.css create mode 100644 ui/src/pages/routes/RoutePage.tsx create mode 100644 ui/src/pages/routes/diagram/DiagramCanvas.tsx create mode 100644 ui/src/pages/routes/diagram/DiagramMinimap.tsx create mode 100644 ui/src/pages/routes/diagram/DiagramNode.tsx create mode 100644 ui/src/pages/routes/diagram/EdgeLayer.tsx create mode 100644 ui/src/pages/routes/diagram/ExchangeInspector.tsx create mode 100644 ui/src/pages/routes/diagram/FlowParticles.tsx create mode 100644 ui/src/pages/routes/diagram/ProcessorDetailPanel.tsx create mode 100644 ui/src/pages/routes/diagram/RouteDiagramSvg.tsx create mode 100644 ui/src/pages/routes/diagram/SvgDefs.tsx create mode 100644 ui/src/pages/routes/diagram/diagram.module.css create mode 100644 ui/src/pages/routes/diagram/nodeStyles.ts diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentRegistrationController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentRegistrationController.java index 68120bec..6419d4e0 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentRegistrationController.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentRegistrationController.java @@ -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> listAgents( - @RequestParam(required = false) String status) { + @RequestParam(required = false) String status, + @RequestParam(required = false) String group) { List 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 response = agents.stream() .map(AgentInstanceResponse::from) .toList(); diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/DiagramRenderController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/DiagramRenderController.java index 02312270..b1ca3775 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/DiagramRenderController.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/DiagramRenderController.java @@ -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 findByGroupAndRoute( + @RequestParam String group, + @RequestParam String routeId) { + List agentIds = registryService.findByGroup(group).stream() + .map(AgentInfo::id) + .toList(); + + if (agentIds.isEmpty()) { + return ResponseEntity.notFound().build(); + } + + Optional contentHash = diagramRepository.findContentHashForRouteByAgents(routeId, agentIds); + if (contentHash.isEmpty()) { + return ResponseEntity.notFound().build(); + } + + Optional 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. *

diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/SearchController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/SearchController.java index 515c352f..293a71e1 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/SearchController.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/SearchController.java @@ -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 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> 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 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 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 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 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 resolveGroupToAgentIds(String group) { + if (group == null || group.isBlank()) { + return null; + } + return registryService.findByGroup(group).stream() + .map(AgentInfo::id) + .toList(); } } diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/search/ClickHouseSearchEngine.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/search/ClickHouseSearchEngine.java index c5cb3f90..7d095565 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/search/ClickHouseSearchEngine.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/search/ClickHouseSearchEngine.java @@ -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 agentIds) { + var conditions = new ArrayList(); + var params = new ArrayList(); + 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(); + var prevConditions = new ArrayList(); + 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(); + var todayParams = new ArrayList(); + 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 agentIds) { long intervalSeconds = Duration.between(from, to).getSeconds() / bucketCount; if (intervalSeconds < 1) intervalSeconds = 1; + var conditions = new ArrayList(); + var params = new ArrayList(); + 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 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 agentIds, + List conditions, List 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. */ diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/ClickHouseDiagramRepository.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/ClickHouseDiagramRepository.java index 85644c2e..11a0ed4f 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/ClickHouseDiagramRepository.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/ClickHouseDiagramRepository.java @@ -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 findContentHashForRouteByAgents(String routeId, List 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(); + params.add(routeId); + params.addAll(agentIds); + List> 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"); diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/agent/AgentRegistryService.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/agent/AgentRegistryService.java index 07e4e231..bfb72e0b 100644 --- a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/agent/AgentRegistryService.java +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/agent/AgentRegistryService.java @@ -167,6 +167,15 @@ public class AgentRegistryService { .collect(Collectors.toList()); } + /** + * Return all agents belonging to the given application group. + */ + public List 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. diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/search/SearchEngine.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/search/SearchEngine.java index e88d385c..44955c18 100644 --- a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/search/SearchEngine.java +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/search/SearchEngine.java @@ -1,5 +1,7 @@ package com.cameleer3.server.core.search; +import java.util.List; + /** * Swappable search backend abstraction. *

@@ -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 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 agentIds); } diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/search/SearchRequest.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/search/SearchRequest.java index 8ce101ca..ab97c31e 100644 --- a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/search/SearchRequest.java +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/search/SearchRequest.java @@ -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 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 resolvedAgentIds) { + return new SearchRequest( + status, timeFrom, timeTo, durationMin, durationMax, correlationId, + text, textInBody, textInHeaders, textInErrors, + routeId, agentId, processorType, group, resolvedAgentIds, + offset, limit, sortField, sortDir + ); + } } diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/search/SearchService.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/search/SearchService.java index 6ec5df48..263193c2 100644 --- a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/search/SearchService.java +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/search/SearchService.java @@ -1,5 +1,7 @@ package com.cameleer3.server.core.search; +import java.util.List; + /** * Orchestrates search operations, delegating to a {@link SearchEngine} backend. *

@@ -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 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 agentIds) { + return engine.timeseries(from, to, bucketCount, routeId, agentIds); + } } diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/storage/DiagramRepository.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/storage/DiagramRepository.java index 7cfd289d..3a2c4bd6 100644 --- a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/storage/DiagramRepository.java +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/storage/DiagramRepository.java @@ -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 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 findContentHashForRouteByAgents(String routeId, List agentIds); } diff --git a/ui/package-lock.json b/ui/package-lock.json index 24299bd2..66bac5fb 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -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", diff --git a/ui/package.json b/ui/package.json index 49dfc3ea..5841cc02 100644 --- a/ui/package.json +++ b/ui/package.json @@ -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": { diff --git a/ui/src/api/openapi.json b/ui/src/api/openapi.json index 76c83b40..8b8028ef 100644 --- a/ui/src/api/openapi.json +++ b/ui/src/api/openapi.json @@ -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" + } } } }, diff --git a/ui/src/api/queries/diagrams.ts b/ui/src/api/queries/diagrams.ts new file mode 100644 index 00000000..1d276557 --- /dev/null +++ b/ui/src/api/queries/diagrams.ts @@ -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, + }); +} diff --git a/ui/src/api/schema.d.ts b/ui/src/api/schema.d.ts index 97f34192..8c48a1b4 100644 --- a/ui/src/api/schema.d.ts +++ b/ui/src/api/schema.d.ts @@ -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; diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts index ec06c510..64916171 100644 --- a/ui/src/api/types.ts +++ b/ui/src/api/types.ts @@ -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']; diff --git a/ui/src/components/charts/DurationHistogram.tsx b/ui/src/components/charts/DurationHistogram.tsx new file mode 100644 index 00000000..96e86367 --- /dev/null +++ b/ui/src/components/charts/DurationHistogram.tsx @@ -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(null); + const chartRef = useRef(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

Not enough data for histogram
; + + return
; +} diff --git a/ui/src/components/charts/LatencyHeatmap.tsx b/ui/src/components/charts/LatencyHeatmap.tsx new file mode 100644 index 00000000..11965ba2 --- /dev/null +++ b/ui/src/components/charts/LatencyHeatmap.tsx @@ -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(null); + const chartRef = useRef(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
; +} diff --git a/ui/src/components/charts/MiniChart.tsx b/ui/src/components/charts/MiniChart.tsx new file mode 100644 index 00000000..c4053917 --- /dev/null +++ b/ui/src/components/charts/MiniChart.tsx @@ -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(null); + const chartRef = useRef(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
; +} diff --git a/ui/src/components/charts/ThroughputChart.tsx b/ui/src/components/charts/ThroughputChart.tsx new file mode 100644 index 00000000..aa2884c7 --- /dev/null +++ b/ui/src/components/charts/ThroughputChart.tsx @@ -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(null); + const chartRef = useRef(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
; +} diff --git a/ui/src/components/charts/theme.ts b/ui/src/components/charts/theme.ts new file mode 100644 index 00000000..f79494d8 --- /dev/null +++ b/ui/src/components/charts/theme.ts @@ -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)[accent] ?? chartColors.amber; +} + +/** Base uPlot options shared across all Cameleer3 charts */ +export function baseOpts(width: number, height: number): Partial { + 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 { + return { + width, + height, + cursor: { show: false }, + legend: { show: false }, + axes: [ + { show: false }, + { show: false }, + ], + scales: { + x: { time: false }, + }, + }; +} diff --git a/ui/src/components/shared/StatCard.tsx b/ui/src/components/shared/StatCard.tsx index 90bf1455..93f64ea2 100644 --- a/ui/src/components/shared/StatCard.tsx +++ b/ui/src/components/shared/StatCard.tsx @@ -1,5 +1,5 @@ import styles from './shared.module.css'; -import { Sparkline } from './Sparkline'; +import { MiniChart } from '../charts/MiniChart'; const ACCENT_COLORS: Record = { amber: 'var(--amber)', @@ -27,7 +27,7 @@ export function StatCard({ label, value, accent, change, changeDirection = 'neut
{change}
)} {sparkData && sparkData.length >= 2 && ( - + )}
); diff --git a/ui/src/hooks/useExecutionOverlay.ts b/ui/src/hooks/useExecutionOverlay.ts new file mode 100644 index 00000000..7a8bbc6e --- /dev/null +++ b/ui/src/hooks/useExecutionOverlay.ts @@ -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; + executedEdges: Set; + durations: Map; + sequences: Map; + iterationData: Map; + 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, + durations: Map, + sequences: Map, + 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, + edges: Array<{ sourceId?: string; targetId?: string }>, +): Set { + const result = new Set(); + 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(null); + const [iterations, setIterations] = useState>(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(); + const dur = new Map(); + const seq = new Map(); + const iter = new Map(); + + 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, + }; +} diff --git a/ui/src/pages/executions/ResultsTable.tsx b/ui/src/pages/executions/ResultsTable.tsx index 42e07bba..d9a7a762 100644 --- a/ui/src/pages/executions/ResultsTable.tsx +++ b/ui/src/pages/executions/ResultsTable.tsx @@ -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 (
@@ -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 ( <> { + if (e.ctrlKey || e.metaKey) { + onDiagramNav(e); + } else { + onToggle(); + } + }} + title="Click to expand, Ctrl+Click to open diagram" > › {formatTime(exec.startTime)} diff --git a/ui/src/pages/routes/DiagramTab.tsx b/ui/src/pages/routes/DiagramTab.tsx new file mode 100644 index 00000000..4b622dd4 --- /dev/null +++ b/ui/src/pages/routes/DiagramTab.tsx @@ -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 ( +
+
+ +
+ {overlay.isActive && execution && ( + + )} +
+ ); +} diff --git a/ui/src/pages/routes/PerformanceTab.tsx b/ui/src/pages/routes/PerformanceTab.tsx new file mode 100644 index 00000000..02476d31 --- /dev/null +++ b/ui/src/pages/routes/PerformanceTab.tsx @@ -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 ( +
+ {/* Stats cards row */} +
+ + + + +
+ + {/* Charts */} +
+
+

Throughput

+ +
+
+

Duration Distribution

+ +
+
+

Latency Over Time

+ +
+
+
+ ); +} diff --git a/ui/src/pages/routes/RouteHeader.tsx b/ui/src/pages/routes/RouteHeader.tsx new file mode 100644 index 00000000..c93f8ad1 --- /dev/null +++ b/ui/src/pages/routes/RouteHeader.tsx @@ -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 ( +
+
+ {routeId} +
+ + + {group} + + {nodeCount > 0 && ( + {nodeCount} nodes + )} +
+
+
+ ); +} diff --git a/ui/src/pages/routes/RoutePage.module.css b/ui/src/pages/routes/RoutePage.module.css new file mode 100644 index 00000000..bde2ad5b --- /dev/null +++ b/ui/src/pages/routes/RoutePage.module.css @@ -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; + } +} diff --git a/ui/src/pages/routes/RoutePage.tsx b/ui/src/pages/routes/RoutePage.tsx new file mode 100644 index 00000000..514d1871 --- /dev/null +++ b/ui/src/pages/routes/RoutePage.tsx @@ -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('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
Missing group or routeId parameters
; + } + + return ( + <> + {/* Breadcrumb */} + + + {/* Route Header */} + + + {/* Toolbar */} +
+
+ + + +
+ + {activeTab === 'diagram' && ( +
+ + {execution && ( + + {execution.status} · {execution.durationMs}ms + + )} +
+ )} +
+ + {/* Tab Content */} + {activeTab === 'diagram' && ( + layoutLoading ? ( +
Loading diagram...
+ ) : layout ? ( + + ) : ( +
No diagram available for this route
+ ) + )} + + {activeTab === 'performance' && ( + + )} + + {activeTab === 'processors' && execId && ( + + )} + + {activeTab === 'processors' && !execId && ( +
+ Select an execution to view the processor tree +
+ )} + + ); +} diff --git a/ui/src/pages/routes/diagram/DiagramCanvas.tsx b/ui/src/pages/routes/diagram/DiagramCanvas.tsx new file mode 100644 index 00000000..4c9627ee --- /dev/null +++ b/ui/src/pages/routes/diagram/DiagramCanvas.tsx @@ -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(null); + const svgWrapRef = useRef(null); + const panzoomRef = useRef(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 ( +
+ {/* Zoom controls */} +
+ + + +
+ +
+
+ +
+
+ + +
+ ); +} diff --git a/ui/src/pages/routes/diagram/DiagramMinimap.tsx b/ui/src/pages/routes/diagram/DiagramMinimap.tsx new file mode 100644 index 00000000..4fc78162 --- /dev/null +++ b/ui/src/pages/routes/diagram/DiagramMinimap.tsx @@ -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 ( +
+ + + {/* 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 ; + })} + {/* Nodes */} + {nodes.map((n) => { + const ns = getNodeStyle(n.type ?? ''); + return ( + + ); + })} + {/* Viewport rect */} + + +
+ ); +} diff --git a/ui/src/pages/routes/diagram/DiagramNode.tsx b/ui/src/pages/routes/diagram/DiagramNode.tsx new file mode 100644 index 00000000..f8a3862f --- /dev/null +++ b/ui/src/pages/routes/diagram/DiagramNode.tsx @@ -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 ( + + + + {node.label} + + {/* Children rendered by parent layer */} + + ); + } + + return ( + node.id && onClick(node.id)} + style={{ cursor: 'pointer' }} + role="img" + aria-label={`${node.type}: ${node.label}${duration != null ? `, ${duration}ms` : ''}`} + tabIndex={0} + > + + + {node.label} + + + {/* Duration badge */} + {isOverlayActive && isExecuted && duration != null && ( + + + + {duration}ms + + + )} + + {/* Sequence badge */} + {isOverlayActive && isExecuted && sequence != null && ( + + + + {sequence} + + + )} + + ); +} diff --git a/ui/src/pages/routes/diagram/EdgeLayer.tsx b/ui/src/pages/routes/diagram/EdgeLayer.tsx new file mode 100644 index 00000000..a9017ca1 --- /dev/null +++ b/ui/src/pages/routes/diagram/EdgeLayer.tsx @@ -0,0 +1,91 @@ +import type { PositionedEdge } from '../../../api/types'; +import styles from './diagram.module.css'; + +interface EdgeLayerProps { + edges: PositionedEdge[]; + executedEdges: Set; + 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 ( + + {edges.map((edge) => { + const key = edgeKey(edge); + const executed = executedEdges.has(key); + const dimmed = isOverlayActive && !executed; + const path = pointsToPath(edge.points ?? []); + + return ( + + {/* Glow under-layer for executed edges */} + {isOverlayActive && executed && ( + + )} + + {edge.label && edge.points && edge.points.length > 1 && ( + + {edge.label} + + )} + + ); + })} + + ); +} diff --git a/ui/src/pages/routes/diagram/ExchangeInspector.tsx b/ui/src/pages/routes/diagram/ExchangeInspector.tsx new file mode 100644 index 00000000..3db14884 --- /dev/null +++ b/ui/src/pages/routes/diagram/ExchangeInspector.tsx @@ -0,0 +1,60 @@ +import { useState } from 'react'; +import styles from './diagram.module.css'; + +interface ExchangeInspectorProps { + snapshot: Record; +} + +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('input'); + + const body = tab === 'input' ? snapshot.inputBody : snapshot.outputBody; + const headers = tab === 'input' ? snapshot.inputHeaders : snapshot.outputHeaders; + + return ( +
+
+ + +
+ + {body && ( +
+
Body
+
{tryFormatJson(body)}
+
+ )} + + {headers && ( +
+
Headers
+
{tryFormatJson(headers)}
+
+ )} + + {!body && !headers && ( +
No exchange data available
+ )} +
+ ); +} diff --git a/ui/src/pages/routes/diagram/FlowParticles.tsx b/ui/src/pages/routes/diagram/FlowParticles.tsx new file mode 100644 index 00000000..eb152b24 --- /dev/null +++ b/ui/src/pages/routes/diagram/FlowParticles.tsx @@ -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; + 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 ( + + {paths.map((p) => ( + + + + + + + + + + ))} + + ); +} diff --git a/ui/src/pages/routes/diagram/ProcessorDetailPanel.tsx b/ui/src/pages/routes/diagram/ProcessorDetailPanel.tsx new file mode 100644 index 00000000..e1a4026f --- /dev/null +++ b/ui/src/pages/routes/diagram/ProcessorDetailPanel.tsx @@ -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 ( +
+
+ Click a node to view processor details +
+
+ ); + } + + return ( +
+ {/* Processor identity */} +
+
{processor.processorType}
+
{processor.processorId}
+
+ +
+
+ Status + + {processor.status} + +
+
+ Duration + {processor.durationMs}ms +
+
+ + {/* Error info */} + {processor.errorMessage && ( +
+
Error
+
{processor.errorMessage}
+
+ )} + + {/* Exchange data */} + {snapshot && } + + {/* Actions (future) */} +
+ + +
+
+ ); +} diff --git a/ui/src/pages/routes/diagram/RouteDiagramSvg.tsx b/ui/src/pages/routes/diagram/RouteDiagramSvg.tsx new file mode 100644 index 00000000..f6fd02fc --- /dev/null +++ b/ui/src/pages/routes/diagram/RouteDiagramSvg.tsx @@ -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 ( + + + + {/* Compound container nodes (background) */} + {compoundNodes.map((node) => ( + + ))} + + {/* Edges */} + + + {/* Flow particles */} + + + {/* Leaf nodes (on top of edges) */} + {leafNodes.map((node) => { + const nodeId = node.id ?? ''; + return ( + + ); + })} + + ); +} diff --git a/ui/src/pages/routes/diagram/SvgDefs.tsx b/ui/src/pages/routes/diagram/SvgDefs.tsx new file mode 100644 index 00000000..e2363bfd --- /dev/null +++ b/ui/src/pages/routes/diagram/SvgDefs.tsx @@ -0,0 +1,64 @@ +/** SVG definitions: arrow markers, glow filters, gradient fills */ +export function SvgDefs() { + return ( + + {/* Arrow marker for edges */} + + + + + + + + + + + {/* Glow filters */} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {/* Flow particle gradient */} + + + + + + ); +} diff --git a/ui/src/pages/routes/diagram/diagram.module.css b/ui/src/pages/routes/diagram/diagram.module.css new file mode 100644 index 00000000..b0045bfe --- /dev/null +++ b/ui/src/pages/routes/diagram/diagram.module.css @@ -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; + } +} diff --git a/ui/src/pages/routes/diagram/nodeStyles.ts b/ui/src/pages/routes/diagram/nodeStyles.ts new file mode 100644 index 00000000..ebd6dcb6 --- /dev/null +++ b/ui/src/pages/routes/diagram/nodeStyles.ts @@ -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()); +} diff --git a/ui/src/router.tsx b/ui/src/router.tsx index 269aa499..faeeeb80 100644 --- a/ui/src/router.tsx +++ b/ui/src/router.tsx @@ -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: }, { path: 'executions', element: }, + { path: 'apps/:group/routes/:routeId', element: }, { path: 'admin/oidc', element: }, ], },