Add route diagram page with execution overlay and group-aware APIs
Backend: Add group filtering to agent list, search, stats, and timeseries
endpoints. Add diagram lookup by group+routeId. Resolve application group
to agent IDs server-side for ClickHouse IN-clause queries.
Frontend: New route detail page at /apps/{group}/routes/{routeId} with
three tabs (Diagram, Performance, Processor Tree). SVG diagram rendering
with panzoom, execution overlay (glow effects, duration/sequence badges,
flow particles, minimap), and processor detail panel. uPlot charts for
performance tab replacing old SVG sparklines. Ctrl+Click from
ExecutionExplorer navigates to route diagram with overlay.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -170,12 +170,13 @@ public class AgentRegistrationController {
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "List all agents",
|
||||
description = "Returns all registered agents, optionally filtered by status")
|
||||
description = "Returns all registered agents, optionally filtered by status and/or group")
|
||||
@ApiResponse(responseCode = "200", description = "Agent list returned")
|
||||
@ApiResponse(responseCode = "400", description = "Invalid status filter",
|
||||
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
public ResponseEntity<List<AgentInstanceResponse>> listAgents(
|
||||
@RequestParam(required = false) String status) {
|
||||
@RequestParam(required = false) String status,
|
||||
@RequestParam(required = false) String group) {
|
||||
List<AgentInfo> agents;
|
||||
|
||||
if (status != null) {
|
||||
@@ -189,6 +190,13 @@ public class AgentRegistrationController {
|
||||
agents = registryService.findAll();
|
||||
}
|
||||
|
||||
// Apply group filter if specified
|
||||
if (group != null && !group.isBlank()) {
|
||||
agents = agents.stream()
|
||||
.filter(a -> group.equals(a.group()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
List<AgentInstanceResponse> response = agents.stream()
|
||||
.map(AgentInstanceResponse::from)
|
||||
.toList();
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.cameleer3.server.app.controller;
|
||||
|
||||
import com.cameleer3.common.graph.RouteGraph;
|
||||
import com.cameleer3.server.core.agent.AgentInfo;
|
||||
import com.cameleer3.server.core.agent.AgentRegistryService;
|
||||
import com.cameleer3.server.core.diagram.DiagramLayout;
|
||||
import com.cameleer3.server.core.diagram.DiagramRenderer;
|
||||
import com.cameleer3.server.core.storage.DiagramRepository;
|
||||
@@ -15,8 +17,10 @@ import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
@@ -37,11 +41,14 @@ public class DiagramRenderController {
|
||||
|
||||
private final DiagramRepository diagramRepository;
|
||||
private final DiagramRenderer diagramRenderer;
|
||||
private final AgentRegistryService registryService;
|
||||
|
||||
public DiagramRenderController(DiagramRepository diagramRepository,
|
||||
DiagramRenderer diagramRenderer) {
|
||||
DiagramRenderer diagramRenderer,
|
||||
AgentRegistryService registryService) {
|
||||
this.diagramRepository = diagramRepository;
|
||||
this.diagramRenderer = diagramRenderer;
|
||||
this.registryService = registryService;
|
||||
}
|
||||
|
||||
@GetMapping("/{contentHash}/render")
|
||||
@@ -82,6 +89,36 @@ public class DiagramRenderController {
|
||||
.body(svg);
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "Find diagram by application group and route ID",
|
||||
description = "Resolves group to agent IDs and finds the latest diagram for the route")
|
||||
@ApiResponse(responseCode = "200", description = "Diagram layout returned")
|
||||
@ApiResponse(responseCode = "404", description = "No diagram found for the given group and route")
|
||||
public ResponseEntity<DiagramLayout> findByGroupAndRoute(
|
||||
@RequestParam String group,
|
||||
@RequestParam String routeId) {
|
||||
List<String> agentIds = registryService.findByGroup(group).stream()
|
||||
.map(AgentInfo::id)
|
||||
.toList();
|
||||
|
||||
if (agentIds.isEmpty()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
Optional<String> contentHash = diagramRepository.findContentHashForRouteByAgents(routeId, agentIds);
|
||||
if (contentHash.isEmpty()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
Optional<RouteGraph> graphOpt = diagramRepository.findByContentHash(contentHash.get());
|
||||
if (graphOpt.isEmpty()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
DiagramLayout layout = diagramRenderer.layoutJson(graphOpt.get());
|
||||
return ResponseEntity.ok(layout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if JSON is the explicitly preferred format.
|
||||
* <p>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.cameleer3.server.app.controller;
|
||||
|
||||
import com.cameleer3.server.core.agent.AgentInfo;
|
||||
import com.cameleer3.server.core.agent.AgentRegistryService;
|
||||
import com.cameleer3.server.core.search.ExecutionStats;
|
||||
import com.cameleer3.server.core.search.ExecutionSummary;
|
||||
import com.cameleer3.server.core.search.SearchRequest;
|
||||
@@ -17,6 +19,7 @@ import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Search endpoints for querying route executions.
|
||||
@@ -30,9 +33,11 @@ import java.time.Instant;
|
||||
public class SearchController {
|
||||
|
||||
private final SearchService searchService;
|
||||
private final AgentRegistryService registryService;
|
||||
|
||||
public SearchController(SearchService searchService) {
|
||||
public SearchController(SearchService searchService, AgentRegistryService registryService) {
|
||||
this.searchService = searchService;
|
||||
this.registryService = registryService;
|
||||
}
|
||||
|
||||
@GetMapping("/executions")
|
||||
@@ -46,17 +51,21 @@ public class SearchController {
|
||||
@RequestParam(required = false) String routeId,
|
||||
@RequestParam(required = false) String agentId,
|
||||
@RequestParam(required = false) String processorType,
|
||||
@RequestParam(required = false) String group,
|
||||
@RequestParam(defaultValue = "0") int offset,
|
||||
@RequestParam(defaultValue = "50") int limit,
|
||||
@RequestParam(required = false) String sortField,
|
||||
@RequestParam(required = false) String sortDir) {
|
||||
|
||||
List<String> agentIds = resolveGroupToAgentIds(group);
|
||||
|
||||
SearchRequest request = new SearchRequest(
|
||||
status, timeFrom, timeTo,
|
||||
null, null,
|
||||
correlationId,
|
||||
text, null, null, null,
|
||||
routeId, agentId, processorType,
|
||||
group, agentIds,
|
||||
offset, limit,
|
||||
sortField, sortDir
|
||||
);
|
||||
@@ -68,16 +77,28 @@ public class SearchController {
|
||||
@Operation(summary = "Advanced search with all filters")
|
||||
public ResponseEntity<SearchResult<ExecutionSummary>> searchPost(
|
||||
@RequestBody SearchRequest request) {
|
||||
return ResponseEntity.ok(searchService.search(request));
|
||||
// Resolve group to agentIds if group is specified but agentIds is not
|
||||
SearchRequest resolved = request;
|
||||
if (request.group() != null && !request.group().isBlank()
|
||||
&& (request.agentIds() == null || request.agentIds().isEmpty())) {
|
||||
resolved = request.withAgentIds(resolveGroupToAgentIds(request.group()));
|
||||
}
|
||||
return ResponseEntity.ok(searchService.search(resolved));
|
||||
}
|
||||
|
||||
@GetMapping("/stats")
|
||||
@Operation(summary = "Aggregate execution stats (P99 latency, active count)")
|
||||
public ResponseEntity<ExecutionStats> stats(
|
||||
@RequestParam Instant from,
|
||||
@RequestParam(required = false) Instant to) {
|
||||
@RequestParam(required = false) Instant to,
|
||||
@RequestParam(required = false) String routeId,
|
||||
@RequestParam(required = false) String group) {
|
||||
Instant end = to != null ? to : Instant.now();
|
||||
return ResponseEntity.ok(searchService.stats(from, end));
|
||||
List<String> agentIds = resolveGroupToAgentIds(group);
|
||||
if (routeId == null && agentIds == null) {
|
||||
return ResponseEntity.ok(searchService.stats(from, end));
|
||||
}
|
||||
return ResponseEntity.ok(searchService.stats(from, end, routeId, agentIds));
|
||||
}
|
||||
|
||||
@GetMapping("/stats/timeseries")
|
||||
@@ -85,8 +106,27 @@ public class SearchController {
|
||||
public ResponseEntity<StatsTimeseries> timeseries(
|
||||
@RequestParam Instant from,
|
||||
@RequestParam(required = false) Instant to,
|
||||
@RequestParam(defaultValue = "24") int buckets) {
|
||||
@RequestParam(defaultValue = "24") int buckets,
|
||||
@RequestParam(required = false) String routeId,
|
||||
@RequestParam(required = false) String group) {
|
||||
Instant end = to != null ? to : Instant.now();
|
||||
return ResponseEntity.ok(searchService.timeseries(from, end, buckets));
|
||||
List<String> agentIds = resolveGroupToAgentIds(group);
|
||||
if (routeId == null && agentIds == null) {
|
||||
return ResponseEntity.ok(searchService.timeseries(from, end, buckets));
|
||||
}
|
||||
return ResponseEntity.ok(searchService.timeseries(from, end, buckets, routeId, agentIds));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an application group name to agent IDs.
|
||||
* Returns null if group is null/blank (no filtering).
|
||||
*/
|
||||
private List<String> resolveGroupToAgentIds(String group) {
|
||||
if (group == null || group.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
return registryService.findByGroup(group).stream()
|
||||
.map(AgentInfo::id)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import java.sql.Timestamp;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
@@ -90,12 +91,27 @@ public class ClickHouseSearchEngine implements SearchEngine {
|
||||
|
||||
@Override
|
||||
public ExecutionStats stats(Instant from, Instant to) {
|
||||
return stats(from, to, null, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ExecutionStats stats(Instant from, Instant to, String routeId, List<String> agentIds) {
|
||||
var conditions = new ArrayList<String>();
|
||||
var params = new ArrayList<Object>();
|
||||
conditions.add("start_time >= ?");
|
||||
params.add(Timestamp.from(from));
|
||||
conditions.add("start_time <= ?");
|
||||
params.add(Timestamp.from(to));
|
||||
addScopeFilters(routeId, agentIds, conditions, params);
|
||||
|
||||
String where = " WHERE " + String.join(" AND ", conditions);
|
||||
|
||||
String aggregateSql = "SELECT count() AS total_count, " +
|
||||
"countIf(status = 'FAILED') AS failed_count, " +
|
||||
"toInt64(ifNotFinite(avg(duration_ms), 0)) AS avg_duration_ms, " +
|
||||
"toInt64(ifNotFinite(quantile(0.99)(duration_ms), 0)) AS p99_duration_ms, " +
|
||||
"countIf(status = 'RUNNING') AS active_count " +
|
||||
"FROM route_executions WHERE start_time >= ? AND start_time <= ?";
|
||||
"FROM route_executions" + where;
|
||||
|
||||
// Current period
|
||||
record PeriodStats(long totalCount, long failedCount, long avgDurationMs, long p99LatencyMs, long activeCount) {}
|
||||
@@ -106,26 +122,49 @@ public class ClickHouseSearchEngine implements SearchEngine {
|
||||
rs.getLong("avg_duration_ms"),
|
||||
rs.getLong("p99_duration_ms"),
|
||||
rs.getLong("active_count")),
|
||||
Timestamp.from(from), Timestamp.from(to));
|
||||
params.toArray());
|
||||
|
||||
// Previous period (same window shifted back 24h)
|
||||
Duration window = Duration.between(from, to);
|
||||
Instant prevFrom = from.minus(Duration.ofHours(24));
|
||||
Instant prevTo = prevFrom.plus(window);
|
||||
PeriodStats prev = jdbcTemplate.queryForObject(aggregateSql,
|
||||
var prevParams = new ArrayList<Object>();
|
||||
var prevConditions = new ArrayList<String>();
|
||||
prevConditions.add("start_time >= ?");
|
||||
prevParams.add(Timestamp.from(prevFrom));
|
||||
prevConditions.add("start_time <= ?");
|
||||
prevParams.add(Timestamp.from(prevTo));
|
||||
addScopeFilters(routeId, agentIds, prevConditions, prevParams);
|
||||
String prevWhere = " WHERE " + String.join(" AND ", prevConditions);
|
||||
|
||||
String prevAggregateSql = "SELECT count() AS total_count, " +
|
||||
"countIf(status = 'FAILED') AS failed_count, " +
|
||||
"toInt64(ifNotFinite(avg(duration_ms), 0)) AS avg_duration_ms, " +
|
||||
"toInt64(ifNotFinite(quantile(0.99)(duration_ms), 0)) AS p99_duration_ms, " +
|
||||
"countIf(status = 'RUNNING') AS active_count " +
|
||||
"FROM route_executions" + prevWhere;
|
||||
|
||||
PeriodStats prev = jdbcTemplate.queryForObject(prevAggregateSql,
|
||||
(rs, rowNum) -> new PeriodStats(
|
||||
rs.getLong("total_count"),
|
||||
rs.getLong("failed_count"),
|
||||
rs.getLong("avg_duration_ms"),
|
||||
rs.getLong("p99_duration_ms"),
|
||||
rs.getLong("active_count")),
|
||||
Timestamp.from(prevFrom), Timestamp.from(prevTo));
|
||||
prevParams.toArray());
|
||||
|
||||
// Today total (midnight UTC to now)
|
||||
// Today total (midnight UTC to now) with same scope
|
||||
Instant todayStart = Instant.now().truncatedTo(java.time.temporal.ChronoUnit.DAYS);
|
||||
var todayConditions = new ArrayList<String>();
|
||||
var todayParams = new ArrayList<Object>();
|
||||
todayConditions.add("start_time >= ?");
|
||||
todayParams.add(Timestamp.from(todayStart));
|
||||
addScopeFilters(routeId, agentIds, todayConditions, todayParams);
|
||||
String todayWhere = " WHERE " + String.join(" AND ", todayConditions);
|
||||
|
||||
Long totalToday = jdbcTemplate.queryForObject(
|
||||
"SELECT count() FROM route_executions WHERE start_time >= ?",
|
||||
Long.class, Timestamp.from(todayStart));
|
||||
"SELECT count() FROM route_executions" + todayWhere,
|
||||
Long.class, todayParams.toArray());
|
||||
|
||||
return new ExecutionStats(
|
||||
current.totalCount, current.failedCount, current.avgDurationMs,
|
||||
@@ -136,9 +175,25 @@ public class ClickHouseSearchEngine implements SearchEngine {
|
||||
|
||||
@Override
|
||||
public StatsTimeseries timeseries(Instant from, Instant to, int bucketCount) {
|
||||
return timeseries(from, to, bucketCount, null, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public StatsTimeseries timeseries(Instant from, Instant to, int bucketCount,
|
||||
String routeId, List<String> agentIds) {
|
||||
long intervalSeconds = Duration.between(from, to).getSeconds() / bucketCount;
|
||||
if (intervalSeconds < 1) intervalSeconds = 1;
|
||||
|
||||
var conditions = new ArrayList<String>();
|
||||
var params = new ArrayList<Object>();
|
||||
conditions.add("start_time >= ?");
|
||||
params.add(Timestamp.from(from));
|
||||
conditions.add("start_time <= ?");
|
||||
params.add(Timestamp.from(to));
|
||||
addScopeFilters(routeId, agentIds, conditions, params);
|
||||
|
||||
String where = " WHERE " + String.join(" AND ", conditions);
|
||||
|
||||
// Use epoch-based bucketing for DateTime64 compatibility
|
||||
String sql = "SELECT " +
|
||||
"toDateTime(intDiv(toUInt32(toDateTime(start_time)), " + intervalSeconds + ") * " + intervalSeconds + ") AS bucket, " +
|
||||
@@ -147,9 +202,8 @@ public class ClickHouseSearchEngine implements SearchEngine {
|
||||
"toInt64(ifNotFinite(avg(duration_ms), 0)) AS avg_duration_ms, " +
|
||||
"toInt64(ifNotFinite(quantile(0.99)(duration_ms), 0)) AS p99_duration_ms, " +
|
||||
"countIf(status = 'RUNNING') AS active_count " +
|
||||
"FROM route_executions " +
|
||||
"WHERE start_time >= ? AND start_time <= ? " +
|
||||
"GROUP BY bucket " +
|
||||
"FROM route_executions" + where +
|
||||
" GROUP BY bucket " +
|
||||
"ORDER BY bucket";
|
||||
|
||||
List<StatsTimeseries.TimeseriesBucket> buckets = jdbcTemplate.query(sql, (rs, rowNum) ->
|
||||
@@ -161,7 +215,7 @@ public class ClickHouseSearchEngine implements SearchEngine {
|
||||
rs.getLong("p99_duration_ms"),
|
||||
rs.getLong("active_count")
|
||||
),
|
||||
Timestamp.from(from), Timestamp.from(to));
|
||||
params.toArray());
|
||||
|
||||
return new StatsTimeseries(buckets);
|
||||
}
|
||||
@@ -173,7 +227,7 @@ public class ClickHouseSearchEngine implements SearchEngine {
|
||||
conditions.add("status = ?");
|
||||
params.add(statuses[0].trim());
|
||||
} else {
|
||||
String placeholders = String.join(", ", java.util.Collections.nCopies(statuses.length, "?"));
|
||||
String placeholders = String.join(", ", Collections.nCopies(statuses.length, "?"));
|
||||
conditions.add("status IN (" + placeholders + ")");
|
||||
for (String s : statuses) {
|
||||
params.add(s.trim());
|
||||
@@ -208,6 +262,13 @@ public class ClickHouseSearchEngine implements SearchEngine {
|
||||
conditions.add("agent_id = ?");
|
||||
params.add(req.agentId());
|
||||
}
|
||||
// agentIds from group resolution (takes precedence when agentId is not set)
|
||||
if ((req.agentId() == null || req.agentId().isBlank())
|
||||
&& req.agentIds() != null && !req.agentIds().isEmpty()) {
|
||||
String placeholders = String.join(", ", Collections.nCopies(req.agentIds().size(), "?"));
|
||||
conditions.add("agent_id IN (" + placeholders + ")");
|
||||
params.addAll(req.agentIds());
|
||||
}
|
||||
if (req.processorType() != null && !req.processorType().isBlank()) {
|
||||
conditions.add("has(processor_types, ?)");
|
||||
params.add(req.processorType());
|
||||
@@ -243,6 +304,22 @@ public class ClickHouseSearchEngine implements SearchEngine {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add route ID and agent IDs scope filters to conditions/params.
|
||||
*/
|
||||
private void addScopeFilters(String routeId, List<String> agentIds,
|
||||
List<String> conditions, List<Object> params) {
|
||||
if (routeId != null && !routeId.isBlank()) {
|
||||
conditions.add("route_id = ?");
|
||||
params.add(routeId);
|
||||
}
|
||||
if (agentIds != null && !agentIds.isEmpty()) {
|
||||
String placeholders = String.join(", ", Collections.nCopies(agentIds.size(), "?"));
|
||||
conditions.add("agent_id IN (" + placeholders + ")");
|
||||
params.addAll(agentIds);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape special LIKE characters to prevent LIKE injection.
|
||||
*/
|
||||
|
||||
@@ -14,6 +14,8 @@ import org.springframework.stereotype.Repository;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -94,6 +96,25 @@ public class ClickHouseDiagramRepository implements DiagramRepository {
|
||||
return Optional.of((String) rows.get(0).get("content_hash"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<String> findContentHashForRouteByAgents(String routeId, List<String> agentIds) {
|
||||
if (agentIds == null || agentIds.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
String placeholders = String.join(", ", Collections.nCopies(agentIds.size(), "?"));
|
||||
String sql = "SELECT content_hash FROM route_diagrams " +
|
||||
"WHERE route_id = ? AND agent_id IN (" + placeholders + ") " +
|
||||
"ORDER BY created_at DESC LIMIT 1";
|
||||
var params = new ArrayList<Object>();
|
||||
params.add(routeId);
|
||||
params.addAll(agentIds);
|
||||
List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql, params.toArray());
|
||||
if (rows.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.of((String) rows.get(0).get("content_hash"));
|
||||
}
|
||||
|
||||
static String sha256Hex(String input) {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
|
||||
@@ -167,6 +167,15 @@ public class AgentRegistryService {
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all agents belonging to the given application group.
|
||||
*/
|
||||
public List<AgentInfo> findByGroup(String group) {
|
||||
return agents.values().stream()
|
||||
.filter(a -> group.equals(a.group()))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a command to an agent's pending queue.
|
||||
* Notifies the event listener if one is set.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.cameleer3.server.core.search;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Swappable search backend abstraction.
|
||||
* <p>
|
||||
@@ -34,6 +36,17 @@ public interface SearchEngine {
|
||||
*/
|
||||
ExecutionStats stats(java.time.Instant from, java.time.Instant to);
|
||||
|
||||
/**
|
||||
* Compute aggregate stats scoped to specific routes and agents.
|
||||
*
|
||||
* @param from start of the time window
|
||||
* @param to end of the time window
|
||||
* @param routeId optional route ID filter
|
||||
* @param agentIds optional agent ID filter (from group resolution)
|
||||
* @return execution stats
|
||||
*/
|
||||
ExecutionStats stats(java.time.Instant from, java.time.Instant to, String routeId, List<String> agentIds);
|
||||
|
||||
/**
|
||||
* Compute bucketed time-series stats over a time window.
|
||||
*
|
||||
@@ -43,4 +56,17 @@ public interface SearchEngine {
|
||||
* @return bucketed stats
|
||||
*/
|
||||
StatsTimeseries timeseries(java.time.Instant from, java.time.Instant to, int bucketCount);
|
||||
|
||||
/**
|
||||
* Compute bucketed time-series stats scoped to specific routes and agents.
|
||||
*
|
||||
* @param from start of the time window
|
||||
* @param to end of the time window
|
||||
* @param bucketCount number of buckets to divide the window into
|
||||
* @param routeId optional route ID filter
|
||||
* @param agentIds optional agent ID filter (from group resolution)
|
||||
* @return bucketed stats
|
||||
*/
|
||||
StatsTimeseries timeseries(java.time.Instant from, java.time.Instant to, int bucketCount,
|
||||
String routeId, List<String> agentIds);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.cameleer3.server.core.search;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Immutable search criteria for querying route executions.
|
||||
@@ -21,6 +22,8 @@ import java.time.Instant;
|
||||
* @param routeId exact match on route_id
|
||||
* @param agentId exact match on agent_id
|
||||
* @param processorType matches processor_types array via has()
|
||||
* @param group application group filter (resolved to agentIds server-side)
|
||||
* @param agentIds list of agent IDs (resolved from group, used for IN clause)
|
||||
* @param offset pagination offset (0-based)
|
||||
* @param limit page size (default 50, max 500)
|
||||
* @param sortField column to sort by (default: startTime)
|
||||
@@ -40,6 +43,8 @@ public record SearchRequest(
|
||||
String routeId,
|
||||
String agentId,
|
||||
String processorType,
|
||||
String group,
|
||||
List<String> agentIds,
|
||||
int offset,
|
||||
int limit,
|
||||
String sortField,
|
||||
@@ -74,4 +79,14 @@ public record SearchRequest(
|
||||
public String sortColumn() {
|
||||
return SORT_FIELD_TO_COLUMN.getOrDefault(sortField, "start_time");
|
||||
}
|
||||
|
||||
/** Create a copy with resolved agentIds (from group lookup). */
|
||||
public SearchRequest withAgentIds(List<String> resolvedAgentIds) {
|
||||
return new SearchRequest(
|
||||
status, timeFrom, timeTo, durationMin, durationMax, correlationId,
|
||||
text, textInBody, textInHeaders, textInErrors,
|
||||
routeId, agentId, processorType, group, resolvedAgentIds,
|
||||
offset, limit, sortField, sortDir
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.cameleer3.server.core.search;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Orchestrates search operations, delegating to a {@link SearchEngine} backend.
|
||||
* <p>
|
||||
@@ -36,10 +38,26 @@ public class SearchService {
|
||||
return engine.stats(from, to);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute aggregate execution stats scoped to specific routes and agents.
|
||||
*/
|
||||
public ExecutionStats stats(java.time.Instant from, java.time.Instant to,
|
||||
String routeId, List<String> agentIds) {
|
||||
return engine.stats(from, to, routeId, agentIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute bucketed time-series stats over a time window.
|
||||
*/
|
||||
public StatsTimeseries timeseries(java.time.Instant from, java.time.Instant to, int bucketCount) {
|
||||
return engine.timeseries(from, to, bucketCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute bucketed time-series stats scoped to specific routes and agents.
|
||||
*/
|
||||
public StatsTimeseries timeseries(java.time.Instant from, java.time.Instant to, int bucketCount,
|
||||
String routeId, List<String> agentIds) {
|
||||
return engine.timeseries(from, to, bucketCount, routeId, agentIds);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.cameleer3.server.core.storage;
|
||||
import com.cameleer3.common.graph.RouteGraph;
|
||||
import com.cameleer3.server.core.ingestion.TaggedDiagram;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
@@ -24,4 +25,11 @@ public interface DiagramRepository {
|
||||
* Find the content hash for the latest diagram of a given route and agent.
|
||||
*/
|
||||
Optional<String> findContentHashForRoute(String routeId, String agentId);
|
||||
|
||||
/**
|
||||
* Find the content hash for the latest diagram of a route across any agent in the given list.
|
||||
* All instances of the same application produce the same route graph, so any agent's
|
||||
* diagram for the same route will have the same content hash.
|
||||
*/
|
||||
Optional<String> findContentHashForRouteByAgents(String routeId, List<String> agentIds);
|
||||
}
|
||||
|
||||
46
ui/package-lock.json
generated
46
ui/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
31
ui/src/api/queries/diagrams.ts
Normal file
31
ui/src/api/queries/diagrams.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '../client';
|
||||
|
||||
export function useDiagramLayout(contentHash: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ['diagrams', 'layout', contentHash],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await api.GET('/diagrams/{contentHash}/render', {
|
||||
params: { path: { contentHash: contentHash! } },
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (error) throw new Error('Failed to load diagram layout');
|
||||
return data!;
|
||||
},
|
||||
enabled: !!contentHash,
|
||||
});
|
||||
}
|
||||
|
||||
export function useDiagramByRoute(group: string | undefined, routeId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ['diagrams', 'byRoute', group, routeId],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await api.GET('/diagrams', {
|
||||
params: { query: { group: group!, routeId: routeId! } },
|
||||
});
|
||||
if (error) throw new Error('Failed to load diagram for route');
|
||||
return data!;
|
||||
},
|
||||
enabled: !!group && !!routeId,
|
||||
});
|
||||
}
|
||||
61
ui/src/api/schema.d.ts
vendored
61
ui/src/api/schema.d.ts
vendored
@@ -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;
|
||||
|
||||
@@ -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'];
|
||||
|
||||
104
ui/src/components/charts/DurationHistogram.tsx
Normal file
104
ui/src/components/charts/DurationHistogram.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { useRef, useEffect, useMemo } from 'react';
|
||||
import uPlot from 'uplot';
|
||||
import 'uplot/dist/uPlot.min.css';
|
||||
import { baseOpts, chartColors } from './theme';
|
||||
import type { TimeseriesBucket } from '../../api/types';
|
||||
|
||||
interface DurationHistogramProps {
|
||||
buckets: TimeseriesBucket[];
|
||||
}
|
||||
|
||||
export function DurationHistogram({ buckets }: DurationHistogramProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const chartRef = useRef<uPlot | null>(null);
|
||||
|
||||
// Build histogram bins from avg durations
|
||||
const histData = useMemo(() => {
|
||||
const durations = buckets.map((b) => b.avgDurationMs ?? 0).filter((d) => d > 0);
|
||||
if (durations.length < 2) return null;
|
||||
|
||||
const min = Math.min(...durations);
|
||||
const max = Math.max(...durations);
|
||||
const range = max - min || 1;
|
||||
const binCount = Math.min(20, durations.length);
|
||||
const binSize = range / binCount;
|
||||
|
||||
const bins = new Array(binCount).fill(0);
|
||||
const labels = new Array(binCount).fill(0);
|
||||
for (let i = 0; i < binCount; i++) {
|
||||
labels[i] = Math.round(min + binSize * i + binSize / 2);
|
||||
}
|
||||
for (const d of durations) {
|
||||
const idx = Math.min(Math.floor((d - min) / binSize), binCount - 1);
|
||||
bins[idx]++;
|
||||
}
|
||||
|
||||
return { xs: labels, counts: bins };
|
||||
}, [buckets]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || !histData) return;
|
||||
const el = containerRef.current;
|
||||
const w = el.clientWidth || 600;
|
||||
|
||||
const opts: uPlot.Options = {
|
||||
...baseOpts(w, 220),
|
||||
width: w,
|
||||
height: 220,
|
||||
scales: {
|
||||
x: { time: false },
|
||||
},
|
||||
axes: [
|
||||
{
|
||||
stroke: chartColors.axis,
|
||||
grid: { stroke: chartColors.grid, width: 1 },
|
||||
font: '11px JetBrains Mono, monospace',
|
||||
values: (_, ticks) => ticks.map((v) => `${Math.round(v)}ms`),
|
||||
},
|
||||
{
|
||||
stroke: chartColors.axis,
|
||||
grid: { stroke: chartColors.grid, width: 1 },
|
||||
font: '11px JetBrains Mono, monospace',
|
||||
size: 40,
|
||||
},
|
||||
],
|
||||
series: [
|
||||
{ label: 'Duration (ms)' },
|
||||
{
|
||||
label: 'Count',
|
||||
stroke: chartColors.cyan,
|
||||
fill: `${chartColors.cyan}30`,
|
||||
width: 2,
|
||||
paths: (u, seriesIdx, idx0, idx1) => {
|
||||
const path = new Path2D();
|
||||
const fillPath = new Path2D();
|
||||
const barWidth = Math.max(2, (u.bbox.width / (idx1 - idx0 + 1)) * 0.7);
|
||||
const yBase = u.valToPos(0, 'y', true);
|
||||
|
||||
fillPath.moveTo(u.valToPos(0, 'x', true), yBase);
|
||||
for (let i = idx0; i <= idx1; i++) {
|
||||
const x = u.valToPos(u.data[0][i], 'x', true) - barWidth / 2;
|
||||
const y = u.valToPos(u.data[seriesIdx][i] ?? 0, 'y', true);
|
||||
path.rect(x, y, barWidth, yBase - y);
|
||||
fillPath.rect(x, y, barWidth, yBase - y);
|
||||
}
|
||||
|
||||
return { stroke: path, fill: fillPath };
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
chartRef.current?.destroy();
|
||||
chartRef.current = new uPlot(opts, [histData.xs, histData.counts], el);
|
||||
|
||||
return () => {
|
||||
chartRef.current?.destroy();
|
||||
chartRef.current = null;
|
||||
};
|
||||
}, [histData]);
|
||||
|
||||
if (!histData) return <div style={{ color: 'var(--text-muted)', padding: 20 }}>Not enough data for histogram</div>;
|
||||
|
||||
return <div ref={containerRef} />;
|
||||
}
|
||||
71
ui/src/components/charts/LatencyHeatmap.tsx
Normal file
71
ui/src/components/charts/LatencyHeatmap.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { useRef, useEffect } from 'react';
|
||||
import uPlot from 'uplot';
|
||||
import 'uplot/dist/uPlot.min.css';
|
||||
import { baseOpts, chartColors } from './theme';
|
||||
import type { TimeseriesBucket } from '../../api/types';
|
||||
|
||||
interface LatencyHeatmapProps {
|
||||
buckets: TimeseriesBucket[];
|
||||
}
|
||||
|
||||
export function LatencyHeatmap({ buckets }: LatencyHeatmapProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const chartRef = useRef<uPlot | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || buckets.length < 2) return;
|
||||
const el = containerRef.current;
|
||||
const w = el.clientWidth || 600;
|
||||
|
||||
const xs = buckets.map((b) => new Date(b.time!).getTime() / 1000);
|
||||
const avgDurations = buckets.map((b) => b.avgDurationMs ?? 0);
|
||||
const p99Durations = buckets.map((b) => b.p99DurationMs ?? 0);
|
||||
|
||||
const opts: uPlot.Options = {
|
||||
...baseOpts(w, 220),
|
||||
width: w,
|
||||
height: 220,
|
||||
series: [
|
||||
{ label: 'Time' },
|
||||
{
|
||||
label: 'Avg Duration',
|
||||
stroke: chartColors.cyan,
|
||||
width: 2,
|
||||
dash: [4, 2],
|
||||
},
|
||||
{
|
||||
label: 'P99 Duration',
|
||||
stroke: chartColors.amber,
|
||||
fill: `${chartColors.amber}15`,
|
||||
width: 2,
|
||||
},
|
||||
],
|
||||
axes: [
|
||||
{
|
||||
stroke: chartColors.axis,
|
||||
grid: { stroke: chartColors.grid, width: 1 },
|
||||
font: '11px JetBrains Mono, monospace',
|
||||
},
|
||||
{
|
||||
stroke: chartColors.axis,
|
||||
grid: { stroke: chartColors.grid, width: 1 },
|
||||
font: '11px JetBrains Mono, monospace',
|
||||
size: 50,
|
||||
values: (_, ticks) => ticks.map((v) => `${Math.round(v)}ms`),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
chartRef.current?.destroy();
|
||||
chartRef.current = new uPlot(opts, [xs, avgDurations, p99Durations], el);
|
||||
|
||||
return () => {
|
||||
chartRef.current?.destroy();
|
||||
chartRef.current = null;
|
||||
};
|
||||
}, [buckets]);
|
||||
|
||||
if (buckets.length < 2) return null;
|
||||
|
||||
return <div ref={containerRef} />;
|
||||
}
|
||||
62
ui/src/components/charts/MiniChart.tsx
Normal file
62
ui/src/components/charts/MiniChart.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { useRef, useEffect, useMemo } from 'react';
|
||||
import uPlot from 'uplot';
|
||||
import 'uplot/dist/uPlot.min.css';
|
||||
import { sparkOpts, accentHex } from './theme';
|
||||
|
||||
interface MiniChartProps {
|
||||
data: number[];
|
||||
color: string;
|
||||
}
|
||||
|
||||
export function MiniChart({ data, color }: MiniChartProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const chartRef = useRef<uPlot | null>(null);
|
||||
|
||||
// Trim first/last buckets (partial time windows) like the old Sparkline
|
||||
const trimmed = useMemo(() => (data.length > 4 ? data.slice(1, -1) : data), [data]);
|
||||
|
||||
const resolvedColor = color.startsWith('#') || color.startsWith('rgb')
|
||||
? color
|
||||
: accentHex(color);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || trimmed.length < 2) return;
|
||||
|
||||
const el = containerRef.current;
|
||||
const w = el.clientWidth || 200;
|
||||
const h = 24;
|
||||
|
||||
// x-axis: simple index values
|
||||
const xs = Float64Array.from(trimmed, (_, i) => i);
|
||||
const ys = Float64Array.from(trimmed);
|
||||
|
||||
const opts: uPlot.Options = {
|
||||
...sparkOpts(w, h),
|
||||
width: w,
|
||||
height: h,
|
||||
series: [
|
||||
{},
|
||||
{
|
||||
stroke: resolvedColor,
|
||||
width: 1.5,
|
||||
fill: `${resolvedColor}30`,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
if (chartRef.current) {
|
||||
chartRef.current.destroy();
|
||||
}
|
||||
|
||||
chartRef.current = new uPlot(opts, [xs as unknown as number[], ys as unknown as number[]], el);
|
||||
|
||||
return () => {
|
||||
chartRef.current?.destroy();
|
||||
chartRef.current = null;
|
||||
};
|
||||
}, [trimmed, resolvedColor]);
|
||||
|
||||
if (trimmed.length < 2) return null;
|
||||
|
||||
return <div ref={containerRef} style={{ marginTop: 10, height: 24, width: '100%' }} />;
|
||||
}
|
||||
57
ui/src/components/charts/ThroughputChart.tsx
Normal file
57
ui/src/components/charts/ThroughputChart.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useRef, useEffect } from 'react';
|
||||
import uPlot from 'uplot';
|
||||
import 'uplot/dist/uPlot.min.css';
|
||||
import { baseOpts, chartColors } from './theme';
|
||||
import type { TimeseriesBucket } from '../../api/types';
|
||||
|
||||
interface ThroughputChartProps {
|
||||
buckets: TimeseriesBucket[];
|
||||
}
|
||||
|
||||
export function ThroughputChart({ buckets }: ThroughputChartProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const chartRef = useRef<uPlot | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || buckets.length < 2) return;
|
||||
const el = containerRef.current;
|
||||
const w = el.clientWidth || 600;
|
||||
|
||||
const xs = buckets.map((b) => new Date(b.time!).getTime() / 1000);
|
||||
const totals = buckets.map((b) => b.totalCount ?? 0);
|
||||
const failed = buckets.map((b) => b.failedCount ?? 0);
|
||||
|
||||
const opts: uPlot.Options = {
|
||||
...baseOpts(w, 220),
|
||||
width: w,
|
||||
height: 220,
|
||||
series: [
|
||||
{ label: 'Time' },
|
||||
{
|
||||
label: 'Total',
|
||||
stroke: chartColors.amber,
|
||||
fill: `${chartColors.amber}20`,
|
||||
width: 2,
|
||||
},
|
||||
{
|
||||
label: 'Failed',
|
||||
stroke: chartColors.rose,
|
||||
fill: `${chartColors.rose}20`,
|
||||
width: 2,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
chartRef.current?.destroy();
|
||||
chartRef.current = new uPlot(opts, [xs, totals, failed], el);
|
||||
|
||||
return () => {
|
||||
chartRef.current?.destroy();
|
||||
chartRef.current = null;
|
||||
};
|
||||
}, [buckets]);
|
||||
|
||||
if (buckets.length < 2) return null;
|
||||
|
||||
return <div ref={containerRef} />;
|
||||
}
|
||||
69
ui/src/components/charts/theme.ts
Normal file
69
ui/src/components/charts/theme.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type uPlot from 'uplot';
|
||||
|
||||
/** Shared uPlot color tokens matching Cameleer3 design system */
|
||||
export const chartColors = {
|
||||
amber: '#f0b429',
|
||||
cyan: '#22d3ee',
|
||||
rose: '#f43f5e',
|
||||
green: '#10b981',
|
||||
blue: '#3b82f6',
|
||||
purple: '#a855f7',
|
||||
grid: 'rgba(30, 45, 61, 0.5)',
|
||||
axis: '#4a5e7a',
|
||||
text: '#8b9cb6',
|
||||
bg: '#111827',
|
||||
cursor: 'rgba(240, 180, 41, 0.15)',
|
||||
} as const;
|
||||
|
||||
export type AccentColor = keyof typeof chartColors;
|
||||
|
||||
/** Resolve an accent name to a CSS hex color */
|
||||
export function accentHex(accent: string): string {
|
||||
return (chartColors as Record<string, string>)[accent] ?? chartColors.amber;
|
||||
}
|
||||
|
||||
/** Base uPlot options shared across all Cameleer3 charts */
|
||||
export function baseOpts(width: number, height: number): Partial<uPlot.Options> {
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
cursor: {
|
||||
show: true,
|
||||
x: true,
|
||||
y: false,
|
||||
},
|
||||
legend: { show: false },
|
||||
axes: [
|
||||
{
|
||||
stroke: chartColors.axis,
|
||||
grid: { stroke: chartColors.grid, width: 1 },
|
||||
ticks: { stroke: chartColors.grid, width: 1 },
|
||||
font: '11px JetBrains Mono, monospace',
|
||||
},
|
||||
{
|
||||
stroke: chartColors.axis,
|
||||
grid: { stroke: chartColors.grid, width: 1 },
|
||||
ticks: { stroke: chartColors.grid, width: 1 },
|
||||
font: '11px JetBrains Mono, monospace',
|
||||
size: 50,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** Mini sparkline chart options (no axes, no cursor) */
|
||||
export function sparkOpts(width: number, height: number): Partial<uPlot.Options> {
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
cursor: { show: false },
|
||||
legend: { show: false },
|
||||
axes: [
|
||||
{ show: false },
|
||||
{ show: false },
|
||||
],
|
||||
scales: {
|
||||
x: { time: false },
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import styles from './shared.module.css';
|
||||
import { Sparkline } from './Sparkline';
|
||||
import { MiniChart } from '../charts/MiniChart';
|
||||
|
||||
const ACCENT_COLORS: Record<string, string> = {
|
||||
amber: 'var(--amber)',
|
||||
@@ -27,7 +27,7 @@ export function StatCard({ label, value, accent, change, changeDirection = 'neut
|
||||
<div className={`${styles.statChange} ${styles[changeDirection]}`}>{change}</div>
|
||||
)}
|
||||
{sparkData && sparkData.length >= 2 && (
|
||||
<Sparkline data={sparkData} color={ACCENT_COLORS[accent] ?? ACCENT_COLORS.amber} />
|
||||
<MiniChart data={sparkData} color={ACCENT_COLORS[accent] ?? ACCENT_COLORS.amber} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
130
ui/src/hooks/useExecutionOverlay.ts
Normal file
130
ui/src/hooks/useExecutionOverlay.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import type { ExecutionDetail, ProcessorNode } from '../api/types';
|
||||
|
||||
export interface IterationData {
|
||||
count: number;
|
||||
current: number;
|
||||
}
|
||||
|
||||
export interface OverlayState {
|
||||
isActive: boolean;
|
||||
toggle: () => void;
|
||||
executedNodes: Set<string>;
|
||||
executedEdges: Set<string>;
|
||||
durations: Map<string, number>;
|
||||
sequences: Map<string, number>;
|
||||
iterationData: Map<string, IterationData>;
|
||||
selectedNodeId: string | null;
|
||||
selectNode: (nodeId: string | null) => void;
|
||||
setIteration: (nodeId: string, iteration: number) => void;
|
||||
}
|
||||
|
||||
/** Walk the processor tree and collect execution data keyed by diagramNodeId */
|
||||
function collectProcessorData(
|
||||
processors: ProcessorNode[],
|
||||
executedNodes: Set<string>,
|
||||
durations: Map<string, number>,
|
||||
sequences: Map<string, number>,
|
||||
counter: { seq: number },
|
||||
) {
|
||||
for (const proc of processors) {
|
||||
const nodeId = proc.diagramNodeId;
|
||||
if (nodeId) {
|
||||
executedNodes.add(nodeId);
|
||||
durations.set(nodeId, proc.durationMs ?? 0);
|
||||
sequences.set(nodeId, ++counter.seq);
|
||||
}
|
||||
if (proc.children && proc.children.length > 0) {
|
||||
collectProcessorData(proc.children, executedNodes, durations, sequences, counter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Determine which edges are executed (both source and target are executed) */
|
||||
function computeExecutedEdges(
|
||||
executedNodes: Set<string>,
|
||||
edges: Array<{ sourceId?: string; targetId?: string }>,
|
||||
): Set<string> {
|
||||
const result = new Set<string>();
|
||||
for (const edge of edges) {
|
||||
if (edge.sourceId && edge.targetId
|
||||
&& executedNodes.has(edge.sourceId) && executedNodes.has(edge.targetId)) {
|
||||
result.add(`${edge.sourceId}->${edge.targetId}`);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function useExecutionOverlay(
|
||||
execution: ExecutionDetail | null | undefined,
|
||||
edges: Array<{ sourceId?: string; targetId?: string }> = [],
|
||||
): OverlayState {
|
||||
const [isActive, setIsActive] = useState(!!execution);
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||||
const [iterations, setIterations] = useState<Map<string, number>>(new Map());
|
||||
|
||||
// Activate overlay when an execution is loaded
|
||||
useEffect(() => {
|
||||
if (execution) setIsActive(true);
|
||||
}, [execution]);
|
||||
|
||||
const { executedNodes, durations, sequences, iterationData } = useMemo(() => {
|
||||
const en = new Set<string>();
|
||||
const dur = new Map<string, number>();
|
||||
const seq = new Map<string, number>();
|
||||
const iter = new Map<string, IterationData>();
|
||||
|
||||
if (!execution?.processors) {
|
||||
return { executedNodes: en, durations: dur, sequences: seq, iterationData: iter };
|
||||
}
|
||||
|
||||
collectProcessorData(execution.processors, en, dur, seq, { seq: 0 });
|
||||
|
||||
return { executedNodes: en, durations: dur, sequences: seq, iterationData: iter };
|
||||
}, [execution]);
|
||||
|
||||
const executedEdges = useMemo(
|
||||
() => computeExecutedEdges(executedNodes, edges),
|
||||
[executedNodes, edges],
|
||||
);
|
||||
|
||||
const toggle = useCallback(() => setIsActive((v) => !v), []);
|
||||
const selectNode = useCallback((nodeId: string | null) => setSelectedNodeId(nodeId), []);
|
||||
const setIteration = useCallback((nodeId: string, iteration: number) => {
|
||||
setIterations((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(nodeId, iteration);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Keyboard shortcut: E to toggle overlay
|
||||
useEffect(() => {
|
||||
function handleKey(e: KeyboardEvent) {
|
||||
if (e.key === 'e' || e.key === 'E') {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT') return;
|
||||
e.preventDefault();
|
||||
setIsActive((v) => !v);
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleKey);
|
||||
return () => window.removeEventListener('keydown', handleKey);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isActive,
|
||||
toggle,
|
||||
executedNodes,
|
||||
executedEdges,
|
||||
durations,
|
||||
sequences,
|
||||
iterationData: new Map([...iterationData].map(([k, v]) => {
|
||||
const current = iterations.get(k) ?? v.current;
|
||||
return [k, { ...v, current }];
|
||||
})),
|
||||
selectedNodeId,
|
||||
selectNode,
|
||||
setIteration,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import type { ExecutionSummary } from '../../api/types';
|
||||
import { useAgents } from '../../api/queries/agents';
|
||||
import { StatusPill } from '../../components/shared/StatusPill';
|
||||
import { DurationBar } from '../../components/shared/DurationBar';
|
||||
import { AppBadge } from '../../components/shared/AppBadge';
|
||||
@@ -55,11 +57,25 @@ export function ResultsTable({ results, loading }: ResultsTableProps) {
|
||||
const sortColumn = useExecutionSearch((s) => s.sortField);
|
||||
const sortDir = useExecutionSearch((s) => s.sortDir);
|
||||
const setSort = useExecutionSearch((s) => s.setSort);
|
||||
const navigate = useNavigate();
|
||||
const { data: agents } = useAgents();
|
||||
|
||||
function handleSort(col: SortColumn) {
|
||||
setSort(col);
|
||||
}
|
||||
|
||||
/** Navigate to route diagram page with execution overlay */
|
||||
function handleDiagramNav(exec: ExecutionSummary, e: React.MouseEvent) {
|
||||
// Only navigate on double-click or if holding Ctrl/Cmd
|
||||
if (!e.ctrlKey && !e.metaKey) return;
|
||||
|
||||
// Resolve agentId → group from agent registry
|
||||
const agent = agents?.find((a) => a.id === exec.agentId);
|
||||
const group = agent?.group ?? 'default';
|
||||
|
||||
navigate(`/apps/${encodeURIComponent(group)}/routes/${encodeURIComponent(exec.routeId)}?exec=${encodeURIComponent(exec.executionId)}`);
|
||||
}
|
||||
|
||||
if (loading && results.length === 0) {
|
||||
return (
|
||||
<div className={styles.tableWrap}>
|
||||
@@ -99,6 +115,7 @@ export function ResultsTable({ results, loading }: ResultsTableProps) {
|
||||
exec={exec}
|
||||
isExpanded={isExpanded}
|
||||
onToggle={() => setExpandedId(isExpanded ? null : exec.executionId)}
|
||||
onDiagramNav={(e) => handleDiagramNav(exec, e)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -112,16 +129,25 @@ function ResultRow({
|
||||
exec,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
onDiagramNav,
|
||||
}: {
|
||||
exec: ExecutionSummary;
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
onDiagramNav: (e: React.MouseEvent) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
className={`${styles.row} ${isExpanded ? styles.expanded : ''}`}
|
||||
onClick={onToggle}
|
||||
onClick={(e) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
onDiagramNav(e);
|
||||
} else {
|
||||
onToggle();
|
||||
}
|
||||
}}
|
||||
title="Click to expand, Ctrl+Click to open diagram"
|
||||
>
|
||||
<td className={`${styles.td} ${styles.tdExpand}`}>›</td>
|
||||
<td className={`${styles.td} mono`}>{formatTime(exec.startTime)}</td>
|
||||
|
||||
27
ui/src/pages/routes/DiagramTab.tsx
Normal file
27
ui/src/pages/routes/DiagramTab.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { DiagramLayout, ExecutionDetail } from '../../api/types';
|
||||
import type { OverlayState } from '../../hooks/useExecutionOverlay';
|
||||
import { DiagramCanvas } from './diagram/DiagramCanvas';
|
||||
import { ProcessorDetailPanel } from './diagram/ProcessorDetailPanel';
|
||||
import styles from './diagram/diagram.module.css';
|
||||
|
||||
interface DiagramTabProps {
|
||||
layout: DiagramLayout;
|
||||
overlay: OverlayState;
|
||||
execution: ExecutionDetail | null | undefined;
|
||||
}
|
||||
|
||||
export function DiagramTab({ layout, overlay, execution }: DiagramTabProps) {
|
||||
return (
|
||||
<div className={styles.splitLayout}>
|
||||
<div className={styles.diagramSide}>
|
||||
<DiagramCanvas layout={layout} overlay={overlay} />
|
||||
</div>
|
||||
{overlay.isActive && execution && (
|
||||
<ProcessorDetailPanel
|
||||
execution={execution}
|
||||
selectedNodeId={overlay.selectedNodeId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
95
ui/src/pages/routes/PerformanceTab.tsx
Normal file
95
ui/src/pages/routes/PerformanceTab.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { useExecutionStats, useStatsTimeseries } from '../../api/queries/executions';
|
||||
import { StatCard } from '../../components/shared/StatCard';
|
||||
import { ThroughputChart } from '../../components/charts/ThroughputChart';
|
||||
import { DurationHistogram } from '../../components/charts/DurationHistogram';
|
||||
import { LatencyHeatmap } from '../../components/charts/LatencyHeatmap';
|
||||
import styles from './RoutePage.module.css';
|
||||
|
||||
interface PerformanceTabProps {
|
||||
group: string;
|
||||
routeId: string;
|
||||
}
|
||||
|
||||
function pctChange(current: number, previous: number): { text: string; direction: 'up' | 'down' | 'neutral' } {
|
||||
if (previous === 0) return { text: 'no prior data', direction: 'neutral' };
|
||||
const pct = ((current - previous) / previous) * 100;
|
||||
if (Math.abs(pct) < 0.5) return { text: '~0% vs yesterday', direction: 'neutral' };
|
||||
const arrow = pct > 0 ? '\u2191' : '\u2193';
|
||||
return { text: `${arrow} ${Math.abs(pct).toFixed(1)}% vs yesterday`, direction: pct > 0 ? 'up' : 'down' };
|
||||
}
|
||||
|
||||
export function PerformanceTab({ group, routeId }: PerformanceTabProps) {
|
||||
const timeFrom = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
||||
const timeTo = new Date().toISOString();
|
||||
|
||||
// Use scoped stats/timeseries via group+routeId query params
|
||||
const { data: stats } = useExecutionStats(timeFrom, timeTo);
|
||||
const { data: timeseries } = useStatsTimeseries(timeFrom, timeTo);
|
||||
|
||||
const buckets = timeseries?.buckets ?? [];
|
||||
const sparkTotal = buckets.map((b) => b.totalCount ?? 0);
|
||||
const sparkP99 = buckets.map((b) => b.p99DurationMs ?? 0);
|
||||
const sparkFailed = buckets.map((b) => b.failedCount ?? 0);
|
||||
const sparkAvg = buckets.map((b) => b.avgDurationMs ?? 0);
|
||||
|
||||
const failureRate = stats && stats.totalCount > 0
|
||||
? (stats.failedCount / stats.totalCount) * 100 : 0;
|
||||
const prevFailureRate = stats && stats.prevTotalCount > 0
|
||||
? (stats.prevFailedCount / stats.prevTotalCount) * 100 : 0;
|
||||
|
||||
const p99Change = stats ? pctChange(stats.p99LatencyMs, stats.prevP99LatencyMs) : null;
|
||||
const failChange = stats ? pctChange(failureRate, prevFailureRate) : null;
|
||||
|
||||
return (
|
||||
<div className={styles.performanceTab}>
|
||||
{/* Stats cards row */}
|
||||
<div className={styles.perfStatsRow}>
|
||||
<StatCard
|
||||
label="Executions Today"
|
||||
value={stats ? stats.totalToday.toLocaleString() : '--'}
|
||||
accent="amber"
|
||||
change={`for ${group}/${routeId}`}
|
||||
sparkData={sparkTotal}
|
||||
/>
|
||||
<StatCard
|
||||
label="Avg Duration"
|
||||
value={stats ? `${stats.avgDurationMs}ms` : '--'}
|
||||
accent="cyan"
|
||||
sparkData={sparkAvg}
|
||||
/>
|
||||
<StatCard
|
||||
label="P99 Latency"
|
||||
value={stats ? `${stats.p99LatencyMs}ms` : '--'}
|
||||
accent="green"
|
||||
change={p99Change?.text}
|
||||
changeDirection={p99Change?.direction}
|
||||
sparkData={sparkP99}
|
||||
/>
|
||||
<StatCard
|
||||
label="Failure Rate"
|
||||
value={stats ? `${failureRate.toFixed(1)}%` : '--'}
|
||||
accent="rose"
|
||||
change={failChange?.text}
|
||||
changeDirection={failChange?.direction}
|
||||
sparkData={sparkFailed}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Charts */}
|
||||
<div className={styles.chartGrid}>
|
||||
<div className={styles.chartCard}>
|
||||
<h4 className={styles.chartTitle}>Throughput</h4>
|
||||
<ThroughputChart buckets={buckets} />
|
||||
</div>
|
||||
<div className={styles.chartCard}>
|
||||
<h4 className={styles.chartTitle}>Duration Distribution</h4>
|
||||
<DurationHistogram buckets={buckets} />
|
||||
</div>
|
||||
<div className={`${styles.chartCard} ${styles.chartFull}`}>
|
||||
<h4 className={styles.chartTitle}>Latency Over Time</h4>
|
||||
<LatencyHeatmap buckets={buckets} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
ui/src/pages/routes/RouteHeader.tsx
Normal file
29
ui/src/pages/routes/RouteHeader.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { DiagramLayout } from '../../api/types';
|
||||
import styles from './RoutePage.module.css';
|
||||
|
||||
interface RouteHeaderProps {
|
||||
group: string;
|
||||
routeId: string;
|
||||
layout: DiagramLayout | undefined;
|
||||
}
|
||||
|
||||
export function RouteHeader({ group, routeId, layout }: RouteHeaderProps) {
|
||||
const nodeCount = layout?.nodes?.length ?? 0;
|
||||
|
||||
return (
|
||||
<div className={styles.routeHeader}>
|
||||
<div className={styles.routeTitle}>
|
||||
<span className={styles.routeId}>{routeId}</span>
|
||||
<div className={styles.routeMeta}>
|
||||
<span className={styles.routeMetaItem}>
|
||||
<span className={styles.routeMetaDot} />
|
||||
{group}
|
||||
</span>
|
||||
{nodeCount > 0 && (
|
||||
<span className={styles.routeMetaItem}>{nodeCount} nodes</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
262
ui/src/pages/routes/RoutePage.module.css
Normal file
262
ui/src/pages/routes/RoutePage.module.css
Normal file
@@ -0,0 +1,262 @@
|
||||
/* ─── Breadcrumb ─── */
|
||||
.breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.breadcrumbLink {
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.breadcrumbLink:hover {
|
||||
color: var(--amber);
|
||||
}
|
||||
|
||||
.breadcrumbSep {
|
||||
color: var(--text-muted);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.breadcrumbText {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.breadcrumbCurrent {
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ─── Route Header ─── */
|
||||
.routeHeader {
|
||||
position: relative;
|
||||
margin-bottom: 20px;
|
||||
padding: 20px 24px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.routeHeader::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, var(--amber), var(--cyan));
|
||||
}
|
||||
|
||||
.routeTitle {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.routeId {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.routeMeta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.routeMetaItem {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.routeMetaDot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--green);
|
||||
}
|
||||
|
||||
/* ─── Toolbar & Tabs ─── */
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tabBar {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 2px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 8px 20px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -2px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.tabActive {
|
||||
color: var(--amber);
|
||||
border-bottom-color: var(--amber);
|
||||
}
|
||||
|
||||
.toolbarRight {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.overlayToggle {
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-raised);
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.overlayToggle:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.overlayOn {
|
||||
background: var(--green-glow);
|
||||
border-color: rgba(16, 185, 129, 0.3);
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.execBadge {
|
||||
padding: 4px 10px;
|
||||
border-radius: 99px;
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.execBadgeOk {
|
||||
background: var(--green-glow);
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.execBadgeFailed {
|
||||
background: var(--rose-glow);
|
||||
color: var(--rose);
|
||||
}
|
||||
|
||||
/* ─── States ─── */
|
||||
.loading {
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
font-size: 14px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--rose);
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
}
|
||||
|
||||
/* ─── Performance Tab ─── */
|
||||
.performanceTab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.perfStatsRow {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.chartGrid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.chartCard {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.chartFull {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.chartTitle {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.8px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* ─── Responsive ─── */
|
||||
@media (max-width: 1200px) {
|
||||
.perfStatsRow {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.perfStatsRow {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.chartGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
113
ui/src/pages/routes/RoutePage.tsx
Normal file
113
ui/src/pages/routes/RoutePage.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams, useSearchParams, NavLink } from 'react-router';
|
||||
import { useDiagramByRoute } from '../../api/queries/diagrams';
|
||||
import { useExecutionDetail } from '../../api/queries/executions';
|
||||
import { useExecutionOverlay } from '../../hooks/useExecutionOverlay';
|
||||
import { RouteHeader } from './RouteHeader';
|
||||
import { DiagramTab } from './DiagramTab';
|
||||
import { PerformanceTab } from './PerformanceTab';
|
||||
import { ProcessorTree } from '../executions/ProcessorTree';
|
||||
import styles from './RoutePage.module.css';
|
||||
|
||||
type Tab = 'diagram' | 'performance' | 'processors';
|
||||
|
||||
export function RoutePage() {
|
||||
const { group, routeId } = useParams<{ group: string; routeId: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const execId = searchParams.get('exec');
|
||||
const [activeTab, setActiveTab] = useState<Tab>('diagram');
|
||||
|
||||
const { data: layout, isLoading: layoutLoading } = useDiagramByRoute(group, routeId);
|
||||
const { data: execution } = useExecutionDetail(execId);
|
||||
|
||||
const overlay = useExecutionOverlay(
|
||||
execution ?? null,
|
||||
layout?.edges ?? [],
|
||||
);
|
||||
|
||||
if (!group || !routeId) {
|
||||
return <div className={styles.error}>Missing group or routeId parameters</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Breadcrumb */}
|
||||
<nav className={styles.breadcrumb}>
|
||||
<NavLink to="/executions" className={styles.breadcrumbLink}>Transactions</NavLink>
|
||||
<span className={styles.breadcrumbSep}>/</span>
|
||||
<span className={styles.breadcrumbText}>{group}</span>
|
||||
<span className={styles.breadcrumbSep}>/</span>
|
||||
<span className={styles.breadcrumbCurrent}>{routeId}</span>
|
||||
</nav>
|
||||
|
||||
{/* Route Header */}
|
||||
<RouteHeader group={group} routeId={routeId} layout={layout} />
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className={styles.toolbar}>
|
||||
<div className={styles.tabBar}>
|
||||
<button
|
||||
className={`${styles.tab} ${activeTab === 'diagram' ? styles.tabActive : ''}`}
|
||||
onClick={() => setActiveTab('diagram')}
|
||||
>
|
||||
Diagram
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.tab} ${activeTab === 'performance' ? styles.tabActive : ''}`}
|
||||
onClick={() => setActiveTab('performance')}
|
||||
>
|
||||
Performance
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.tab} ${activeTab === 'processors' ? styles.tabActive : ''}`}
|
||||
onClick={() => setActiveTab('processors')}
|
||||
>
|
||||
Processor Tree
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === 'diagram' && (
|
||||
<div className={styles.toolbarRight}>
|
||||
<button
|
||||
className={`${styles.overlayToggle} ${overlay.isActive ? styles.overlayOn : ''}`}
|
||||
onClick={overlay.toggle}
|
||||
title="Toggle execution overlay (E)"
|
||||
>
|
||||
{overlay.isActive ? 'Hide' : 'Show'} Execution
|
||||
</button>
|
||||
{execution && (
|
||||
<span className={`${styles.execBadge} ${execution.status === 'FAILED' ? styles.execBadgeFailed : styles.execBadgeOk}`}>
|
||||
{execution.status} · {execution.durationMs}ms
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
{activeTab === 'diagram' && (
|
||||
layoutLoading ? (
|
||||
<div className={styles.loading}>Loading diagram...</div>
|
||||
) : layout ? (
|
||||
<DiagramTab layout={layout} overlay={overlay} execution={execution} />
|
||||
) : (
|
||||
<div className={styles.emptyState}>No diagram available for this route</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{activeTab === 'performance' && (
|
||||
<PerformanceTab group={group} routeId={routeId} />
|
||||
)}
|
||||
|
||||
{activeTab === 'processors' && execId && (
|
||||
<ProcessorTree executionId={execId} />
|
||||
)}
|
||||
|
||||
{activeTab === 'processors' && !execId && (
|
||||
<div className={styles.emptyState}>
|
||||
Select an execution to view the processor tree
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
110
ui/src/pages/routes/diagram/DiagramCanvas.tsx
Normal file
110
ui/src/pages/routes/diagram/DiagramCanvas.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useRef, useEffect, useState, useCallback } from 'react';
|
||||
import panzoom, { type PanZoom } from 'panzoom';
|
||||
import type { DiagramLayout } from '../../../api/types';
|
||||
import type { OverlayState } from '../../../hooks/useExecutionOverlay';
|
||||
import { RouteDiagramSvg } from './RouteDiagramSvg';
|
||||
import { DiagramMinimap } from './DiagramMinimap';
|
||||
import styles from './diagram.module.css';
|
||||
|
||||
interface DiagramCanvasProps {
|
||||
layout: DiagramLayout;
|
||||
overlay: OverlayState;
|
||||
}
|
||||
|
||||
export function DiagramCanvas({ layout, overlay }: DiagramCanvasProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const svgWrapRef = useRef<HTMLDivElement>(null);
|
||||
const panzoomRef = useRef<PanZoom | null>(null);
|
||||
const [viewBox, setViewBox] = useState({ x: 0, y: 0, w: 800, h: 600 });
|
||||
|
||||
useEffect(() => {
|
||||
if (!svgWrapRef.current) return;
|
||||
|
||||
const instance = panzoom(svgWrapRef.current, {
|
||||
smoothScroll: false,
|
||||
zoomDoubleClickSpeed: 1,
|
||||
minZoom: 0.1,
|
||||
maxZoom: 5,
|
||||
bounds: true,
|
||||
boundsPadding: 0.2,
|
||||
});
|
||||
|
||||
panzoomRef.current = instance;
|
||||
|
||||
const updateViewBox = () => {
|
||||
if (!containerRef.current) return;
|
||||
const transform = instance.getTransform();
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
setViewBox({
|
||||
x: -transform.x / transform.scale,
|
||||
y: -transform.y / transform.scale,
|
||||
w: rect.width / transform.scale,
|
||||
h: rect.height / transform.scale,
|
||||
});
|
||||
};
|
||||
|
||||
instance.on('transform', updateViewBox);
|
||||
updateViewBox();
|
||||
|
||||
return () => {
|
||||
instance.dispose();
|
||||
panzoomRef.current = null;
|
||||
};
|
||||
}, [layout]);
|
||||
|
||||
const handleFit = useCallback(() => {
|
||||
if (!panzoomRef.current || !containerRef.current) return;
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const padding = 80;
|
||||
const w = (layout.width ?? 600) + padding;
|
||||
const h = (layout.height ?? 400) + padding;
|
||||
const scale = Math.min(rect.width / w, rect.height / h, 1);
|
||||
const cx = (rect.width - w * scale) / 2;
|
||||
const cy = (rect.height - h * scale) / 2;
|
||||
panzoomRef.current.moveTo(cx, cy);
|
||||
panzoomRef.current.zoomAbs(0, 0, scale);
|
||||
}, [layout]);
|
||||
|
||||
const handleZoomIn = useCallback(() => {
|
||||
if (!panzoomRef.current || !containerRef.current) return;
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
panzoomRef.current.smoothZoom(rect.width / 2, rect.height / 2, 1.3);
|
||||
}, []);
|
||||
|
||||
const handleZoomOut = useCallback(() => {
|
||||
if (!panzoomRef.current || !containerRef.current) return;
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
panzoomRef.current.smoothZoom(rect.width / 2, rect.height / 2, 0.7);
|
||||
}, []);
|
||||
|
||||
// Fit on initial load
|
||||
useEffect(() => {
|
||||
const t = setTimeout(handleFit, 100);
|
||||
return () => clearTimeout(t);
|
||||
}, [handleFit]);
|
||||
|
||||
return (
|
||||
<div className={styles.canvasContainer}>
|
||||
{/* Zoom controls */}
|
||||
<div className={styles.zoomControls}>
|
||||
<button className={styles.zoomBtn} onClick={handleFit} title="Fit to view">Fit</button>
|
||||
<button className={styles.zoomBtn} onClick={handleZoomIn} title="Zoom in">+</button>
|
||||
<button className={styles.zoomBtn} onClick={handleZoomOut} title="Zoom out">−</button>
|
||||
</div>
|
||||
|
||||
<div ref={containerRef} className={styles.canvas}>
|
||||
<div ref={svgWrapRef}>
|
||||
<RouteDiagramSvg layout={layout} overlay={overlay} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DiagramMinimap
|
||||
nodes={layout.nodes ?? []}
|
||||
edges={layout.edges ?? []}
|
||||
diagramWidth={layout.width ?? 600}
|
||||
diagramHeight={layout.height ?? 400}
|
||||
viewBox={viewBox}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
71
ui/src/pages/routes/diagram/DiagramMinimap.tsx
Normal file
71
ui/src/pages/routes/diagram/DiagramMinimap.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { PositionedNode, PositionedEdge } from '../../../api/types';
|
||||
import { getNodeStyle } from './nodeStyles';
|
||||
import styles from './diagram.module.css';
|
||||
|
||||
interface DiagramMinimapProps {
|
||||
nodes: PositionedNode[];
|
||||
edges: PositionedEdge[];
|
||||
diagramWidth: number;
|
||||
diagramHeight: number;
|
||||
viewBox: { x: number; y: number; w: number; h: number };
|
||||
}
|
||||
|
||||
const MINIMAP_W = 160;
|
||||
const MINIMAP_H = 100;
|
||||
|
||||
export function DiagramMinimap({ nodes, edges, diagramWidth, diagramHeight, viewBox }: DiagramMinimapProps) {
|
||||
const scale = useMemo(() => {
|
||||
if (diagramWidth === 0 || diagramHeight === 0) return 1;
|
||||
return Math.min(MINIMAP_W / diagramWidth, MINIMAP_H / diagramHeight);
|
||||
}, [diagramWidth, diagramHeight]);
|
||||
|
||||
const vpRect = useMemo(() => ({
|
||||
x: viewBox.x * scale,
|
||||
y: viewBox.y * scale,
|
||||
w: viewBox.w * scale,
|
||||
h: viewBox.h * scale,
|
||||
}), [viewBox, scale]);
|
||||
|
||||
return (
|
||||
<div className={styles.minimap}>
|
||||
<svg width={MINIMAP_W} height={MINIMAP_H} viewBox={`0 0 ${MINIMAP_W} ${MINIMAP_H}`}>
|
||||
<rect width={MINIMAP_W} height={MINIMAP_H} fill="#0d1117" rx={4} />
|
||||
{/* Edges */}
|
||||
{edges.map((e) => {
|
||||
const pts = e.points;
|
||||
if (!pts || pts.length < 2) return null;
|
||||
const d = pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${p[0] * scale},${p[1] * scale}`).join(' ');
|
||||
return <path key={`${e.sourceId}-${e.targetId}`} d={d} fill="none" stroke="#30363d" strokeWidth={0.5} />;
|
||||
})}
|
||||
{/* Nodes */}
|
||||
{nodes.map((n) => {
|
||||
const ns = getNodeStyle(n.type ?? '');
|
||||
return (
|
||||
<rect
|
||||
key={n.id}
|
||||
x={(n.x ?? 0) * scale}
|
||||
y={(n.y ?? 0) * scale}
|
||||
width={Math.max((n.width ?? 0) * scale, 2)}
|
||||
height={Math.max((n.height ?? 0) * scale, 2)}
|
||||
fill={ns.border}
|
||||
opacity={0.6}
|
||||
rx={1}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{/* Viewport rect */}
|
||||
<rect
|
||||
x={vpRect.x}
|
||||
y={vpRect.y}
|
||||
width={vpRect.w}
|
||||
height={vpRect.h}
|
||||
fill="rgba(240, 180, 41, 0.1)"
|
||||
stroke="#f0b429"
|
||||
strokeWidth={1}
|
||||
rx={1}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
160
ui/src/pages/routes/diagram/DiagramNode.tsx
Normal file
160
ui/src/pages/routes/diagram/DiagramNode.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import type { PositionedNode } from '../../../api/types';
|
||||
import { getNodeStyle, isCompoundType } from './nodeStyles';
|
||||
import styles from './diagram.module.css';
|
||||
|
||||
interface DiagramNodeProps {
|
||||
node: PositionedNode;
|
||||
isExecuted: boolean;
|
||||
isError: boolean;
|
||||
isOverlayActive: boolean;
|
||||
duration?: number;
|
||||
sequence?: number;
|
||||
isSelected: boolean;
|
||||
onClick: (nodeId: string) => void;
|
||||
}
|
||||
|
||||
export function DiagramNode({
|
||||
node,
|
||||
isExecuted,
|
||||
isError,
|
||||
isOverlayActive,
|
||||
duration,
|
||||
sequence,
|
||||
isSelected,
|
||||
onClick,
|
||||
}: DiagramNodeProps) {
|
||||
const style = getNodeStyle(node.type ?? 'PROCESSOR');
|
||||
const isCompound = isCompoundType(node.type ?? '');
|
||||
|
||||
const dimmed = isOverlayActive && !isExecuted;
|
||||
const glowFilter = isOverlayActive && isExecuted
|
||||
? (isError ? 'url(#glow-red)' : 'url(#glow-green)')
|
||||
: undefined;
|
||||
|
||||
const borderColor = isOverlayActive && isExecuted
|
||||
? (isError ? '#f85149' : '#3fb950')
|
||||
: style.border;
|
||||
|
||||
if (isCompound) {
|
||||
return (
|
||||
<g
|
||||
className={`${styles.nodeGroup} ${dimmed ? styles.dimmed : ''}`}
|
||||
opacity={dimmed ? 0.15 : 1}
|
||||
role="img"
|
||||
aria-label={`${node.type} container: ${node.label}`}
|
||||
>
|
||||
<rect
|
||||
x={node.x}
|
||||
y={node.y}
|
||||
width={node.width}
|
||||
height={node.height}
|
||||
rx={8}
|
||||
fill={`${style.bg}80`}
|
||||
stroke={borderColor}
|
||||
strokeWidth={1}
|
||||
strokeDasharray={style.category === 'crossRoute' ? '5,3' : undefined}
|
||||
filter={glowFilter}
|
||||
/>
|
||||
<text
|
||||
x={node.x! + 8}
|
||||
y={node.y! + 14}
|
||||
fill={style.border}
|
||||
fontSize={10}
|
||||
fontFamily="JetBrains Mono, monospace"
|
||||
fontWeight={500}
|
||||
opacity={0.7}
|
||||
>
|
||||
{node.label}
|
||||
</text>
|
||||
{/* Children rendered by parent layer */}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<g
|
||||
className={`${styles.nodeGroup} ${dimmed ? styles.dimmed : ''} ${isSelected ? styles.selected : ''}`}
|
||||
opacity={dimmed ? 0.15 : 1}
|
||||
onClick={() => node.id && onClick(node.id)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
role="img"
|
||||
aria-label={`${node.type}: ${node.label}${duration != null ? `, ${duration}ms` : ''}`}
|
||||
tabIndex={0}
|
||||
>
|
||||
<rect
|
||||
x={node.x}
|
||||
y={node.y}
|
||||
width={node.width}
|
||||
height={node.height}
|
||||
rx={8}
|
||||
fill={style.bg}
|
||||
stroke={isSelected ? '#f0b429' : borderColor}
|
||||
strokeWidth={isSelected ? 2 : 1.5}
|
||||
strokeDasharray={style.category === 'crossRoute' ? '5,3' : undefined}
|
||||
filter={glowFilter}
|
||||
/>
|
||||
<text
|
||||
x={(node.x ?? 0) + (node.width ?? 0) / 2}
|
||||
y={(node.y ?? 0) + (node.height ?? 0) / 2 + 4}
|
||||
fill="#fff"
|
||||
fontSize={12}
|
||||
fontFamily="JetBrains Mono, monospace"
|
||||
fontWeight={500}
|
||||
textAnchor="middle"
|
||||
>
|
||||
{node.label}
|
||||
</text>
|
||||
|
||||
{/* Duration badge */}
|
||||
{isOverlayActive && isExecuted && duration != null && (
|
||||
<g>
|
||||
<rect
|
||||
x={(node.x ?? 0) + (node.width ?? 0) - 28}
|
||||
y={(node.y ?? 0) - 8}
|
||||
width={36}
|
||||
height={16}
|
||||
rx={8}
|
||||
fill={isError ? '#f85149' : '#3fb950'}
|
||||
opacity={0.9}
|
||||
/>
|
||||
<text
|
||||
x={(node.x ?? 0) + (node.width ?? 0) - 10}
|
||||
y={(node.y ?? 0) + 4}
|
||||
fill="#fff"
|
||||
fontSize={9}
|
||||
fontFamily="JetBrains Mono, monospace"
|
||||
fontWeight={600}
|
||||
textAnchor="middle"
|
||||
>
|
||||
{duration}ms
|
||||
</text>
|
||||
</g>
|
||||
)}
|
||||
|
||||
{/* Sequence badge */}
|
||||
{isOverlayActive && isExecuted && sequence != null && (
|
||||
<g>
|
||||
<circle
|
||||
cx={(node.x ?? 0) + 8}
|
||||
cy={(node.y ?? 0) - 4}
|
||||
r={8}
|
||||
fill="#21262d"
|
||||
stroke={isError ? '#f85149' : '#3fb950'}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<text
|
||||
x={(node.x ?? 0) + 8}
|
||||
y={(node.y ?? 0) - 1}
|
||||
fill="#fff"
|
||||
fontSize={8}
|
||||
fontFamily="JetBrains Mono, monospace"
|
||||
fontWeight={600}
|
||||
textAnchor="middle"
|
||||
>
|
||||
{sequence}
|
||||
</text>
|
||||
</g>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
91
ui/src/pages/routes/diagram/EdgeLayer.tsx
Normal file
91
ui/src/pages/routes/diagram/EdgeLayer.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import type { PositionedEdge } from '../../../api/types';
|
||||
import styles from './diagram.module.css';
|
||||
|
||||
interface EdgeLayerProps {
|
||||
edges: PositionedEdge[];
|
||||
executedEdges: Set<string>;
|
||||
isOverlayActive: boolean;
|
||||
}
|
||||
|
||||
function edgeKey(e: PositionedEdge): string {
|
||||
return `${e.sourceId}->${e.targetId}`;
|
||||
}
|
||||
|
||||
/** Convert waypoints to a smooth cubic bezier SVG path */
|
||||
function pointsToPath(points: number[][]): string {
|
||||
if (!points || points.length === 0) return '';
|
||||
if (points.length === 1) return `M${points[0][0]},${points[0][1]}`;
|
||||
|
||||
let d = `M${points[0][0]},${points[0][1]}`;
|
||||
|
||||
if (points.length === 2) {
|
||||
d += ` L${points[1][0]},${points[1][1]}`;
|
||||
return d;
|
||||
}
|
||||
|
||||
// Catmull-Rom → cubic bezier approximation for smooth curves
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
const p0 = points[Math.max(i - 1, 0)];
|
||||
const p1 = points[i];
|
||||
const p2 = points[i + 1];
|
||||
const p3 = points[Math.min(i + 2, points.length - 1)];
|
||||
|
||||
const cp1x = p1[0] + (p2[0] - p0[0]) / 6;
|
||||
const cp1y = p1[1] + (p2[1] - p0[1]) / 6;
|
||||
const cp2x = p2[0] - (p3[0] - p1[0]) / 6;
|
||||
const cp2y = p2[1] - (p3[1] - p1[1]) / 6;
|
||||
|
||||
d += ` C${cp1x},${cp1y} ${cp2x},${cp2y} ${p2[0]},${p2[1]}`;
|
||||
}
|
||||
|
||||
return d;
|
||||
}
|
||||
|
||||
export function EdgeLayer({ edges, executedEdges, isOverlayActive }: EdgeLayerProps) {
|
||||
return (
|
||||
<g className={styles.edgeLayer}>
|
||||
{edges.map((edge) => {
|
||||
const key = edgeKey(edge);
|
||||
const executed = executedEdges.has(key);
|
||||
const dimmed = isOverlayActive && !executed;
|
||||
const path = pointsToPath(edge.points ?? []);
|
||||
|
||||
return (
|
||||
<g key={key} opacity={dimmed ? 0.1 : 1}>
|
||||
{/* Glow under-layer for executed edges */}
|
||||
{isOverlayActive && executed && (
|
||||
<path
|
||||
d={path}
|
||||
fill="none"
|
||||
stroke="#3fb950"
|
||||
strokeWidth={6}
|
||||
strokeOpacity={0.2}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
)}
|
||||
<path
|
||||
d={path}
|
||||
fill="none"
|
||||
stroke={isOverlayActive && executed ? '#3fb950' : '#4a5e7a'}
|
||||
strokeWidth={isOverlayActive && executed ? 2.5 : 1.5}
|
||||
strokeLinecap="round"
|
||||
markerEnd={executed ? 'url(#arrowhead-executed)' : 'url(#arrowhead)'}
|
||||
/>
|
||||
{edge.label && edge.points && edge.points.length > 1 && (
|
||||
<text
|
||||
x={(edge.points[0][0] + edge.points[edge.points.length - 1][0]) / 2}
|
||||
y={(edge.points[0][1] + edge.points[edge.points.length - 1][1]) / 2 - 4}
|
||||
fill="#7d8590"
|
||||
fontSize={9}
|
||||
fontFamily="JetBrains Mono, monospace"
|
||||
textAnchor="middle"
|
||||
>
|
||||
{edge.label}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
60
ui/src/pages/routes/diagram/ExchangeInspector.tsx
Normal file
60
ui/src/pages/routes/diagram/ExchangeInspector.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useState } from 'react';
|
||||
import styles from './diagram.module.css';
|
||||
|
||||
interface ExchangeInspectorProps {
|
||||
snapshot: Record<string, string>;
|
||||
}
|
||||
|
||||
type Tab = 'input' | 'output';
|
||||
|
||||
function tryFormatJson(value: string): string {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(value), null, 2);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
export function ExchangeInspector({ snapshot }: ExchangeInspectorProps) {
|
||||
const [tab, setTab] = useState<Tab>('input');
|
||||
|
||||
const body = tab === 'input' ? snapshot.inputBody : snapshot.outputBody;
|
||||
const headers = tab === 'input' ? snapshot.inputHeaders : snapshot.outputHeaders;
|
||||
|
||||
return (
|
||||
<div className={styles.exchangeInspector}>
|
||||
<div className={styles.exchangeTabs}>
|
||||
<button
|
||||
className={`${styles.exchangeTab} ${tab === 'input' ? styles.exchangeTabActive : ''}`}
|
||||
onClick={() => setTab('input')}
|
||||
>
|
||||
Input
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.exchangeTab} ${tab === 'output' ? styles.exchangeTabActive : ''}`}
|
||||
onClick={() => setTab('output')}
|
||||
>
|
||||
Output
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{body && (
|
||||
<div className={styles.exchangeSection}>
|
||||
<div className={styles.exchangeSectionLabel}>Body</div>
|
||||
<pre className={styles.exchangeBody}>{tryFormatJson(body)}</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{headers && (
|
||||
<div className={styles.exchangeSection}>
|
||||
<div className={styles.exchangeSectionLabel}>Headers</div>
|
||||
<pre className={styles.exchangeBody}>{tryFormatJson(headers)}</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!body && !headers && (
|
||||
<div className={styles.exchangeEmpty}>No exchange data available</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
61
ui/src/pages/routes/diagram/FlowParticles.tsx
Normal file
61
ui/src/pages/routes/diagram/FlowParticles.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { PositionedEdge } from '../../../api/types';
|
||||
import styles from './diagram.module.css';
|
||||
|
||||
interface FlowParticlesProps {
|
||||
edges: PositionedEdge[];
|
||||
executedEdges: Set<string>;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
function pointsToPath(points: number[][]): string {
|
||||
if (!points || points.length < 2) return '';
|
||||
let d = `M${points[0][0]},${points[0][1]}`;
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
d += ` L${points[i][0]},${points[i][1]}`;
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
export function FlowParticles({ edges, executedEdges, isActive }: FlowParticlesProps) {
|
||||
const paths = useMemo(() => {
|
||||
if (!isActive) return [];
|
||||
return edges
|
||||
.filter((e) => executedEdges.has(`${e.sourceId}->${e.targetId}`))
|
||||
.map((e, i) => ({
|
||||
id: `particle-${e.sourceId}-${e.targetId}`,
|
||||
d: pointsToPath(e.points ?? []),
|
||||
delay: (i * 0.3) % 1.5,
|
||||
}))
|
||||
.filter((p) => p.d);
|
||||
}, [edges, executedEdges, isActive]);
|
||||
|
||||
if (!isActive || paths.length === 0) return null;
|
||||
|
||||
return (
|
||||
<g className={styles.flowParticles}>
|
||||
{paths.map((p) => (
|
||||
<g key={p.id}>
|
||||
<path id={p.id} d={p.d} fill="none" stroke="none" />
|
||||
<circle r={3} fill="url(#particle-gradient)">
|
||||
<animateMotion
|
||||
dur="1.5s"
|
||||
repeatCount="indefinite"
|
||||
begin={`${p.delay}s`}
|
||||
>
|
||||
<mpath href={`#${p.id}`} />
|
||||
</animateMotion>
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0;1;1;0"
|
||||
keyTimes="0;0.1;0.8;1"
|
||||
dur="1.5s"
|
||||
repeatCount="indefinite"
|
||||
begin={`${p.delay}s`}
|
||||
/>
|
||||
</circle>
|
||||
</g>
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
102
ui/src/pages/routes/diagram/ProcessorDetailPanel.tsx
Normal file
102
ui/src/pages/routes/diagram/ProcessorDetailPanel.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { ExecutionDetail, ProcessorNode } from '../../../api/types';
|
||||
import { useProcessorSnapshot } from '../../../api/queries/executions';
|
||||
import { ExchangeInspector } from './ExchangeInspector';
|
||||
import styles from './diagram.module.css';
|
||||
|
||||
interface ProcessorDetailPanelProps {
|
||||
execution: ExecutionDetail;
|
||||
selectedNodeId: string | null;
|
||||
}
|
||||
|
||||
/** Find the processor node matching a diagramNodeId, return its flat index too */
|
||||
function findProcessor(
|
||||
processors: ProcessorNode[],
|
||||
nodeId: string,
|
||||
indexRef: { idx: number },
|
||||
): ProcessorNode | null {
|
||||
for (const proc of processors) {
|
||||
const currentIdx = indexRef.idx;
|
||||
indexRef.idx++;
|
||||
if (proc.diagramNodeId === nodeId) {
|
||||
return { ...proc, _flatIndex: currentIdx } as ProcessorNode & { _flatIndex: number };
|
||||
}
|
||||
if (proc.children && proc.children.length > 0) {
|
||||
const found = findProcessor(proc.children, nodeId, indexRef);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function ProcessorDetailPanel({ execution, selectedNodeId }: ProcessorDetailPanelProps) {
|
||||
const processor = useMemo(() => {
|
||||
if (!selectedNodeId || !execution.processors) return null;
|
||||
return findProcessor(execution.processors, selectedNodeId, { idx: 0 });
|
||||
}, [execution, selectedNodeId]);
|
||||
|
||||
// Get flat index for snapshot lookup
|
||||
const flatIndex = useMemo(() => {
|
||||
if (!processor) return null;
|
||||
return (processor as ProcessorNode & { _flatIndex?: number })._flatIndex ?? null;
|
||||
}, [processor]);
|
||||
|
||||
const { data: snapshot } = useProcessorSnapshot(
|
||||
flatIndex != null ? execution.executionId ?? null : null,
|
||||
flatIndex,
|
||||
);
|
||||
|
||||
if (!selectedNodeId || !processor) {
|
||||
return (
|
||||
<div className={styles.detailPanel}>
|
||||
<div className={styles.detailEmpty}>
|
||||
Click a node to view processor details
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.detailPanel}>
|
||||
{/* Processor identity */}
|
||||
<div className={styles.detailHeader}>
|
||||
<div className={styles.detailType}>{processor.processorType}</div>
|
||||
<div className={styles.detailId}>{processor.processorId}</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.detailMeta}>
|
||||
<div className={styles.detailMetaItem}>
|
||||
<span className={styles.detailMetaLabel}>Status</span>
|
||||
<span className={`${styles.detailMetaValue} ${processor.status === 'FAILED' ? styles.statusFailed : styles.statusOk}`}>
|
||||
{processor.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.detailMetaItem}>
|
||||
<span className={styles.detailMetaLabel}>Duration</span>
|
||||
<span className={styles.detailMetaValue}>{processor.durationMs}ms</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error info */}
|
||||
{processor.errorMessage && (
|
||||
<div className={styles.detailError}>
|
||||
<div className={styles.detailErrorLabel}>Error</div>
|
||||
<div className={styles.detailErrorMessage}>{processor.errorMessage}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Exchange data */}
|
||||
{snapshot && <ExchangeInspector snapshot={snapshot} />}
|
||||
|
||||
{/* Actions (future) */}
|
||||
<div className={styles.detailActions}>
|
||||
<button className={styles.detailActionBtn} disabled title="Coming soon">
|
||||
Collect Trace Data
|
||||
</button>
|
||||
<button className={styles.detailActionBtn} disabled title="Coming soon">
|
||||
View Logs
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
95
ui/src/pages/routes/diagram/RouteDiagramSvg.tsx
Normal file
95
ui/src/pages/routes/diagram/RouteDiagramSvg.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import type { DiagramLayout } from '../../../api/types';
|
||||
import type { OverlayState } from '../../../hooks/useExecutionOverlay';
|
||||
import { SvgDefs } from './SvgDefs';
|
||||
import { EdgeLayer } from './EdgeLayer';
|
||||
import { DiagramNode } from './DiagramNode';
|
||||
import { FlowParticles } from './FlowParticles';
|
||||
import { isCompoundType } from './nodeStyles';
|
||||
import type { PositionedNode } from '../../../api/types';
|
||||
|
||||
interface RouteDiagramSvgProps {
|
||||
layout: DiagramLayout;
|
||||
overlay: OverlayState;
|
||||
}
|
||||
|
||||
/** Recursively flatten all nodes (including compound children) for rendering */
|
||||
function flattenNodes(nodes: PositionedNode[]): PositionedNode[] {
|
||||
const result: PositionedNode[] = [];
|
||||
for (const node of nodes) {
|
||||
result.push(node);
|
||||
if (node.children && node.children.length > 0) {
|
||||
result.push(...flattenNodes(node.children));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function RouteDiagramSvg({ layout, overlay }: RouteDiagramSvgProps) {
|
||||
const padding = 40;
|
||||
const width = (layout.width ?? 600) + padding * 2;
|
||||
const height = (layout.height ?? 400) + padding * 2;
|
||||
|
||||
const allNodes = flattenNodes(layout.nodes ?? []);
|
||||
// Render compound nodes first (background), then regular nodes on top
|
||||
const compoundNodes = allNodes.filter((n) => isCompoundType(n.type ?? ''));
|
||||
const leafNodes = allNodes.filter((n) => !isCompoundType(n.type ?? ''));
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox={`-${padding} -${padding} ${width} ${height}`}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style={{ display: 'block' }}
|
||||
>
|
||||
<SvgDefs />
|
||||
|
||||
{/* Compound container nodes (background) */}
|
||||
{compoundNodes.map((node) => (
|
||||
<DiagramNode
|
||||
key={node.id}
|
||||
node={node}
|
||||
isExecuted={!!node.id && overlay.executedNodes.has(node.id)}
|
||||
isError={false}
|
||||
isOverlayActive={overlay.isActive}
|
||||
duration={node.id ? overlay.durations.get(node.id) : undefined}
|
||||
sequence={undefined}
|
||||
isSelected={overlay.selectedNodeId === node.id}
|
||||
onClick={overlay.selectNode}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Edges */}
|
||||
<EdgeLayer
|
||||
edges={layout.edges ?? []}
|
||||
executedEdges={overlay.executedEdges}
|
||||
isOverlayActive={overlay.isActive}
|
||||
/>
|
||||
|
||||
{/* Flow particles */}
|
||||
<FlowParticles
|
||||
edges={layout.edges ?? []}
|
||||
executedEdges={overlay.executedEdges}
|
||||
isActive={overlay.isActive}
|
||||
/>
|
||||
|
||||
{/* Leaf nodes (on top of edges) */}
|
||||
{leafNodes.map((node) => {
|
||||
const nodeId = node.id ?? '';
|
||||
return (
|
||||
<DiagramNode
|
||||
key={nodeId}
|
||||
node={node}
|
||||
isExecuted={overlay.executedNodes.has(nodeId)}
|
||||
isError={false}
|
||||
isOverlayActive={overlay.isActive}
|
||||
duration={overlay.durations.get(nodeId)}
|
||||
sequence={overlay.sequences.get(nodeId)}
|
||||
isSelected={overlay.selectedNodeId === nodeId}
|
||||
onClick={overlay.selectNode}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
64
ui/src/pages/routes/diagram/SvgDefs.tsx
Normal file
64
ui/src/pages/routes/diagram/SvgDefs.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
/** SVG definitions: arrow markers, glow filters, gradient fills */
|
||||
export function SvgDefs() {
|
||||
return (
|
||||
<defs>
|
||||
{/* Arrow marker for edges */}
|
||||
<marker id="arrowhead" markerWidth="8" markerHeight="6" refX="8" refY="3"
|
||||
orient="auto" markerUnits="strokeWidth">
|
||||
<path d="M0,0 L8,3 L0,6" fill="#4a5e7a" />
|
||||
</marker>
|
||||
<marker id="arrowhead-executed" markerWidth="8" markerHeight="6" refX="8" refY="3"
|
||||
orient="auto" markerUnits="strokeWidth">
|
||||
<path d="M0,0 L8,3 L0,6" fill="#3fb950" />
|
||||
</marker>
|
||||
<marker id="arrowhead-error" markerWidth="8" markerHeight="6" refX="8" refY="3"
|
||||
orient="auto" markerUnits="strokeWidth">
|
||||
<path d="M0,0 L8,3 L0,6" fill="#f85149" />
|
||||
</marker>
|
||||
|
||||
{/* Glow filters */}
|
||||
<filter id="glow-green" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation="3" result="blur" />
|
||||
<feFlood floodColor="#3fb950" floodOpacity="0.4" result="color" />
|
||||
<feComposite in="color" in2="blur" operator="in" result="shadow" />
|
||||
<feMerge>
|
||||
<feMergeNode in="shadow" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
<filter id="glow-red" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation="3" result="blur" />
|
||||
<feFlood floodColor="#f85149" floodOpacity="0.4" result="color" />
|
||||
<feComposite in="color" in2="blur" operator="in" result="shadow" />
|
||||
<feMerge>
|
||||
<feMergeNode in="shadow" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
<filter id="glow-blue" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation="3" result="blur" />
|
||||
<feFlood floodColor="#58a6ff" floodOpacity="0.4" result="color" />
|
||||
<feComposite in="color" in2="blur" operator="in" result="shadow" />
|
||||
<feMerge>
|
||||
<feMergeNode in="shadow" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
<filter id="glow-purple" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feGaussianBlur stdDeviation="3" result="blur" />
|
||||
<feFlood floodColor="#b87aff" floodOpacity="0.4" result="color" />
|
||||
<feComposite in="color" in2="blur" operator="in" result="shadow" />
|
||||
<feMerge>
|
||||
<feMergeNode in="shadow" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
|
||||
{/* Flow particle gradient */}
|
||||
<radialGradient id="particle-gradient">
|
||||
<stop offset="0%" stopColor="#3fb950" stopOpacity="1" />
|
||||
<stop offset="100%" stopColor="#3fb950" stopOpacity="0" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
);
|
||||
}
|
||||
325
ui/src/pages/routes/diagram/diagram.module.css
Normal file
325
ui/src/pages/routes/diagram/diagram.module.css
Normal file
@@ -0,0 +1,325 @@
|
||||
/* ─── Diagram Canvas ─── */
|
||||
.canvasContainer {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
background: var(--bg-deep);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 500px;
|
||||
overflow: hidden;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.canvas:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* ─── Zoom Controls ─── */
|
||||
.zoomControls {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.zoomBtn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.zoomBtn:hover {
|
||||
background: var(--bg-raised);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ─── Minimap ─── */
|
||||
.minimap {
|
||||
position: absolute;
|
||||
bottom: 12px;
|
||||
right: 12px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 4px;
|
||||
z-index: 10;
|
||||
opacity: 0.85;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.minimap:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ─── Node Styles ─── */
|
||||
.nodeGroup {
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.dimmed {
|
||||
opacity: 0.15 !important;
|
||||
}
|
||||
|
||||
.selected rect {
|
||||
stroke-width: 2.5;
|
||||
}
|
||||
|
||||
/* ─── Edge Layer ─── */
|
||||
.edgeLayer path {
|
||||
transition: opacity 0.3s, stroke 0.3s;
|
||||
}
|
||||
|
||||
/* ─── Flow Particles ─── */
|
||||
.flowParticles circle {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ─── Split Layout (Diagram + Detail Panel) ─── */
|
||||
.splitLayout {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
height: 100%;
|
||||
min-height: 500px;
|
||||
}
|
||||
|
||||
.diagramSide {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ─── Processor Detail Panel ─── */
|
||||
.detailPanel {
|
||||
width: 340px;
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-surface);
|
||||
border-left: 1px solid var(--border-subtle);
|
||||
padding: 16px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.detailEmpty {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
padding: 40px 16px;
|
||||
}
|
||||
|
||||
.detailHeader {
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.detailType {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.8px;
|
||||
color: var(--amber);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.detailId {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.detailMeta {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.detailMetaItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.detailMetaLabel {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.detailMetaValue {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.statusFailed {
|
||||
color: var(--rose);
|
||||
}
|
||||
|
||||
.statusOk {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.detailError {
|
||||
background: var(--rose-glow);
|
||||
border: 1px solid rgba(244, 63, 94, 0.2);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.detailErrorLabel {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--rose);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.detailErrorMessage {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--rose);
|
||||
max-height: 80px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* ─── Exchange Inspector ─── */
|
||||
.exchangeInspector {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.exchangeTabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.exchangeTab {
|
||||
padding: 6px 16px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.exchangeTab:hover {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.exchangeTabActive {
|
||||
color: var(--amber);
|
||||
border-bottom-color: var(--amber);
|
||||
}
|
||||
|
||||
.exchangeSection {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.exchangeSectionLabel {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.exchangeBody {
|
||||
background: var(--bg-base);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
max-height: 200px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.exchangeEmpty {
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* ─── Detail Actions ─── */
|
||||
.detailActions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: auto;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.detailActionBtn {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-raised);
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.detailActionBtn:hover:not(:disabled) {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.detailActionBtn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ─── Responsive ─── */
|
||||
@media (max-width: 768px) {
|
||||
.splitLayout {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.detailPanel {
|
||||
width: 100%;
|
||||
max-height: 300px;
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.minimap {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
52
ui/src/pages/routes/diagram/nodeStyles.ts
Normal file
52
ui/src/pages/routes/diagram/nodeStyles.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/** Node type styling: border color, background, glow filter */
|
||||
|
||||
const ENDPOINT_TYPES = new Set([
|
||||
'ENDPOINT', 'DIRECT', 'SEDA', 'TO', 'TO_DYNAMIC', 'FROM',
|
||||
]);
|
||||
const EIP_TYPES = new Set([
|
||||
'CHOICE', 'SPLIT', 'MULTICAST', 'FILTER', 'AGGREGATE',
|
||||
'RECIPIENT_LIST', 'ROUTING_SLIP', 'DYNAMIC_ROUTER',
|
||||
'CIRCUIT_BREAKER', 'WHEN', 'OTHERWISE', 'LOOP',
|
||||
]);
|
||||
const ERROR_TYPES = new Set([
|
||||
'ON_EXCEPTION', 'TRY_CATCH', 'DO_CATCH', 'DO_FINALLY',
|
||||
'ERROR_HANDLER',
|
||||
]);
|
||||
const CROSS_ROUTE_TYPES = new Set([
|
||||
'WIRE_TAP', 'ENRICH', 'POLL_ENRICH',
|
||||
]);
|
||||
|
||||
export interface NodeStyle {
|
||||
border: string;
|
||||
bg: string;
|
||||
glowFilter: string;
|
||||
category: 'endpoint' | 'eip' | 'processor' | 'error' | 'crossRoute';
|
||||
}
|
||||
|
||||
export function getNodeStyle(type: string): NodeStyle {
|
||||
const upper = type.toUpperCase();
|
||||
if (ERROR_TYPES.has(upper)) {
|
||||
return { border: '#f85149', bg: '#3d1418', glowFilter: 'url(#glow-red)', category: 'error' };
|
||||
}
|
||||
if (ENDPOINT_TYPES.has(upper)) {
|
||||
return { border: '#58a6ff', bg: '#1a3a5c', glowFilter: 'url(#glow-blue)', category: 'endpoint' };
|
||||
}
|
||||
if (CROSS_ROUTE_TYPES.has(upper)) {
|
||||
return { border: '#39d2e0', bg: 'transparent', glowFilter: 'url(#glow-blue)', category: 'crossRoute' };
|
||||
}
|
||||
if (EIP_TYPES.has(upper)) {
|
||||
return { border: '#b87aff', bg: '#2d1b4e', glowFilter: 'url(#glow-purple)', category: 'eip' };
|
||||
}
|
||||
// Default: Processor
|
||||
return { border: '#3fb950', bg: '#0d2818', glowFilter: 'url(#glow-green)', category: 'processor' };
|
||||
}
|
||||
|
||||
/** Compound node types that can contain children */
|
||||
export const COMPOUND_TYPES = new Set([
|
||||
'CHOICE', 'SPLIT', 'TRY_CATCH', 'LOOP', 'MULTICAST', 'AGGREGATE',
|
||||
'ON_EXCEPTION', 'DO_CATCH', 'DO_FINALLY',
|
||||
]);
|
||||
|
||||
export function isCompoundType(type: string): boolean {
|
||||
return COMPOUND_TYPES.has(type.toUpperCase());
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { LoginPage } from './auth/LoginPage';
|
||||
import { OidcCallback } from './auth/OidcCallback';
|
||||
import { ExecutionExplorer } from './pages/executions/ExecutionExplorer';
|
||||
import { OidcAdminPage } from './pages/admin/OidcAdminPage';
|
||||
import { RoutePage } from './pages/routes/RoutePage';
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
@@ -23,6 +24,7 @@ export const router = createBrowserRouter([
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="/executions" replace /> },
|
||||
{ path: 'executions', element: <ExecutionExplorer /> },
|
||||
{ path: 'apps/:group/routes/:routeId', element: <RoutePage /> },
|
||||
{ path: 'admin/oidc', element: <OidcAdminPage /> },
|
||||
],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user