Compare commits
26 Commits
752d7ec0e7
...
v0.0.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dafd7adb00 | ||
|
|
44eecfa5cd | ||
|
|
ff76751629 | ||
|
|
413839452c | ||
|
|
c33e899be7 | ||
|
|
180514a039 | ||
|
|
60fced56ed | ||
|
|
515c942623 | ||
|
|
3ccd4b6548 | ||
|
|
dad608e3a2 | ||
|
|
7479dd6daf | ||
|
|
e4dff0cad1 | ||
|
|
717367252c | ||
|
|
a06808a2a2 | ||
|
|
6b750df1c4 | ||
|
|
ea56bcf2d7 | ||
|
|
826466aa55 | ||
|
|
6a5dba4eba | ||
|
|
8ad0016a8e | ||
|
|
3c226de62f | ||
|
|
c8c62a98bb | ||
|
|
2ae2871822 | ||
|
|
a950feaef1 | ||
|
|
695969d759 | ||
|
|
a72b0954db | ||
|
|
4572230c9c |
@@ -51,7 +51,7 @@ public class AgentLifecycleMonitor {
|
||||
if (before != null && before != agent.state()) {
|
||||
String eventType = mapTransitionEvent(before, agent.state());
|
||||
if (eventType != null) {
|
||||
agentEventService.recordEvent(agent.id(), agent.group(), eventType,
|
||||
agentEventService.recordEvent(agent.id(), agent.application(), eventType,
|
||||
agent.name() + " " + before + " -> " + agent.state());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ public class AgentCommandController {
|
||||
|
||||
List<AgentInfo> agents = registryService.findAll().stream()
|
||||
.filter(a -> a.state() == AgentState.LIVE)
|
||||
.filter(a -> group.equals(a.group()))
|
||||
.filter(a -> group.equals(a.application()))
|
||||
.toList();
|
||||
|
||||
List<String> commandIds = new ArrayList<>();
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.cameleer3.server.app.dto.MetricBucket;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.*;
|
||||
@@ -58,7 +59,7 @@ public class AgentMetricsController {
|
||||
double value = rs.getDouble("avg_value");
|
||||
result.computeIfAbsent(metricName, k -> new ArrayList<>())
|
||||
.add(new MetricBucket(bucket, value));
|
||||
}, intervalStr, agentId, from, to, namesArray);
|
||||
}, intervalStr, agentId, Timestamp.from(from), Timestamp.from(to), namesArray);
|
||||
|
||||
return new AgentMetricsResponse(result);
|
||||
}
|
||||
|
||||
@@ -102,21 +102,21 @@ public class AgentRegistrationController {
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
|
||||
String group = request.group() != null ? request.group() : "default";
|
||||
String application = request.application() != null ? request.application() : "default";
|
||||
List<String> routeIds = request.routeIds() != null ? request.routeIds() : List.of();
|
||||
var capabilities = request.capabilities() != null ? request.capabilities() : Collections.<String, Object>emptyMap();
|
||||
|
||||
AgentInfo agent = registryService.register(
|
||||
request.agentId(), request.name(), group, request.version(), routeIds, capabilities);
|
||||
log.info("Agent registered: {} (name={}, group={})", request.agentId(), request.name(), group);
|
||||
request.agentId(), request.name(), application, request.version(), routeIds, capabilities);
|
||||
log.info("Agent registered: {} (name={}, application={})", request.agentId(), request.name(), application);
|
||||
|
||||
agentEventService.recordEvent(request.agentId(), group, "REGISTERED",
|
||||
agentEventService.recordEvent(request.agentId(), application, "REGISTERED",
|
||||
"Agent registered: " + request.name());
|
||||
|
||||
// Issue JWT tokens with AGENT role
|
||||
List<String> roles = List.of("AGENT");
|
||||
String accessToken = jwtService.createAccessToken(request.agentId(), group, roles);
|
||||
String refreshToken = jwtService.createRefreshToken(request.agentId(), group, roles);
|
||||
String accessToken = jwtService.createAccessToken(request.agentId(), application, roles);
|
||||
String refreshToken = jwtService.createRefreshToken(request.agentId(), application, roles);
|
||||
|
||||
return ResponseEntity.ok(new AgentRegistrationResponse(
|
||||
agent.id(),
|
||||
@@ -166,8 +166,8 @@ public class AgentRegistrationController {
|
||||
// Preserve roles from refresh token
|
||||
List<String> roles = result.roles().isEmpty()
|
||||
? List.of("AGENT") : result.roles();
|
||||
String newAccessToken = jwtService.createAccessToken(agentId, agent.group(), roles);
|
||||
String newRefreshToken = jwtService.createRefreshToken(agentId, agent.group(), roles);
|
||||
String newAccessToken = jwtService.createAccessToken(agentId, agent.application(), roles);
|
||||
String newRefreshToken = jwtService.createRefreshToken(agentId, agent.application(), roles);
|
||||
|
||||
return ResponseEntity.ok(new AgentRefreshResponse(newAccessToken, newRefreshToken));
|
||||
}
|
||||
@@ -187,13 +187,13 @@ public class AgentRegistrationController {
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "List all agents",
|
||||
description = "Returns all registered agents with runtime metrics, optionally filtered by status and/or group")
|
||||
description = "Returns all registered agents with runtime metrics, optionally filtered by status and/or application")
|
||||
@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 group) {
|
||||
@RequestParam(required = false) String application) {
|
||||
List<AgentInfo> agents;
|
||||
|
||||
if (status != null) {
|
||||
@@ -207,10 +207,10 @@ public class AgentRegistrationController {
|
||||
agents = registryService.findAll();
|
||||
}
|
||||
|
||||
// Apply group filter if specified
|
||||
if (group != null && !group.isBlank()) {
|
||||
// Apply application filter if specified
|
||||
if (application != null && !application.isBlank()) {
|
||||
agents = agents.stream()
|
||||
.filter(a -> group.equals(a.group()))
|
||||
.filter(a -> application.equals(a.application()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@@ -221,11 +221,11 @@ public class AgentRegistrationController {
|
||||
List<AgentInstanceResponse> response = finalAgents.stream()
|
||||
.map(a -> {
|
||||
AgentInstanceResponse dto = AgentInstanceResponse.from(a);
|
||||
double[] m = agentMetrics.get(a.group());
|
||||
double[] m = agentMetrics.get(a.application());
|
||||
if (m != null) {
|
||||
long groupAgentCount = finalAgents.stream()
|
||||
.filter(ag -> ag.group().equals(a.group())).count();
|
||||
double agentTps = groupAgentCount > 0 ? m[0] / groupAgentCount : 0;
|
||||
long appAgentCount = finalAgents.stream()
|
||||
.filter(ag -> ag.application().equals(a.application())).count();
|
||||
double agentTps = appAgentCount > 0 ? m[0] / appAgentCount : 0;
|
||||
double errorRate = m[1];
|
||||
int activeRoutes = (int) m[2];
|
||||
return dto.withMetrics(agentTps, errorRate, activeRoutes);
|
||||
@@ -242,19 +242,19 @@ public class AgentRegistrationController {
|
||||
Instant from1m = now.minus(1, ChronoUnit.MINUTES);
|
||||
try {
|
||||
jdbc.query(
|
||||
"SELECT group_name, " +
|
||||
"SELECT application_name, " +
|
||||
"SUM(total_count) AS total, " +
|
||||
"SUM(failed_count) AS failed, " +
|
||||
"COUNT(DISTINCT route_id) AS active_routes " +
|
||||
"FROM stats_1m_route WHERE bucket >= ? AND bucket < ? " +
|
||||
"GROUP BY group_name",
|
||||
"GROUP BY application_name",
|
||||
rs -> {
|
||||
long total = rs.getLong("total");
|
||||
long failed = rs.getLong("failed");
|
||||
double tps = total / 60.0;
|
||||
double errorRate = total > 0 ? (double) failed / total : 0.0;
|
||||
int activeRoutes = rs.getInt("active_routes");
|
||||
result.put(rs.getString("group_name"), new double[]{tps, errorRate, activeRoutes});
|
||||
result.put(rs.getString("application_name"), new double[]{tps, errorRate, activeRoutes});
|
||||
},
|
||||
Timestamp.from(from1m), Timestamp.from(now));
|
||||
} catch (Exception e) {
|
||||
|
||||
@@ -90,14 +90,14 @@ public class DiagramRenderController {
|
||||
}
|
||||
|
||||
@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")
|
||||
@Operation(summary = "Find diagram by application and route ID",
|
||||
description = "Resolves application 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,
|
||||
@ApiResponse(responseCode = "404", description = "No diagram found for the given application and route")
|
||||
public ResponseEntity<DiagramLayout> findByApplicationAndRoute(
|
||||
@RequestParam String application,
|
||||
@RequestParam String routeId) {
|
||||
List<String> agentIds = registryService.findByGroup(group).stream()
|
||||
List<String> agentIds = registryService.findByApplication(application).stream()
|
||||
.map(AgentInfo::id)
|
||||
.toList();
|
||||
|
||||
|
||||
@@ -53,11 +53,11 @@ public class ExecutionController {
|
||||
@ApiResponse(responseCode = "202", description = "Data accepted for processing")
|
||||
public ResponseEntity<Void> ingestExecutions(@RequestBody String body) throws JsonProcessingException {
|
||||
String agentId = extractAgentId();
|
||||
String groupName = resolveGroupName(agentId);
|
||||
String applicationName = resolveApplicationName(agentId);
|
||||
List<RouteExecution> executions = parsePayload(body);
|
||||
|
||||
for (RouteExecution execution : executions) {
|
||||
ingestionService.ingestExecution(agentId, groupName, execution);
|
||||
ingestionService.ingestExecution(agentId, applicationName, execution);
|
||||
}
|
||||
|
||||
return ResponseEntity.accepted().build();
|
||||
@@ -68,9 +68,9 @@ public class ExecutionController {
|
||||
return auth != null ? auth.getName() : "";
|
||||
}
|
||||
|
||||
private String resolveGroupName(String agentId) {
|
||||
private String resolveApplicationName(String agentId) {
|
||||
AgentInfo agent = registryService.findById(agentId);
|
||||
return agent != null ? agent.group() : "";
|
||||
return agent != null ? agent.application() : "";
|
||||
}
|
||||
|
||||
private List<RouteExecution> parsePayload(String body) throws JsonProcessingException {
|
||||
|
||||
@@ -47,9 +47,9 @@ public class RouteCatalogController {
|
||||
public ResponseEntity<List<AppCatalogEntry>> getCatalog() {
|
||||
List<AgentInfo> allAgents = registryService.findAll();
|
||||
|
||||
// Group agents by application (group name)
|
||||
// Group agents by application name
|
||||
Map<String, List<AgentInfo>> agentsByApp = allAgents.stream()
|
||||
.collect(Collectors.groupingBy(AgentInfo::group, LinkedHashMap::new, Collectors.toList()));
|
||||
.collect(Collectors.groupingBy(AgentInfo::application, LinkedHashMap::new, Collectors.toList()));
|
||||
|
||||
// Collect all distinct routes per app
|
||||
Map<String, Set<String>> routesByApp = new LinkedHashMap<>();
|
||||
@@ -73,11 +73,11 @@ public class RouteCatalogController {
|
||||
Map<String, Instant> routeLastSeen = new LinkedHashMap<>();
|
||||
try {
|
||||
jdbc.query(
|
||||
"SELECT group_name, route_id, SUM(total_count) AS cnt, MAX(bucket) AS last_seen " +
|
||||
"SELECT application_name, route_id, SUM(total_count) AS cnt, MAX(bucket) AS last_seen " +
|
||||
"FROM stats_1m_route WHERE bucket >= ? AND bucket < ? " +
|
||||
"GROUP BY group_name, route_id",
|
||||
"GROUP BY application_name, route_id",
|
||||
rs -> {
|
||||
String key = rs.getString("group_name") + "/" + rs.getString("route_id");
|
||||
String key = rs.getString("application_name") + "/" + rs.getString("route_id");
|
||||
routeExchangeCounts.put(key, rs.getLong("cnt"));
|
||||
Timestamp ts = rs.getTimestamp("last_seen");
|
||||
if (ts != null) routeLastSeen.put(key, ts.toInstant());
|
||||
@@ -91,9 +91,9 @@ public class RouteCatalogController {
|
||||
Map<String, Double> agentTps = new LinkedHashMap<>();
|
||||
try {
|
||||
jdbc.query(
|
||||
"SELECT group_name, SUM(total_count) AS cnt " +
|
||||
"SELECT application_name, SUM(total_count) AS cnt " +
|
||||
"FROM stats_1m_route WHERE bucket >= ? AND bucket < ? " +
|
||||
"GROUP BY group_name",
|
||||
"GROUP BY application_name",
|
||||
rs -> {
|
||||
// This gives per-app TPS; we'll distribute among agents below
|
||||
},
|
||||
|
||||
@@ -44,7 +44,7 @@ public class RouteMetricsController {
|
||||
long windowSeconds = Duration.between(fromInstant, toInstant).toSeconds();
|
||||
|
||||
var sql = new StringBuilder(
|
||||
"SELECT group_name, route_id, " +
|
||||
"SELECT application_name, route_id, " +
|
||||
"SUM(total_count) AS total, " +
|
||||
"SUM(failed_count) AS failed, " +
|
||||
"CASE WHEN SUM(total_count) > 0 THEN SUM(duration_sum) / SUM(total_count) ELSE 0 END AS avg_dur, " +
|
||||
@@ -55,17 +55,17 @@ public class RouteMetricsController {
|
||||
params.add(Timestamp.from(toInstant));
|
||||
|
||||
if (appId != null) {
|
||||
sql.append(" AND group_name = ?");
|
||||
sql.append(" AND application_name = ?");
|
||||
params.add(appId);
|
||||
}
|
||||
sql.append(" GROUP BY group_name, route_id ORDER BY group_name, route_id");
|
||||
sql.append(" GROUP BY application_name, route_id ORDER BY application_name, route_id");
|
||||
|
||||
// Key struct for sparkline lookup
|
||||
record RouteKey(String appId, String routeId) {}
|
||||
List<RouteKey> routeKeys = new ArrayList<>();
|
||||
|
||||
List<RouteMetrics> metrics = jdbc.query(sql.toString(), (rs, rowNum) -> {
|
||||
String groupName = rs.getString("group_name");
|
||||
String applicationName = rs.getString("application_name");
|
||||
String routeId = rs.getString("route_id");
|
||||
long total = rs.getLong("total");
|
||||
long failed = rs.getLong("failed");
|
||||
@@ -76,8 +76,8 @@ public class RouteMetricsController {
|
||||
double errorRate = total > 0 ? (double) failed / total : 0.0;
|
||||
double tps = windowSeconds > 0 ? (double) total / windowSeconds : 0.0;
|
||||
|
||||
routeKeys.add(new RouteKey(groupName, routeId));
|
||||
return new RouteMetrics(routeId, groupName, total, successRate,
|
||||
routeKeys.add(new RouteKey(applicationName, routeId));
|
||||
return new RouteMetrics(routeId, applicationName, total, successRate,
|
||||
avgDur, p99Dur, errorRate, tps, List.of());
|
||||
}, params.toArray());
|
||||
|
||||
@@ -93,7 +93,7 @@ public class RouteMetricsController {
|
||||
"SELECT time_bucket(? * INTERVAL '1 second', bucket) AS period, " +
|
||||
"COALESCE(SUM(total_count), 0) AS cnt " +
|
||||
"FROM stats_1m_route WHERE bucket >= ? AND bucket < ? " +
|
||||
"AND group_name = ? AND route_id = ? " +
|
||||
"AND application_name = ? AND route_id = ? " +
|
||||
"GROUP BY period ORDER BY period",
|
||||
(rs, rowNum) -> rs.getDouble("cnt"),
|
||||
bucketSeconds, Timestamp.from(fromInstant), Timestamp.from(toInstant),
|
||||
@@ -124,7 +124,7 @@ public class RouteMetricsController {
|
||||
Instant fromInstant = from != null ? from : toInstant.minus(24, ChronoUnit.HOURS);
|
||||
|
||||
var sql = new StringBuilder(
|
||||
"SELECT processor_id, processor_type, route_id, group_name, " +
|
||||
"SELECT processor_id, processor_type, route_id, application_name, " +
|
||||
"SUM(total_count) AS total_count, " +
|
||||
"SUM(failed_count) AS failed_count, " +
|
||||
"CASE WHEN SUM(total_count) > 0 THEN SUM(duration_sum)::double precision / SUM(total_count) ELSE 0 END AS avg_duration_ms, " +
|
||||
@@ -137,10 +137,10 @@ public class RouteMetricsController {
|
||||
params.add(routeId);
|
||||
|
||||
if (appId != null) {
|
||||
sql.append(" AND group_name = ?");
|
||||
sql.append(" AND application_name = ?");
|
||||
params.add(appId);
|
||||
}
|
||||
sql.append(" GROUP BY processor_id, processor_type, route_id, group_name");
|
||||
sql.append(" GROUP BY processor_id, processor_type, route_id, application_name");
|
||||
sql.append(" ORDER BY SUM(total_count) DESC");
|
||||
|
||||
List<ProcessorMetrics> metrics = jdbc.query(sql.toString(), (rs, rowNum) -> {
|
||||
@@ -151,7 +151,7 @@ public class RouteMetricsController {
|
||||
rs.getString("processor_id"),
|
||||
rs.getString("processor_type"),
|
||||
rs.getString("route_id"),
|
||||
rs.getString("group_name"),
|
||||
rs.getString("application_name"),
|
||||
totalCount,
|
||||
failedCount,
|
||||
rs.getDouble("avg_duration_ms"),
|
||||
|
||||
@@ -51,13 +51,13 @@ public class SearchController {
|
||||
@RequestParam(required = false) String routeId,
|
||||
@RequestParam(required = false) String agentId,
|
||||
@RequestParam(required = false) String processorType,
|
||||
@RequestParam(required = false) String group,
|
||||
@RequestParam(required = false) String application,
|
||||
@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);
|
||||
List<String> agentIds = resolveApplicationToAgentIds(application);
|
||||
|
||||
SearchRequest request = new SearchRequest(
|
||||
status, timeFrom, timeTo,
|
||||
@@ -65,7 +65,7 @@ public class SearchController {
|
||||
correlationId,
|
||||
text, null, null, null,
|
||||
routeId, agentId, processorType,
|
||||
group, agentIds,
|
||||
application, agentIds,
|
||||
offset, limit,
|
||||
sortField, sortDir
|
||||
);
|
||||
@@ -77,11 +77,11 @@ public class SearchController {
|
||||
@Operation(summary = "Advanced search with all filters")
|
||||
public ResponseEntity<SearchResult<ExecutionSummary>> searchPost(
|
||||
@RequestBody SearchRequest request) {
|
||||
// Resolve group to agentIds if group is specified but agentIds is not
|
||||
// Resolve application to agentIds if application is specified but agentIds is not
|
||||
SearchRequest resolved = request;
|
||||
if (request.group() != null && !request.group().isBlank()
|
||||
if (request.application() != null && !request.application().isBlank()
|
||||
&& (request.agentIds() == null || request.agentIds().isEmpty())) {
|
||||
resolved = request.withAgentIds(resolveGroupToAgentIds(request.group()));
|
||||
resolved = request.withAgentIds(resolveApplicationToAgentIds(request.application()));
|
||||
}
|
||||
return ResponseEntity.ok(searchService.search(resolved));
|
||||
}
|
||||
@@ -92,12 +92,15 @@ public class SearchController {
|
||||
@RequestParam Instant from,
|
||||
@RequestParam(required = false) Instant to,
|
||||
@RequestParam(required = false) String routeId,
|
||||
@RequestParam(required = false) String group) {
|
||||
@RequestParam(required = false) String application) {
|
||||
Instant end = to != null ? to : Instant.now();
|
||||
List<String> agentIds = resolveGroupToAgentIds(group);
|
||||
if (routeId == null && agentIds == null) {
|
||||
if (routeId == null && application == null) {
|
||||
return ResponseEntity.ok(searchService.stats(from, end));
|
||||
}
|
||||
if (routeId == null) {
|
||||
return ResponseEntity.ok(searchService.statsForApp(from, end, application));
|
||||
}
|
||||
List<String> agentIds = resolveApplicationToAgentIds(application);
|
||||
return ResponseEntity.ok(searchService.stats(from, end, routeId, agentIds));
|
||||
}
|
||||
|
||||
@@ -108,9 +111,15 @@ public class SearchController {
|
||||
@RequestParam(required = false) Instant to,
|
||||
@RequestParam(defaultValue = "24") int buckets,
|
||||
@RequestParam(required = false) String routeId,
|
||||
@RequestParam(required = false) String group) {
|
||||
@RequestParam(required = false) String application) {
|
||||
Instant end = to != null ? to : Instant.now();
|
||||
List<String> agentIds = resolveGroupToAgentIds(group);
|
||||
if (routeId == null && application == null) {
|
||||
return ResponseEntity.ok(searchService.timeseries(from, end, buckets));
|
||||
}
|
||||
if (routeId == null) {
|
||||
return ResponseEntity.ok(searchService.timeseriesForApp(from, end, buckets, application));
|
||||
}
|
||||
List<String> agentIds = resolveApplicationToAgentIds(application);
|
||||
if (routeId == null && agentIds == null) {
|
||||
return ResponseEntity.ok(searchService.timeseries(from, end, buckets));
|
||||
}
|
||||
@@ -118,14 +127,14 @@ public class SearchController {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an application group name to agent IDs.
|
||||
* Returns null if group is null/blank (no filtering).
|
||||
* Resolve an application name to agent IDs.
|
||||
* Returns null if application is null/blank (no filtering).
|
||||
*/
|
||||
private List<String> resolveGroupToAgentIds(String group) {
|
||||
if (group == null || group.isBlank()) {
|
||||
private List<String> resolveApplicationToAgentIds(String application) {
|
||||
if (application == null || application.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
return registryService.findByGroup(group).stream()
|
||||
return registryService.findByApplication(application).stream()
|
||||
.map(AgentInfo::id)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import java.util.Map;
|
||||
public record AgentInstanceResponse(
|
||||
@NotNull String id,
|
||||
@NotNull String name,
|
||||
@NotNull String group,
|
||||
@NotNull String application,
|
||||
@NotNull String status,
|
||||
@NotNull List<String> routeIds,
|
||||
@NotNull Instant registeredAt,
|
||||
@@ -29,7 +29,7 @@ public record AgentInstanceResponse(
|
||||
public static AgentInstanceResponse from(AgentInfo info) {
|
||||
long uptime = Duration.between(info.registeredAt(), Instant.now()).toSeconds();
|
||||
return new AgentInstanceResponse(
|
||||
info.id(), info.name(), info.group(),
|
||||
info.id(), info.name(), info.application(),
|
||||
info.state().name(), info.routeIds(),
|
||||
info.registeredAt(), info.lastHeartbeat(),
|
||||
info.version(), info.capabilities(),
|
||||
@@ -41,7 +41,7 @@ public record AgentInstanceResponse(
|
||||
|
||||
public AgentInstanceResponse withMetrics(double tps, double errorRate, int activeRoutes) {
|
||||
return new AgentInstanceResponse(
|
||||
id, name, group, status, routeIds, registeredAt, lastHeartbeat,
|
||||
id, name, application, status, routeIds, registeredAt, lastHeartbeat,
|
||||
version, capabilities,
|
||||
tps, errorRate, activeRoutes, totalRoutes, uptimeSeconds
|
||||
);
|
||||
|
||||
@@ -10,7 +10,7 @@ import java.util.Map;
|
||||
public record AgentRegistrationRequest(
|
||||
@NotNull String agentId,
|
||||
@NotNull String name,
|
||||
@Schema(defaultValue = "default") String group,
|
||||
@Schema(defaultValue = "default") String application,
|
||||
String version,
|
||||
List<String> routeIds,
|
||||
Map<String, Object> capabilities
|
||||
|
||||
@@ -288,7 +288,7 @@ public class OpenSearchIndex implements SearchIndex {
|
||||
map.put("execution_id", doc.executionId());
|
||||
map.put("route_id", doc.routeId());
|
||||
map.put("agent_id", doc.agentId());
|
||||
map.put("group_name", doc.groupName());
|
||||
map.put("application_name", doc.applicationName());
|
||||
map.put("status", doc.status());
|
||||
map.put("correlation_id", doc.correlationId());
|
||||
map.put("exchange_id", doc.exchangeId());
|
||||
@@ -323,6 +323,7 @@ public class OpenSearchIndex implements SearchIndex {
|
||||
(String) src.get("execution_id"),
|
||||
(String) src.get("route_id"),
|
||||
(String) src.get("agent_id"),
|
||||
(String) src.get("application_name"),
|
||||
(String) src.get("status"),
|
||||
src.get("start_time") != null ? Instant.parse((String) src.get("start_time")) : null,
|
||||
src.get("end_time") != null ? Instant.parse((String) src.get("end_time")) : null,
|
||||
|
||||
@@ -60,13 +60,13 @@ public class JwtServiceImpl implements JwtService {
|
||||
}
|
||||
|
||||
@Override
|
||||
public String createAccessToken(String subject, String group, List<String> roles) {
|
||||
return createToken(subject, group, roles, "access", properties.getAccessTokenExpiryMs());
|
||||
public String createAccessToken(String subject, String application, List<String> roles) {
|
||||
return createToken(subject, application, roles, "access", properties.getAccessTokenExpiryMs());
|
||||
}
|
||||
|
||||
@Override
|
||||
public String createRefreshToken(String subject, String group, List<String> roles) {
|
||||
return createToken(subject, group, roles, "refresh", properties.getRefreshTokenExpiryMs());
|
||||
public String createRefreshToken(String subject, String application, List<String> roles) {
|
||||
return createToken(subject, application, roles, "refresh", properties.getRefreshTokenExpiryMs());
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -84,12 +84,12 @@ public class JwtServiceImpl implements JwtService {
|
||||
return validateAccessToken(token).subject();
|
||||
}
|
||||
|
||||
private String createToken(String subject, String group, List<String> roles,
|
||||
private String createToken(String subject, String application, List<String> roles,
|
||||
String type, long expiryMs) {
|
||||
Instant now = Instant.now();
|
||||
JWTClaimsSet claims = new JWTClaimsSet.Builder()
|
||||
.subject(subject)
|
||||
.claim("group", group)
|
||||
.claim("group", application)
|
||||
.claim("type", type)
|
||||
.claim("roles", roles)
|
||||
.issueTime(Date.from(now))
|
||||
@@ -132,7 +132,7 @@ public class JwtServiceImpl implements JwtService {
|
||||
throw new InvalidTokenException("Token has no subject");
|
||||
}
|
||||
|
||||
String group = claims.getStringClaim("group");
|
||||
String application = claims.getStringClaim("group");
|
||||
|
||||
// Extract roles — may be absent in legacy tokens
|
||||
List<String> roles;
|
||||
@@ -145,7 +145,7 @@ public class JwtServiceImpl implements JwtService {
|
||||
roles = List.of();
|
||||
}
|
||||
|
||||
return new JwtValidationResult(subject, group, roles);
|
||||
return new JwtValidationResult(subject, application, roles);
|
||||
} catch (ParseException e) {
|
||||
throw new InvalidTokenException("Failed to parse JWT", e);
|
||||
} catch (JOSEException e) {
|
||||
|
||||
@@ -24,7 +24,7 @@ public class PostgresExecutionStore implements ExecutionStore {
|
||||
@Override
|
||||
public void upsert(ExecutionRecord execution) {
|
||||
jdbc.update("""
|
||||
INSERT INTO executions (execution_id, route_id, agent_id, group_name,
|
||||
INSERT INTO executions (execution_id, route_id, agent_id, application_name,
|
||||
status, correlation_id, exchange_id, start_time, end_time,
|
||||
duration_ms, error_message, error_stacktrace, diagram_content_hash,
|
||||
created_at, updated_at)
|
||||
@@ -45,7 +45,7 @@ public class PostgresExecutionStore implements ExecutionStore {
|
||||
updated_at = now()
|
||||
""",
|
||||
execution.executionId(), execution.routeId(), execution.agentId(),
|
||||
execution.groupName(), execution.status(), execution.correlationId(),
|
||||
execution.applicationName(), execution.status(), execution.correlationId(),
|
||||
execution.exchangeId(),
|
||||
Timestamp.from(execution.startTime()),
|
||||
execution.endTime() != null ? Timestamp.from(execution.endTime()) : null,
|
||||
@@ -55,11 +55,11 @@ public class PostgresExecutionStore implements ExecutionStore {
|
||||
|
||||
@Override
|
||||
public void upsertProcessors(String executionId, Instant startTime,
|
||||
String groupName, String routeId,
|
||||
String applicationName, String routeId,
|
||||
List<ProcessorRecord> processors) {
|
||||
jdbc.batchUpdate("""
|
||||
INSERT INTO processor_executions (execution_id, processor_id, processor_type,
|
||||
diagram_node_id, group_name, route_id, depth, parent_processor_id,
|
||||
diagram_node_id, application_name, route_id, depth, parent_processor_id,
|
||||
status, start_time, end_time, duration_ms, error_message, error_stacktrace,
|
||||
input_body, output_body, input_headers, output_headers)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?::jsonb, ?::jsonb)
|
||||
@@ -76,7 +76,7 @@ public class PostgresExecutionStore implements ExecutionStore {
|
||||
""",
|
||||
processors.stream().map(p -> new Object[]{
|
||||
p.executionId(), p.processorId(), p.processorType(),
|
||||
p.diagramNodeId(), p.groupName(), p.routeId(),
|
||||
p.diagramNodeId(), p.applicationName(), p.routeId(),
|
||||
p.depth(), p.parentProcessorId(), p.status(),
|
||||
Timestamp.from(p.startTime()),
|
||||
p.endTime() != null ? Timestamp.from(p.endTime()) : null,
|
||||
@@ -103,7 +103,7 @@ public class PostgresExecutionStore implements ExecutionStore {
|
||||
private static final RowMapper<ExecutionRecord> EXECUTION_MAPPER = (rs, rowNum) ->
|
||||
new ExecutionRecord(
|
||||
rs.getString("execution_id"), rs.getString("route_id"),
|
||||
rs.getString("agent_id"), rs.getString("group_name"),
|
||||
rs.getString("agent_id"), rs.getString("application_name"),
|
||||
rs.getString("status"), rs.getString("correlation_id"),
|
||||
rs.getString("exchange_id"),
|
||||
toInstant(rs, "start_time"), toInstant(rs, "end_time"),
|
||||
@@ -115,7 +115,7 @@ public class PostgresExecutionStore implements ExecutionStore {
|
||||
new ProcessorRecord(
|
||||
rs.getString("execution_id"), rs.getString("processor_id"),
|
||||
rs.getString("processor_type"), rs.getString("diagram_node_id"),
|
||||
rs.getString("group_name"), rs.getString("route_id"),
|
||||
rs.getString("application_name"), rs.getString("route_id"),
|
||||
rs.getInt("depth"), rs.getString("parent_processor_id"),
|
||||
rs.getString("status"),
|
||||
toInstant(rs, "start_time"), toInstant(rs, "end_time"),
|
||||
|
||||
@@ -29,9 +29,9 @@ public class PostgresStatsStore implements StatsStore {
|
||||
}
|
||||
|
||||
@Override
|
||||
public ExecutionStats statsForApp(Instant from, Instant to, String groupName) {
|
||||
public ExecutionStats statsForApp(Instant from, Instant to, String applicationName) {
|
||||
return queryStats("stats_1m_app", from, to, List.of(
|
||||
new Filter("group_name", groupName)));
|
||||
new Filter("application_name", applicationName)));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -56,9 +56,9 @@ public class PostgresStatsStore implements StatsStore {
|
||||
}
|
||||
|
||||
@Override
|
||||
public StatsTimeseries timeseriesForApp(Instant from, Instant to, int bucketCount, String groupName) {
|
||||
public StatsTimeseries timeseriesForApp(Instant from, Instant to, int bucketCount, String applicationName) {
|
||||
return queryTimeseries("stats_1m_app", from, to, bucketCount, List.of(
|
||||
new Filter("group_name", groupName)), true);
|
||||
new Filter("application_name", applicationName)), true);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -13,6 +13,7 @@ CREATE TABLE users (
|
||||
provider TEXT NOT NULL,
|
||||
email TEXT,
|
||||
display_name TEXT,
|
||||
password_hash TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
@@ -39,12 +40,20 @@ CREATE TABLE groups (
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Built-in Admins group
|
||||
INSERT INTO groups (id, name) VALUES
|
||||
('00000000-0000-0000-0000-000000000010', 'Admins');
|
||||
|
||||
CREATE TABLE group_roles (
|
||||
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (group_id, role_id)
|
||||
);
|
||||
|
||||
-- Assign ADMIN role to Admins group
|
||||
INSERT INTO group_roles (group_id, role_id) VALUES
|
||||
('00000000-0000-0000-0000-000000000010', '00000000-0000-0000-0000-000000000004');
|
||||
|
||||
CREATE TABLE user_groups (
|
||||
user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
@@ -70,7 +79,7 @@ CREATE TABLE executions (
|
||||
execution_id TEXT NOT NULL,
|
||||
route_id TEXT NOT NULL,
|
||||
agent_id TEXT NOT NULL,
|
||||
group_name TEXT NOT NULL,
|
||||
application_name TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
correlation_id TEXT,
|
||||
exchange_id TEXT,
|
||||
@@ -89,7 +98,7 @@ SELECT create_hypertable('executions', 'start_time', chunk_time_interval => INTE
|
||||
|
||||
CREATE INDEX idx_executions_agent_time ON executions (agent_id, start_time DESC);
|
||||
CREATE INDEX idx_executions_route_time ON executions (route_id, start_time DESC);
|
||||
CREATE INDEX idx_executions_group_time ON executions (group_name, start_time DESC);
|
||||
CREATE INDEX idx_executions_app_time ON executions (application_name, start_time DESC);
|
||||
CREATE INDEX idx_executions_correlation ON executions (correlation_id);
|
||||
|
||||
CREATE TABLE processor_executions (
|
||||
@@ -98,7 +107,7 @@ CREATE TABLE processor_executions (
|
||||
processor_id TEXT NOT NULL,
|
||||
processor_type TEXT NOT NULL,
|
||||
diagram_node_id TEXT,
|
||||
group_name TEXT NOT NULL,
|
||||
application_name TEXT NOT NULL,
|
||||
route_id TEXT NOT NULL,
|
||||
depth INT NOT NULL,
|
||||
parent_processor_id TEXT,
|
||||
@@ -153,22 +162,56 @@ CREATE TABLE route_diagrams (
|
||||
CREATE INDEX idx_diagrams_route_agent ON route_diagrams (route_id, agent_id);
|
||||
|
||||
-- =============================================================
|
||||
-- OIDC configuration
|
||||
-- Agent events
|
||||
-- =============================================================
|
||||
|
||||
CREATE TABLE oidc_config (
|
||||
config_id TEXT PRIMARY KEY DEFAULT 'default',
|
||||
enabled BOOLEAN NOT NULL DEFAULT false,
|
||||
issuer_uri TEXT,
|
||||
client_id TEXT,
|
||||
client_secret TEXT,
|
||||
roles_claim TEXT,
|
||||
default_roles TEXT[] NOT NULL DEFAULT '{}',
|
||||
auto_signup BOOLEAN DEFAULT false,
|
||||
display_name_claim TEXT,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
CREATE TABLE agent_events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
agent_id TEXT NOT NULL,
|
||||
app_id TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
detail TEXT,
|
||||
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_agent_events_agent ON agent_events(agent_id, timestamp DESC);
|
||||
CREATE INDEX idx_agent_events_app ON agent_events(app_id, timestamp DESC);
|
||||
CREATE INDEX idx_agent_events_time ON agent_events(timestamp DESC);
|
||||
|
||||
-- =============================================================
|
||||
-- Server configuration
|
||||
-- =============================================================
|
||||
|
||||
CREATE TABLE server_config (
|
||||
config_key TEXT PRIMARY KEY,
|
||||
config_val JSONB NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_by TEXT
|
||||
);
|
||||
|
||||
-- =============================================================
|
||||
-- Admin
|
||||
-- =============================================================
|
||||
|
||||
CREATE TABLE audit_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
timestamp TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
username TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
target TEXT,
|
||||
detail JSONB,
|
||||
result TEXT NOT NULL,
|
||||
ip_address TEXT,
|
||||
user_agent TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX idx_audit_log_timestamp ON audit_log (timestamp DESC);
|
||||
CREATE INDEX idx_audit_log_username ON audit_log (username);
|
||||
CREATE INDEX idx_audit_log_category ON audit_log (category);
|
||||
CREATE INDEX idx_audit_log_action ON audit_log (action);
|
||||
CREATE INDEX idx_audit_log_target ON audit_log (target);
|
||||
|
||||
-- =============================================================
|
||||
-- Continuous aggregates
|
||||
-- =============================================================
|
||||
@@ -188,16 +231,12 @@ WHERE status IS NOT NULL
|
||||
GROUP BY bucket
|
||||
WITH NO DATA;
|
||||
|
||||
SELECT add_continuous_aggregate_policy('stats_1m_all',
|
||||
start_offset => INTERVAL '1 hour',
|
||||
end_offset => INTERVAL '1 minute',
|
||||
schedule_interval => INTERVAL '1 minute');
|
||||
|
||||
CREATE MATERIALIZED VIEW stats_1m_app
|
||||
WITH (timescaledb.continuous, timescaledb.materialized_only = false) AS
|
||||
SELECT
|
||||
time_bucket('1 minute', start_time) AS bucket,
|
||||
group_name,
|
||||
application_name,
|
||||
COUNT(*) AS total_count,
|
||||
COUNT(*) FILTER (WHERE status = 'FAILED') AS failed_count,
|
||||
COUNT(*) FILTER (WHERE status = 'RUNNING') AS running_count,
|
||||
@@ -206,19 +245,15 @@ SELECT
|
||||
approx_percentile(0.99, percentile_agg(duration_ms::DOUBLE PRECISION)) AS p99_duration
|
||||
FROM executions
|
||||
WHERE status IS NOT NULL
|
||||
GROUP BY bucket, group_name
|
||||
GROUP BY bucket, application_name
|
||||
WITH NO DATA;
|
||||
|
||||
SELECT add_continuous_aggregate_policy('stats_1m_app',
|
||||
start_offset => INTERVAL '1 hour',
|
||||
end_offset => INTERVAL '1 minute',
|
||||
schedule_interval => INTERVAL '1 minute');
|
||||
|
||||
CREATE MATERIALIZED VIEW stats_1m_route
|
||||
WITH (timescaledb.continuous, timescaledb.materialized_only = false) AS
|
||||
SELECT
|
||||
time_bucket('1 minute', start_time) AS bucket,
|
||||
group_name,
|
||||
application_name,
|
||||
route_id,
|
||||
COUNT(*) AS total_count,
|
||||
COUNT(*) FILTER (WHERE status = 'FAILED') AS failed_count,
|
||||
@@ -228,19 +263,15 @@ SELECT
|
||||
approx_percentile(0.99, percentile_agg(duration_ms::DOUBLE PRECISION)) AS p99_duration
|
||||
FROM executions
|
||||
WHERE status IS NOT NULL
|
||||
GROUP BY bucket, group_name, route_id
|
||||
GROUP BY bucket, application_name, route_id
|
||||
WITH NO DATA;
|
||||
|
||||
SELECT add_continuous_aggregate_policy('stats_1m_route',
|
||||
start_offset => INTERVAL '1 hour',
|
||||
end_offset => INTERVAL '1 minute',
|
||||
schedule_interval => INTERVAL '1 minute');
|
||||
|
||||
CREATE MATERIALIZED VIEW stats_1m_processor
|
||||
WITH (timescaledb.continuous, timescaledb.materialized_only = false) AS
|
||||
SELECT
|
||||
time_bucket('1 minute', start_time) AS bucket,
|
||||
group_name,
|
||||
application_name,
|
||||
route_id,
|
||||
processor_type,
|
||||
COUNT(*) AS total_count,
|
||||
@@ -249,41 +280,24 @@ SELECT
|
||||
MAX(duration_ms) AS duration_max,
|
||||
approx_percentile(0.99, percentile_agg(duration_ms::DOUBLE PRECISION)) AS p99_duration
|
||||
FROM processor_executions
|
||||
GROUP BY bucket, group_name, route_id, processor_type
|
||||
GROUP BY bucket, application_name, route_id, processor_type
|
||||
WITH NO DATA;
|
||||
|
||||
SELECT add_continuous_aggregate_policy('stats_1m_processor',
|
||||
start_offset => INTERVAL '1 hour',
|
||||
end_offset => INTERVAL '1 minute',
|
||||
schedule_interval => INTERVAL '1 minute');
|
||||
|
||||
-- =============================================================
|
||||
-- Admin
|
||||
-- =============================================================
|
||||
CREATE MATERIALIZED VIEW stats_1m_processor_detail
|
||||
WITH (timescaledb.continuous, timescaledb.materialized_only = false) AS
|
||||
SELECT
|
||||
time_bucket('1 minute', start_time) AS bucket,
|
||||
application_name,
|
||||
route_id,
|
||||
processor_id,
|
||||
processor_type,
|
||||
COUNT(*) AS total_count,
|
||||
COUNT(*) FILTER (WHERE status = 'FAILED') AS failed_count,
|
||||
SUM(duration_ms) AS duration_sum,
|
||||
MAX(duration_ms) AS duration_max,
|
||||
approx_percentile(0.99, percentile_agg(duration_ms)) AS p99_duration
|
||||
FROM processor_executions
|
||||
GROUP BY bucket, application_name, route_id, processor_id, processor_type
|
||||
WITH NO DATA;
|
||||
|
||||
CREATE TABLE admin_thresholds (
|
||||
id INTEGER PRIMARY KEY DEFAULT 1,
|
||||
config JSONB NOT NULL DEFAULT '{}',
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_by TEXT NOT NULL,
|
||||
CONSTRAINT single_row CHECK (id = 1)
|
||||
);
|
||||
|
||||
CREATE TABLE audit_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
timestamp TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
username TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
target TEXT,
|
||||
detail JSONB,
|
||||
result TEXT NOT NULL,
|
||||
ip_address TEXT,
|
||||
user_agent TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX idx_audit_log_timestamp ON audit_log (timestamp DESC);
|
||||
CREATE INDEX idx_audit_log_username ON audit_log (username);
|
||||
CREATE INDEX idx_audit_log_category ON audit_log (category);
|
||||
CREATE INDEX idx_audit_log_action ON audit_log (action);
|
||||
CREATE INDEX idx_audit_log_target ON audit_log (target);
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
-- Built-in Admins group
|
||||
INSERT INTO groups (id, name) VALUES
|
||||
('00000000-0000-0000-0000-000000000010', 'Admins');
|
||||
|
||||
-- Assign ADMIN role to Admins group
|
||||
INSERT INTO group_roles (group_id, role_id) VALUES
|
||||
('00000000-0000-0000-0000-000000000010', '00000000-0000-0000-0000-000000000004');
|
||||
@@ -0,0 +1,38 @@
|
||||
-- V2__policies.sql - TimescaleDB policies (must run outside transaction)
|
||||
-- flyway:executeInTransaction=false
|
||||
|
||||
-- Agent metrics retention & compression
|
||||
ALTER TABLE agent_metrics SET (timescaledb.compress);
|
||||
SELECT add_retention_policy('agent_metrics', INTERVAL '90 days', if_not_exists => true);
|
||||
SELECT add_compression_policy('agent_metrics', INTERVAL '7 days', if_not_exists => true);
|
||||
|
||||
-- Continuous aggregate refresh policies
|
||||
SELECT add_continuous_aggregate_policy('stats_1m_all',
|
||||
start_offset => INTERVAL '1 hour',
|
||||
end_offset => INTERVAL '1 minute',
|
||||
schedule_interval => INTERVAL '1 minute',
|
||||
if_not_exists => true);
|
||||
|
||||
SELECT add_continuous_aggregate_policy('stats_1m_app',
|
||||
start_offset => INTERVAL '1 hour',
|
||||
end_offset => INTERVAL '1 minute',
|
||||
schedule_interval => INTERVAL '1 minute',
|
||||
if_not_exists => true);
|
||||
|
||||
SELECT add_continuous_aggregate_policy('stats_1m_route',
|
||||
start_offset => INTERVAL '1 hour',
|
||||
end_offset => INTERVAL '1 minute',
|
||||
schedule_interval => INTERVAL '1 minute',
|
||||
if_not_exists => true);
|
||||
|
||||
SELECT add_continuous_aggregate_policy('stats_1m_processor',
|
||||
start_offset => INTERVAL '1 hour',
|
||||
end_offset => INTERVAL '1 minute',
|
||||
schedule_interval => INTERVAL '1 minute',
|
||||
if_not_exists => true);
|
||||
|
||||
SELECT add_continuous_aggregate_policy('stats_1m_processor_detail',
|
||||
start_offset => INTERVAL '1 hour',
|
||||
end_offset => INTERVAL '1 minute',
|
||||
schedule_interval => INTERVAL '1 minute',
|
||||
if_not_exists => true);
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE users ADD COLUMN password_hash TEXT;
|
||||
@@ -1,36 +0,0 @@
|
||||
-- =============================================================
|
||||
-- Consolidate oidc_config + admin_thresholds → server_config
|
||||
-- =============================================================
|
||||
|
||||
CREATE TABLE server_config (
|
||||
config_key TEXT PRIMARY KEY,
|
||||
config_val JSONB NOT NULL,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_by TEXT
|
||||
);
|
||||
|
||||
-- Migrate existing oidc_config row (if any)
|
||||
INSERT INTO server_config (config_key, config_val, updated_at)
|
||||
SELECT 'oidc',
|
||||
jsonb_build_object(
|
||||
'enabled', enabled,
|
||||
'issuerUri', issuer_uri,
|
||||
'clientId', client_id,
|
||||
'clientSecret', client_secret,
|
||||
'rolesClaim', roles_claim,
|
||||
'defaultRoles', to_jsonb(default_roles),
|
||||
'autoSignup', auto_signup,
|
||||
'displayNameClaim', display_name_claim
|
||||
),
|
||||
updated_at
|
||||
FROM oidc_config
|
||||
WHERE config_id = 'default';
|
||||
|
||||
-- Migrate existing admin_thresholds row (if any)
|
||||
INSERT INTO server_config (config_key, config_val, updated_at, updated_by)
|
||||
SELECT 'thresholds', config, updated_at, updated_by
|
||||
FROM admin_thresholds
|
||||
WHERE id = 1;
|
||||
|
||||
DROP TABLE oidc_config;
|
||||
DROP TABLE admin_thresholds;
|
||||
@@ -1,13 +0,0 @@
|
||||
-- Agent lifecycle events for tracking registration, state transitions, etc.
|
||||
CREATE TABLE agent_events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
agent_id TEXT NOT NULL,
|
||||
app_id TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
detail TEXT,
|
||||
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_agent_events_agent ON agent_events(agent_id, timestamp DESC);
|
||||
CREATE INDEX idx_agent_events_app ON agent_events(app_id, timestamp DESC);
|
||||
CREATE INDEX idx_agent_events_time ON agent_events(timestamp DESC);
|
||||
@@ -1,6 +0,0 @@
|
||||
-- Retention: drop agent_metrics chunks older than 90 days
|
||||
SELECT add_retention_policy('agent_metrics', INTERVAL '90 days', if_not_exists => true);
|
||||
|
||||
-- Compression: compress agent_metrics chunks older than 7 days
|
||||
ALTER TABLE agent_metrics SET (timescaledb.compress);
|
||||
SELECT add_compression_policy('agent_metrics', INTERVAL '7 days', if_not_exists => true);
|
||||
@@ -1,21 +0,0 @@
|
||||
-- V7: Per-processor-id continuous aggregate for route detail page
|
||||
CREATE MATERIALIZED VIEW stats_1m_processor_detail
|
||||
WITH (timescaledb.continuous) AS
|
||||
SELECT
|
||||
time_bucket('1 minute', start_time) AS bucket,
|
||||
group_name,
|
||||
route_id,
|
||||
processor_id,
|
||||
processor_type,
|
||||
COUNT(*) AS total_count,
|
||||
COUNT(*) FILTER (WHERE status = 'FAILED') AS failed_count,
|
||||
SUM(duration_ms) AS duration_sum,
|
||||
MAX(duration_ms) AS duration_max,
|
||||
approx_percentile(0.99, percentile_agg(duration_ms)) AS p99_duration
|
||||
FROM processor_executions
|
||||
GROUP BY bucket, group_name, route_id, processor_id, processor_type;
|
||||
|
||||
SELECT add_continuous_aggregate_policy('stats_1m_processor_detail',
|
||||
start_offset => INTERVAL '1 hour',
|
||||
end_offset => INTERVAL '1 minute',
|
||||
schedule_interval => INTERVAL '1 minute');
|
||||
@@ -37,8 +37,8 @@ public class TestSecurityHelper {
|
||||
/**
|
||||
* Returns a valid JWT access token with the given roles (no agent registration).
|
||||
*/
|
||||
public String createToken(String subject, String group, List<String> roles) {
|
||||
return jwtService.createAccessToken(subject, group, roles);
|
||||
public String createToken(String subject, String application, List<String> roles) {
|
||||
return jwtService.createAccessToken(subject, application, roles);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -38,17 +38,17 @@ class AgentCommandControllerIT extends AbstractPostgresIT {
|
||||
operatorJwt = securityHelper.operatorToken();
|
||||
}
|
||||
|
||||
private ResponseEntity<String> registerAgent(String agentId, String name, String group) {
|
||||
private ResponseEntity<String> registerAgent(String agentId, String name, String application) {
|
||||
String json = """
|
||||
{
|
||||
"agentId": "%s",
|
||||
"name": "%s",
|
||||
"group": "%s",
|
||||
"application": "%s",
|
||||
"version": "1.0.0",
|
||||
"routeIds": ["route-1"],
|
||||
"capabilities": {}
|
||||
}
|
||||
""".formatted(agentId, name, group);
|
||||
""".formatted(agentId, name, application);
|
||||
|
||||
return restTemplate.postForEntity(
|
||||
"/api/v1/agents/register",
|
||||
|
||||
@@ -41,7 +41,7 @@ class AgentRegistrationControllerIT extends AbstractPostgresIT {
|
||||
{
|
||||
"agentId": "%s",
|
||||
"name": "%s",
|
||||
"group": "test-group",
|
||||
"application": "test-group",
|
||||
"version": "1.0.0",
|
||||
"routeIds": ["route-1", "route-2"],
|
||||
"capabilities": {"tracing": true}
|
||||
|
||||
@@ -53,17 +53,17 @@ class AgentSseControllerIT extends AbstractPostgresIT {
|
||||
operatorJwt = securityHelper.operatorToken();
|
||||
}
|
||||
|
||||
private ResponseEntity<String> registerAgent(String agentId, String name, String group) {
|
||||
private ResponseEntity<String> registerAgent(String agentId, String name, String application) {
|
||||
String json = """
|
||||
{
|
||||
"agentId": "%s",
|
||||
"name": "%s",
|
||||
"group": "%s",
|
||||
"application": "%s",
|
||||
"version": "1.0.0",
|
||||
"routeIds": ["route-1"],
|
||||
"capabilities": {}
|
||||
}
|
||||
""".formatted(agentId, name, group);
|
||||
""".formatted(agentId, name, application);
|
||||
|
||||
return restTemplate.postForEntity(
|
||||
"/api/v1/agents/register",
|
||||
|
||||
@@ -29,7 +29,7 @@ class BootstrapTokenIT extends AbstractPostgresIT {
|
||||
{
|
||||
"agentId": "bootstrap-test-agent",
|
||||
"name": "Bootstrap Test",
|
||||
"group": "test-group",
|
||||
"application": "test-group",
|
||||
"version": "1.0.0",
|
||||
"routeIds": [],
|
||||
"capabilities": {}
|
||||
@@ -97,7 +97,7 @@ class BootstrapTokenIT extends AbstractPostgresIT {
|
||||
{
|
||||
"agentId": "bootstrap-test-previous",
|
||||
"name": "Previous Token Test",
|
||||
"group": "test-group",
|
||||
"application": "test-group",
|
||||
"version": "1.0.0",
|
||||
"routeIds": [],
|
||||
"capabilities": {}
|
||||
|
||||
@@ -39,7 +39,7 @@ class JwtRefreshIT extends AbstractPostgresIT {
|
||||
{
|
||||
"agentId": "%s",
|
||||
"name": "Refresh Test Agent",
|
||||
"group": "test-group",
|
||||
"application": "test-group",
|
||||
"version": "1.0.0",
|
||||
"routeIds": [],
|
||||
"capabilities": {}
|
||||
|
||||
@@ -78,7 +78,7 @@ class JwtServiceTest {
|
||||
String token = jwtService.createAccessToken("user:admin", "user", roles);
|
||||
JwtService.JwtValidationResult result = jwtService.validateAccessToken(token);
|
||||
assertEquals("user:admin", result.subject());
|
||||
assertEquals("user", result.group());
|
||||
assertEquals("user", result.application());
|
||||
assertEquals(roles, result.roles());
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ class JwtServiceTest {
|
||||
String token = jwtService.createRefreshToken("agent-1", "default", roles);
|
||||
JwtService.JwtValidationResult result = jwtService.validateRefreshToken(token);
|
||||
assertEquals("agent-1", result.subject());
|
||||
assertEquals("default", result.group());
|
||||
assertEquals("default", result.application());
|
||||
assertEquals(roles, result.roles());
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ class RegistrationSecurityIT extends AbstractPostgresIT {
|
||||
{
|
||||
"agentId": "%s",
|
||||
"name": "Security Test Agent",
|
||||
"group": "test-group",
|
||||
"application": "test-group",
|
||||
"version": "1.0.0",
|
||||
"routeIds": [],
|
||||
"capabilities": {}
|
||||
|
||||
@@ -90,7 +90,7 @@ class SseSigningIT extends AbstractPostgresIT {
|
||||
{
|
||||
"agentId": "%s",
|
||||
"name": "SSE Signing Test Agent",
|
||||
"group": "test-group",
|
||||
"application": "test-group",
|
||||
"version": "1.0.0",
|
||||
"routeIds": ["route-1"],
|
||||
"capabilities": {}
|
||||
|
||||
@@ -54,10 +54,10 @@ class PostgresStatsStoreIT extends AbstractPostgresIT {
|
||||
assertFalse(ts.buckets().isEmpty());
|
||||
}
|
||||
|
||||
private void insertExecution(String id, String routeId, String groupName,
|
||||
private void insertExecution(String id, String routeId, String applicationName,
|
||||
String status, Instant startTime, long durationMs) {
|
||||
executionStore.upsert(new ExecutionRecord(
|
||||
id, routeId, "agent-1", groupName, status, null, null,
|
||||
id, routeId, "agent-1", applicationName, status, null, null,
|
||||
startTime, startTime.plusMillis(durationMs), durationMs,
|
||||
status.equals("FAILED") ? "error" : null, null, null));
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import java.util.Map;
|
||||
*
|
||||
* @param id agent-provided persistent identifier
|
||||
* @param name human-readable agent name
|
||||
* @param group logical grouping (e.g., "order-service-prod")
|
||||
* @param application application name (e.g., "order-service-prod")
|
||||
* @param version agent software version
|
||||
* @param routeIds list of Camel route IDs managed by this agent
|
||||
* @param capabilities agent-declared capabilities (free-form)
|
||||
@@ -25,7 +25,7 @@ import java.util.Map;
|
||||
public record AgentInfo(
|
||||
String id,
|
||||
String name,
|
||||
String group,
|
||||
String application,
|
||||
String version,
|
||||
List<String> routeIds,
|
||||
Map<String, Object> capabilities,
|
||||
@@ -36,28 +36,28 @@ public record AgentInfo(
|
||||
) {
|
||||
|
||||
public AgentInfo withState(AgentState newState) {
|
||||
return new AgentInfo(id, name, group, version, routeIds, capabilities,
|
||||
return new AgentInfo(id, name, application, version, routeIds, capabilities,
|
||||
newState, registeredAt, lastHeartbeat, staleTransitionTime);
|
||||
}
|
||||
|
||||
public AgentInfo withLastHeartbeat(Instant newLastHeartbeat) {
|
||||
return new AgentInfo(id, name, group, version, routeIds, capabilities,
|
||||
return new AgentInfo(id, name, application, version, routeIds, capabilities,
|
||||
state, registeredAt, newLastHeartbeat, staleTransitionTime);
|
||||
}
|
||||
|
||||
public AgentInfo withRegisteredAt(Instant newRegisteredAt) {
|
||||
return new AgentInfo(id, name, group, version, routeIds, capabilities,
|
||||
return new AgentInfo(id, name, application, version, routeIds, capabilities,
|
||||
state, newRegisteredAt, lastHeartbeat, staleTransitionTime);
|
||||
}
|
||||
|
||||
public AgentInfo withStaleTransitionTime(Instant newStaleTransitionTime) {
|
||||
return new AgentInfo(id, name, group, version, routeIds, capabilities,
|
||||
return new AgentInfo(id, name, application, version, routeIds, capabilities,
|
||||
state, registeredAt, lastHeartbeat, newStaleTransitionTime);
|
||||
}
|
||||
|
||||
public AgentInfo withMetadata(String name, String group, String version,
|
||||
public AgentInfo withMetadata(String name, String application, String version,
|
||||
List<String> routeIds, Map<String, Object> capabilities) {
|
||||
return new AgentInfo(id, name, group, version, routeIds, capabilities,
|
||||
return new AgentInfo(id, name, application, version, routeIds, capabilities,
|
||||
state, registeredAt, lastHeartbeat, staleTransitionTime);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,10 +43,10 @@ public class AgentRegistryService {
|
||||
* Register a new agent or re-register an existing one.
|
||||
* Re-registration updates metadata, transitions state to LIVE, and resets timestamps.
|
||||
*/
|
||||
public AgentInfo register(String id, String name, String group, String version,
|
||||
public AgentInfo register(String id, String name, String application, String version,
|
||||
List<String> routeIds, Map<String, Object> capabilities) {
|
||||
Instant now = Instant.now();
|
||||
AgentInfo newAgent = new AgentInfo(id, name, group, version,
|
||||
AgentInfo newAgent = new AgentInfo(id, name, application, version,
|
||||
List.copyOf(routeIds), Map.copyOf(capabilities),
|
||||
AgentState.LIVE, now, now, null);
|
||||
|
||||
@@ -55,13 +55,13 @@ public class AgentRegistryService {
|
||||
// Re-registration: update metadata, reset to LIVE
|
||||
log.info("Agent {} re-registering (was {})", id, existing.state());
|
||||
return existing
|
||||
.withMetadata(name, group, version, List.copyOf(routeIds), Map.copyOf(capabilities))
|
||||
.withMetadata(name, application, version, List.copyOf(routeIds), Map.copyOf(capabilities))
|
||||
.withState(AgentState.LIVE)
|
||||
.withLastHeartbeat(now)
|
||||
.withRegisteredAt(now)
|
||||
.withStaleTransitionTime(null);
|
||||
}
|
||||
log.info("Agent {} registered (name={}, group={})", id, name, group);
|
||||
log.info("Agent {} registered (name={}, application={})", id, name, application);
|
||||
return newAgent;
|
||||
});
|
||||
|
||||
@@ -168,11 +168,11 @@ public class AgentRegistryService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all agents belonging to the given application group.
|
||||
* Return all agents belonging to the given application.
|
||||
*/
|
||||
public List<AgentInfo> findByGroup(String group) {
|
||||
public List<AgentInfo> findByApplication(String application) {
|
||||
return agents.values().stream()
|
||||
.filter(a -> group.equals(a.group()))
|
||||
.filter(a -> application.equals(a.application()))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ public class DetailService {
|
||||
List<ProcessorNode> roots = buildTree(processors);
|
||||
return new ExecutionDetail(
|
||||
exec.executionId(), exec.routeId(), exec.agentId(),
|
||||
exec.applicationName(),
|
||||
exec.status(), exec.startTime(), exec.endTime(),
|
||||
exec.durationMs() != null ? exec.durationMs() : 0L,
|
||||
exec.correlationId(), exec.exchangeId(),
|
||||
|
||||
@@ -27,6 +27,7 @@ public record ExecutionDetail(
|
||||
String executionId,
|
||||
String routeId,
|
||||
String agentId,
|
||||
String applicationName,
|
||||
String status,
|
||||
Instant startTime,
|
||||
Instant endTime,
|
||||
|
||||
@@ -74,7 +74,7 @@ public class SearchIndexer implements SearchIndexerStats {
|
||||
.toList();
|
||||
|
||||
searchIndex.index(new ExecutionDocument(
|
||||
exec.executionId(), exec.routeId(), exec.agentId(), exec.groupName(),
|
||||
exec.executionId(), exec.routeId(), exec.agentId(), exec.applicationName(),
|
||||
exec.status(), exec.correlationId(), exec.exchangeId(),
|
||||
exec.startTime(), exec.endTime(), exec.durationMs(),
|
||||
exec.errorMessage(), exec.errorStacktrace(), processorDocs));
|
||||
|
||||
@@ -38,18 +38,18 @@ public class IngestionService {
|
||||
this.bodySizeLimit = bodySizeLimit;
|
||||
}
|
||||
|
||||
public void ingestExecution(String agentId, String groupName, RouteExecution execution) {
|
||||
ExecutionRecord record = toExecutionRecord(agentId, groupName, execution);
|
||||
public void ingestExecution(String agentId, String applicationName, RouteExecution execution) {
|
||||
ExecutionRecord record = toExecutionRecord(agentId, applicationName, execution);
|
||||
executionStore.upsert(record);
|
||||
|
||||
if (execution.getProcessors() != null && !execution.getProcessors().isEmpty()) {
|
||||
List<ProcessorRecord> processors = flattenProcessors(
|
||||
execution.getProcessors(), record.executionId(),
|
||||
record.startTime(), groupName, execution.getRouteId(),
|
||||
record.startTime(), applicationName, execution.getRouteId(),
|
||||
null, 0);
|
||||
executionStore.upsertProcessors(
|
||||
record.executionId(), record.startTime(),
|
||||
groupName, execution.getRouteId(), processors);
|
||||
applicationName, execution.getRouteId(), processors);
|
||||
}
|
||||
|
||||
eventPublisher.accept(new ExecutionUpdatedEvent(
|
||||
@@ -72,13 +72,13 @@ public class IngestionService {
|
||||
return metricsBuffer;
|
||||
}
|
||||
|
||||
private ExecutionRecord toExecutionRecord(String agentId, String groupName,
|
||||
private ExecutionRecord toExecutionRecord(String agentId, String applicationName,
|
||||
RouteExecution exec) {
|
||||
String diagramHash = diagramStore
|
||||
.findContentHashForRoute(exec.getRouteId(), agentId)
|
||||
.orElse("");
|
||||
return new ExecutionRecord(
|
||||
exec.getExchangeId(), exec.getRouteId(), agentId, groupName,
|
||||
exec.getExchangeId(), exec.getRouteId(), agentId, applicationName,
|
||||
exec.getStatus() != null ? exec.getStatus().name() : "RUNNING",
|
||||
exec.getCorrelationId(), exec.getExchangeId(),
|
||||
exec.getStartTime(), exec.getEndTime(),
|
||||
@@ -90,13 +90,13 @@ public class IngestionService {
|
||||
|
||||
private List<ProcessorRecord> flattenProcessors(
|
||||
List<ProcessorExecution> processors, String executionId,
|
||||
java.time.Instant execStartTime, String groupName, String routeId,
|
||||
java.time.Instant execStartTime, String applicationName, String routeId,
|
||||
String parentProcessorId, int depth) {
|
||||
List<ProcessorRecord> flat = new ArrayList<>();
|
||||
for (ProcessorExecution p : processors) {
|
||||
flat.add(new ProcessorRecord(
|
||||
executionId, p.getProcessorId(), p.getProcessorType(),
|
||||
p.getDiagramNodeId(), groupName, routeId,
|
||||
p.getDiagramNodeId(), applicationName, routeId,
|
||||
depth, parentProcessorId,
|
||||
p.getStatus() != null ? p.getStatus().name() : "RUNNING",
|
||||
p.getStartTime() != null ? p.getStartTime() : execStartTime,
|
||||
@@ -109,7 +109,7 @@ public class IngestionService {
|
||||
if (p.getChildren() != null) {
|
||||
flat.addAll(flattenProcessors(
|
||||
p.getChildren(), executionId, execStartTime,
|
||||
groupName, routeId, p.getProcessorId(), depth + 1));
|
||||
applicationName, routeId, p.getProcessorId(), depth + 1));
|
||||
}
|
||||
}
|
||||
return flat;
|
||||
|
||||
@@ -23,6 +23,7 @@ public record ExecutionSummary(
|
||||
String executionId,
|
||||
String routeId,
|
||||
String agentId,
|
||||
String applicationName,
|
||||
String status,
|
||||
Instant startTime,
|
||||
Instant endTime,
|
||||
|
||||
@@ -22,7 +22,7 @@ import java.util.List;
|
||||
* @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 application application name 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)
|
||||
@@ -43,7 +43,7 @@ public record SearchRequest(
|
||||
String routeId,
|
||||
String agentId,
|
||||
String processorType,
|
||||
String group,
|
||||
String application,
|
||||
List<String> agentIds,
|
||||
int offset,
|
||||
int limit,
|
||||
@@ -80,12 +80,12 @@ public record SearchRequest(
|
||||
return SORT_FIELD_TO_COLUMN.getOrDefault(sortField, "start_time");
|
||||
}
|
||||
|
||||
/** Create a copy with resolved agentIds (from group lookup). */
|
||||
/** Create a copy with resolved agentIds (from application name 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,
|
||||
routeId, agentId, processorType, application, resolvedAgentIds,
|
||||
offset, limit, sortField, sortDir
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,10 @@ public class SearchService {
|
||||
return statsStore.stats(from, to);
|
||||
}
|
||||
|
||||
public ExecutionStats statsForApp(Instant from, Instant to, String applicationName) {
|
||||
return statsStore.statsForApp(from, to, applicationName);
|
||||
}
|
||||
|
||||
public ExecutionStats stats(Instant from, Instant to, String routeId, List<String> agentIds) {
|
||||
return statsStore.statsForRoute(from, to, routeId, agentIds);
|
||||
}
|
||||
@@ -36,6 +40,10 @@ public class SearchService {
|
||||
return statsStore.timeseries(from, to, bucketCount);
|
||||
}
|
||||
|
||||
public StatsTimeseries timeseriesForApp(Instant from, Instant to, int bucketCount, String applicationName) {
|
||||
return statsStore.timeseriesForApp(from, to, bucketCount, applicationName);
|
||||
}
|
||||
|
||||
public StatsTimeseries timeseries(Instant from, Instant to, int bucketCount,
|
||||
String routeId, List<String> agentIds) {
|
||||
return statsStore.timeseriesForRoute(from, to, bucketCount, routeId, agentIds);
|
||||
|
||||
@@ -14,21 +14,21 @@ public interface JwtService {
|
||||
/**
|
||||
* Validated JWT payload.
|
||||
*
|
||||
* @param subject the {@code sub} claim (agent ID or {@code user:<username>})
|
||||
* @param group the {@code group} claim
|
||||
* @param roles the {@code roles} claim (e.g. {@code ["AGENT"]}, {@code ["ADMIN"]})
|
||||
* @param subject the {@code sub} claim (agent ID or {@code user:<username>})
|
||||
* @param application the {@code group} claim (application name)
|
||||
* @param roles the {@code roles} claim (e.g. {@code ["AGENT"]}, {@code ["ADMIN"]})
|
||||
*/
|
||||
record JwtValidationResult(String subject, String group, List<String> roles) {}
|
||||
record JwtValidationResult(String subject, String application, List<String> roles) {}
|
||||
|
||||
/**
|
||||
* Creates a signed access JWT with the given subject, group, and roles.
|
||||
* Creates a signed access JWT with the given subject, application, and roles.
|
||||
*/
|
||||
String createAccessToken(String subject, String group, List<String> roles);
|
||||
String createAccessToken(String subject, String application, List<String> roles);
|
||||
|
||||
/**
|
||||
* Creates a signed refresh JWT with the given subject, group, and roles.
|
||||
* Creates a signed refresh JWT with the given subject, application, and roles.
|
||||
*/
|
||||
String createRefreshToken(String subject, String group, List<String> roles);
|
||||
String createRefreshToken(String subject, String application, List<String> roles);
|
||||
|
||||
/**
|
||||
* Validates an access token and returns the full validation result.
|
||||
@@ -46,12 +46,12 @@ public interface JwtService {
|
||||
|
||||
// --- Backward-compatible defaults (delegate to role-aware methods) ---
|
||||
|
||||
default String createAccessToken(String subject, String group) {
|
||||
return createAccessToken(subject, group, List.of());
|
||||
default String createAccessToken(String subject, String application) {
|
||||
return createAccessToken(subject, application, List.of());
|
||||
}
|
||||
|
||||
default String createRefreshToken(String subject, String group) {
|
||||
return createRefreshToken(subject, group, List.of());
|
||||
default String createRefreshToken(String subject, String application) {
|
||||
return createRefreshToken(subject, application, List.of());
|
||||
}
|
||||
|
||||
default String validateAndExtractAgentId(String token) {
|
||||
|
||||
@@ -9,7 +9,7 @@ public interface ExecutionStore {
|
||||
void upsert(ExecutionRecord execution);
|
||||
|
||||
void upsertProcessors(String executionId, Instant startTime,
|
||||
String groupName, String routeId,
|
||||
String applicationName, String routeId,
|
||||
List<ProcessorRecord> processors);
|
||||
|
||||
Optional<ExecutionRecord> findById(String executionId);
|
||||
@@ -17,7 +17,7 @@ public interface ExecutionStore {
|
||||
List<ProcessorRecord> findProcessors(String executionId);
|
||||
|
||||
record ExecutionRecord(
|
||||
String executionId, String routeId, String agentId, String groupName,
|
||||
String executionId, String routeId, String agentId, String applicationName,
|
||||
String status, String correlationId, String exchangeId,
|
||||
Instant startTime, Instant endTime, Long durationMs,
|
||||
String errorMessage, String errorStacktrace, String diagramContentHash
|
||||
@@ -25,7 +25,7 @@ public interface ExecutionStore {
|
||||
|
||||
record ProcessorRecord(
|
||||
String executionId, String processorId, String processorType,
|
||||
String diagramNodeId, String groupName, String routeId,
|
||||
String diagramNodeId, String applicationName, String routeId,
|
||||
int depth, String parentProcessorId, String status,
|
||||
Instant startTime, Instant endTime, Long durationMs,
|
||||
String errorMessage, String errorStacktrace,
|
||||
|
||||
@@ -12,7 +12,7 @@ public interface StatsStore {
|
||||
ExecutionStats stats(Instant from, Instant to);
|
||||
|
||||
// Per-app stats (stats_1m_app)
|
||||
ExecutionStats statsForApp(Instant from, Instant to, String groupName);
|
||||
ExecutionStats statsForApp(Instant from, Instant to, String applicationName);
|
||||
|
||||
// Per-route stats (stats_1m_route), optionally scoped to specific agents
|
||||
ExecutionStats statsForRoute(Instant from, Instant to, String routeId, List<String> agentIds);
|
||||
@@ -24,7 +24,7 @@ public interface StatsStore {
|
||||
StatsTimeseries timeseries(Instant from, Instant to, int bucketCount);
|
||||
|
||||
// Per-app timeseries
|
||||
StatsTimeseries timeseriesForApp(Instant from, Instant to, int bucketCount, String groupName);
|
||||
StatsTimeseries timeseriesForApp(Instant from, Instant to, int bucketCount, String applicationName);
|
||||
|
||||
// Per-route timeseries, optionally scoped to specific agents
|
||||
StatsTimeseries timeseriesForRoute(Instant from, Instant to, int bucketCount,
|
||||
|
||||
@@ -4,7 +4,7 @@ import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
public record ExecutionDocument(
|
||||
String executionId, String routeId, String agentId, String groupName,
|
||||
String executionId, String routeId, String agentId, String applicationName,
|
||||
String status, String correlationId, String exchangeId,
|
||||
Instant startTime, Instant endTime, Long durationMs,
|
||||
String errorMessage, String errorStacktrace,
|
||||
|
||||
@@ -32,7 +32,7 @@ class AgentRegistryServiceTest {
|
||||
assertThat(agent).isNotNull();
|
||||
assertThat(agent.id()).isEqualTo("agent-1");
|
||||
assertThat(agent.name()).isEqualTo("Order Agent");
|
||||
assertThat(agent.group()).isEqualTo("order-svc");
|
||||
assertThat(agent.application()).isEqualTo("order-svc");
|
||||
assertThat(agent.version()).isEqualTo("1.0.0");
|
||||
assertThat(agent.routeIds()).containsExactly("route1", "route2");
|
||||
assertThat(agent.capabilities()).containsEntry("feature", "tracing");
|
||||
@@ -52,7 +52,7 @@ class AgentRegistryServiceTest {
|
||||
|
||||
assertThat(updated.id()).isEqualTo("agent-1");
|
||||
assertThat(updated.name()).isEqualTo("New Name");
|
||||
assertThat(updated.group()).isEqualTo("new-group");
|
||||
assertThat(updated.application()).isEqualTo("new-group");
|
||||
assertThat(updated.version()).isEqualTo("2.0.0");
|
||||
assertThat(updated.routeIds()).containsExactly("route1", "route2");
|
||||
assertThat(updated.capabilities()).containsEntry("new", "cap");
|
||||
|
||||
1204
docs/superpowers/plans/2026-03-17-rbac-crud-gaps.md
Normal file
1204
docs/superpowers/plans/2026-03-17-rbac-crud-gaps.md
Normal file
File diff suppressed because it is too large
Load Diff
2392
docs/superpowers/plans/2026-03-17-rbac-management.md
Normal file
2392
docs/superpowers/plans/2026-03-17-rbac-management.md
Normal file
File diff suppressed because it is too large
Load Diff
142
docs/superpowers/specs/2026-03-17-rbac-crud-gaps-design.md
Normal file
142
docs/superpowers/specs/2026-03-17-rbac-crud-gaps-design.md
Normal file
@@ -0,0 +1,142 @@
|
||||
# RBAC CRUD Gaps — Design Specification
|
||||
|
||||
## Goal
|
||||
|
||||
Add missing CRUD and assignment UI to the RBAC management page, fix date formatting, seed a built-in Admins group, and fix dashboard diagram ordering.
|
||||
|
||||
## References
|
||||
|
||||
- Parent spec: `docs/superpowers/specs/2026-03-17-rbac-management-design.md`
|
||||
- Visual prototype: `examples/RBAC/rbac_management_ui.html`
|
||||
|
||||
---
|
||||
|
||||
## Changes
|
||||
|
||||
### 1. Users Tab — Delete + Assignments
|
||||
|
||||
Users cannot be created manually (they arrive via login). The detail pane gains:
|
||||
|
||||
- **Delete button** in the detail header area. Uses existing `ConfirmDeleteDialog` with the user's `displayName` as the confirmation string. Calls `useDeleteUser()`. **Guard:** the currently authenticated user (from `useAuthStore`) cannot delete themselves — button disabled with tooltip "Cannot delete your own account".
|
||||
- **Group membership section** — "+ Add" chip opens a **multi-select dropdown** listing all groups the user is NOT already a member of. Checkboxes for batch selection, "Apply" button to commit. Calls are batched via `Promise.allSettled()` — if any fail, show an inline error, invalidate queries regardless to refresh. Existing group chips gain an "x" remove button calling `useRemoveUserFromGroup()`.
|
||||
- **Direct roles section** — the existing "Effective roles" section renders both direct and inherited roles. The "+ Add" multi-select dropdown lists roles not yet directly assigned. Calls `useAssignRoleToUser()` (batched via `Promise.allSettled()`). Direct role chips gain an "x" button calling `useRemoveRoleFromUser()`. Inherited role chips (dashed border) do NOT get remove buttons — they can only be removed by changing group membership or group role assignments.
|
||||
- **Created field** — change from date-only to full date+time: `new Date(createdAt).toLocaleString()`.
|
||||
- **Mutation button states** — all action buttons (delete, remove chip "x") disable while their mutation is in-flight to prevent double-clicks.
|
||||
|
||||
### 2. Groups Tab — CRUD + Assignments
|
||||
|
||||
- **"+ Add group" button** in the panel header (`.btnAdd` style exists). Opens an inline form below the search bar with: name text input, optional parent group dropdown, "Create" button. Calls `useCreateGroup()`. Form clears and closes on success. On error: shows error message inline.
|
||||
- **Delete button** in detail pane header. Uses `ConfirmDeleteDialog` with group name. Calls `useDeleteGroup()`. Resets selected group. **Guard:** the built-in Admins group (`SystemRole.ADMINS_GROUP_ID`) cannot be deleted — button disabled with tooltip "Built-in group cannot be deleted".
|
||||
- **Assigned roles section** — "+ Add" multi-select dropdown listing roles not yet assigned to this group. Batched via `Promise.allSettled()`. Calls `useAssignRoleToGroup()`. Role chips gain "x" for `useRemoveRoleFromGroup()`.
|
||||
- **Parent group** — shown as a dropdown in the detail header area, allowing re-parenting. Calls `useUpdateGroup()`. The dropdown excludes the group itself and its transitive descendants (cycle prevention — requires recursive traversal of `childGroups` on each `GroupDetail`). Setting to empty/none makes it top-level.
|
||||
|
||||
### 3. Roles Tab — CRUD
|
||||
|
||||
- **"+ Add role" button** in panel header. Opens an inline form: name (required), description (optional), scope (optional, free-text, defaults to "custom"). Calls `useCreateRole()`.
|
||||
- **Delete button** in detail pane header. **Disabled for system roles** (lock icon + tooltip "System roles cannot be deleted"). Custom roles use `ConfirmDeleteDialog` with role name → `useDeleteRole()`.
|
||||
- No assignment UI on the roles tab — assignments are managed from the User and Group detail panes.
|
||||
|
||||
### 4. Multi-Select Dropdown Component
|
||||
|
||||
A reusable component used across all assignment actions:
|
||||
|
||||
```
|
||||
Props:
|
||||
items: { id: string; label: string }[] — available items to pick from
|
||||
onApply: (selectedIds: string[]) => void — called with all checked IDs
|
||||
placeholder?: string — search filter placeholder
|
||||
```
|
||||
|
||||
Behavior:
|
||||
- Opens as a positioned dropdown below the "+ Add" chip
|
||||
- Search/filter input at top
|
||||
- Checkbox list of items (max-height with scroll)
|
||||
- "Apply" button at bottom (disabled when nothing selected)
|
||||
- Closes on Apply, Escape, or click-outside
|
||||
- Shows count badge on Apply button: "Apply (3)"
|
||||
|
||||
Styling: background `var(--bg-raised)`, border `var(--border)`, border-radius `var(--radius-md)`, items with `var(--bg-hover)` on hover, checkboxes with `var(--amber)` accent.
|
||||
|
||||
### 5. Inline Create Form
|
||||
|
||||
A reusable pattern for "Add group" and "Add role":
|
||||
|
||||
- Appears below the search bar in the list pane, pushing content down
|
||||
- Input fields with labels
|
||||
- "Create" and "Cancel" buttons
|
||||
- On success: closes form, clears inputs, new entity appears in list
|
||||
- On error: shows error message inline
|
||||
- "Create" button disabled while mutation is in-flight
|
||||
|
||||
### 6. Built-in Admins Group Seed
|
||||
|
||||
**Database migration** — new `V2__admin_group_seed.sql` (V1 is already deployed, V2-V10 were deleted in the migration consolidation so V2 is safe):
|
||||
|
||||
```sql
|
||||
-- Built-in Admins group
|
||||
INSERT INTO groups (id, name) VALUES
|
||||
('00000000-0000-0000-0000-000000000010', 'Admins');
|
||||
|
||||
-- Assign ADMIN role to Admins group
|
||||
INSERT INTO group_roles (group_id, role_id) VALUES
|
||||
('00000000-0000-0000-0000-000000000010', '00000000-0000-0000-0000-000000000004');
|
||||
```
|
||||
|
||||
**SystemRole.java** — add constants:
|
||||
```java
|
||||
public static final UUID ADMINS_GROUP_ID = UUID.fromString("00000000-0000-0000-0000-000000000010");
|
||||
```
|
||||
|
||||
**UiAuthController.login()** — after upserting the user and assigning ADMIN role, also add to Admins group:
|
||||
```java
|
||||
rbacService.addUserToGroup(subject, SystemRole.ADMINS_GROUP_ID);
|
||||
```
|
||||
|
||||
**Frontend guard:** The Admins group UUID is hardcoded as a constant in the frontend to disable deletion. Alternatively, check if a group's ID matches a known system group ID.
|
||||
|
||||
### 7. Dashboard Diagram Ordering
|
||||
|
||||
The inheritance diagram's three columns (Groups → Roles → Users) must show items in a consistent, matching order:
|
||||
|
||||
- **Groups column**: alphabetical by name, children indented under parents
|
||||
- **Roles column**: iterate groups top-to-bottom, collect their direct roles, deduplicate preserving first-seen order. Roles not assigned to any group are omitted from the diagram.
|
||||
- **Users column**: alphabetical by display name
|
||||
|
||||
Sort explicitly in `DashboardTab.tsx` before rendering.
|
||||
|
||||
---
|
||||
|
||||
## Files Changed
|
||||
|
||||
### Frontend — Modified
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `ui/src/pages/admin/rbac/UsersTab.tsx` | Delete button, group/role assignment dropdowns, date format fix, self-delete guard |
|
||||
| `ui/src/pages/admin/rbac/GroupsTab.tsx` | Add group form, delete button, role assignment dropdown, parent group dropdown, Admins guard |
|
||||
| `ui/src/pages/admin/rbac/RolesTab.tsx` | Add role form, delete button (disabled for system) |
|
||||
| `ui/src/pages/admin/rbac/DashboardTab.tsx` | Sort diagram columns consistently |
|
||||
| `ui/src/pages/admin/rbac/RbacPage.module.css` | Styles for multi-select dropdown, inline create form, delete button, action chips, remove buttons |
|
||||
|
||||
### Frontend — New
|
||||
| File | Responsibility |
|
||||
|---|---|
|
||||
| `ui/src/pages/admin/rbac/components/MultiSelectDropdown.tsx` | Reusable multi-select picker with search, checkboxes, batch apply |
|
||||
|
||||
### Backend — Modified
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `cameleer3-server-core/.../rbac/SystemRole.java` | Add `ADMINS_GROUP_ID` constant |
|
||||
| `cameleer3-server-app/.../security/UiAuthController.java` | Add admin user to Admins group on login |
|
||||
|
||||
### Backend — New Migration
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `cameleer3-server-app/src/main/resources/db/migration/V2__admin_group_seed.sql` | Seed Admins group + ADMIN role assignment |
|
||||
|
||||
---
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Editing user profile fields (name, email) — users are managed by their identity provider
|
||||
- Drag-and-drop group hierarchy management
|
||||
- Role permission editing (custom roles have no effect on Spring Security yet)
|
||||
327
docs/superpowers/specs/2026-03-17-rbac-management-design.md
Normal file
327
docs/superpowers/specs/2026-03-17-rbac-management-design.md
Normal file
@@ -0,0 +1,327 @@
|
||||
# RBAC Management — Design Specification
|
||||
|
||||
## Goal
|
||||
|
||||
Implement a full RBAC management system (issue #41) with group hierarchy, role inheritance, and a management UI integrated into the admin section. Replace the flat `users.roles` text array with a proper relational model.
|
||||
|
||||
## References
|
||||
|
||||
- Functional spec: `examples/RBAC/rbac-ui-spec.md`
|
||||
- Visual prototype: `examples/RBAC/rbac_management_ui.html`
|
||||
|
||||
---
|
||||
|
||||
## Backend
|
||||
|
||||
### Database Schema
|
||||
|
||||
Squash V1–V10 Flyway migrations into a single `V1__init.sql`. The `users` table drops the `roles TEXT[]` column. New tables:
|
||||
|
||||
```sql
|
||||
CREATE TABLE users (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
provider TEXT NOT NULL,
|
||||
email TEXT,
|
||||
display_name TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- RBAC: all roles — system roles seeded with fixed UUIDs, custom roles created by admins
|
||||
CREATE TABLE roles (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
scope TEXT NOT NULL DEFAULT 'custom',
|
||||
system BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Seed system roles with fixed UUIDs (stable across environments)
|
||||
INSERT INTO roles (id, name, description, scope, system) VALUES
|
||||
('00000000-0000-0000-0000-000000000001', 'AGENT', 'Agent registration and data ingestion', 'system-wide', true),
|
||||
('00000000-0000-0000-0000-000000000002', 'VIEWER', 'Read-only access to dashboards and data', 'system-wide', true),
|
||||
('00000000-0000-0000-0000-000000000003', 'OPERATOR', 'Operational commands (start/stop/configure agents)', 'system-wide', true),
|
||||
('00000000-0000-0000-0000-000000000004', 'ADMIN', 'Full administrative access', 'system-wide', true);
|
||||
|
||||
-- RBAC: groups with self-referential hierarchy
|
||||
CREATE TABLE groups (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
parent_group_id UUID REFERENCES groups(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Join: roles assigned to groups (system + custom)
|
||||
CREATE TABLE group_roles (
|
||||
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (group_id, role_id)
|
||||
);
|
||||
|
||||
-- Join: direct group membership for users
|
||||
CREATE TABLE user_groups (
|
||||
user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (user_id, group_id)
|
||||
);
|
||||
|
||||
-- Join: direct role assignments to users (system + custom)
|
||||
CREATE TABLE user_roles (
|
||||
user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (user_id, role_id)
|
||||
);
|
||||
|
||||
-- Indexes for join query performance
|
||||
CREATE INDEX idx_user_roles_user_id ON user_roles(user_id);
|
||||
CREATE INDEX idx_user_groups_user_id ON user_groups(user_id);
|
||||
CREATE INDEX idx_group_roles_group_id ON group_roles(group_id);
|
||||
CREATE INDEX idx_groups_parent ON groups(parent_group_id);
|
||||
```
|
||||
|
||||
Note: `roles TEXT[]` column is removed from `users`. All roles (system and custom) live in the `roles` table. System roles are seeded rows with `system = true` and fixed UUIDs — the application prevents their deletion or modification.
|
||||
|
||||
### System Roles
|
||||
|
||||
The four system roles (AGENT, VIEWER, OPERATOR, ADMIN) are:
|
||||
- Seeded as rows in the `roles` table with `system = true` and fixed UUIDs
|
||||
- Assigned to users via the same `user_roles` join table as custom roles
|
||||
- Protected by application logic: creation, deletion, and name/scope modification are rejected
|
||||
- Displayed in the UI as read-only entries (lock icon, non-deletable)
|
||||
- Used by Spring Security / JWT for authorization decisions
|
||||
|
||||
Custom roles (`system = false`) are application-defined and have no effect on Spring Security — they serve the RBAC management model only (for future permission expansion).
|
||||
|
||||
The `scope` field distinguishes role domains: system roles use `system-wide`, custom roles can use descriptive scopes like `monitoring:read`, `config:write` for future permission gating.
|
||||
|
||||
### Domain Model (Java)
|
||||
|
||||
```java
|
||||
// Existing, modified — drop roles field
|
||||
public record UserInfo(
|
||||
String userId,
|
||||
String provider,
|
||||
String email,
|
||||
String displayName,
|
||||
Instant createdAt
|
||||
) {}
|
||||
|
||||
// New — enriched user for admin API responses
|
||||
public record UserDetail(
|
||||
String userId,
|
||||
String provider,
|
||||
String email,
|
||||
String displayName,
|
||||
Instant createdAt,
|
||||
List<RoleSummary> directRoles, // from user_roles join (system + custom)
|
||||
List<GroupSummary> directGroups, // from user_groups join
|
||||
List<RoleSummary> effectiveRoles, // computed: union of direct + inherited via groups
|
||||
List<GroupSummary> effectiveGroups // computed: direct groups + their ancestor chain
|
||||
) {}
|
||||
|
||||
public record GroupDetail(
|
||||
UUID id,
|
||||
String name,
|
||||
UUID parentGroupId, // nullable
|
||||
Instant createdAt,
|
||||
List<RoleSummary> directRoles,
|
||||
List<RoleSummary> effectiveRoles, // direct + inherited from parent chain
|
||||
List<UserSummary> members, // direct members
|
||||
List<GroupSummary> childGroups
|
||||
) {}
|
||||
|
||||
public record RoleDetail(
|
||||
UUID id,
|
||||
String name,
|
||||
String description,
|
||||
String scope,
|
||||
boolean system, // true for AGENT/VIEWER/OPERATOR/ADMIN
|
||||
Instant createdAt,
|
||||
List<GroupSummary> assignedGroups,
|
||||
List<UserSummary> directUsers,
|
||||
List<UserSummary> effectivePrincipals // all users who hold this role
|
||||
) {}
|
||||
|
||||
// Summaries for embedding in detail responses
|
||||
public record UserSummary(String userId, String displayName, String provider) {}
|
||||
public record GroupSummary(UUID id, String name) {}
|
||||
public record RoleSummary(UUID id, String name, boolean system, String source) {}
|
||||
// source: "direct" | group name (for inherited)
|
||||
```
|
||||
|
||||
### Inheritance Logic
|
||||
|
||||
Server-side computation in a service class (e.g., `RbacService`):
|
||||
|
||||
1. **Effective groups for user**: Start from `user_groups` (direct memberships), then for each group walk `parent_group_id` chain upward to collect all ancestor groups. The union is every group the user is transitively a member of.
|
||||
2. **Effective roles for user**: Direct `user_roles` + all `group_roles` for every effective group. Both system and custom roles flow through the same path.
|
||||
3. **Effective roles for group**: Direct `group_roles` + inherited from parent chain.
|
||||
4. **Effective principals for role**: All users who hold the role directly + all users in any group that has the role (transitively).
|
||||
|
||||
No role negation — roles only grant, never deny.
|
||||
|
||||
**Cycle detection**: When setting `parent_group_id` on a group, the application must walk the proposed parent chain upward and reject the update if it would create a cycle (i.e., the group appears in its own ancestor chain). Return HTTP 409 Conflict.
|
||||
|
||||
### Auth Integration
|
||||
|
||||
`JwtService` and `SecurityConfig` read system roles from `user_roles` joined to `roles WHERE system = true`, instead of `users.roles`. The `UserRepository` methods that currently read/write `users.roles` are updated to use the join table. JWT claims remain unchanged (`roles: ["ADMIN", "VIEWER"]`).
|
||||
|
||||
OIDC auto-signup: When a user is auto-registered via OIDC token exchange, they get a row in `users` with `provider = "oidc:<issuer>"` and a default system role (VIEWER) via `user_roles`. No group membership by default.
|
||||
|
||||
### API Endpoints
|
||||
|
||||
All under `/api/v1/admin/` prefix, protected by `@PreAuthorize("hasRole('ADMIN')")`.
|
||||
|
||||
The existing `PUT /users/{userId}/roles` bulk endpoint is removed. Role assignments use individual add/remove endpoints.
|
||||
|
||||
All mutation endpoints log to the `AuditService` (category: `USER_MGMT` for user operations, `RBAC` for group/role operations).
|
||||
|
||||
**Users** — response type: `UserDetail`
|
||||
|
||||
| Method | Path | Description | Request Body |
|
||||
|---|---|---|---|
|
||||
| GET | `/users` | List all users with effective roles/groups | — |
|
||||
| GET | `/users/{id}` | Full user detail | — |
|
||||
| POST | `/users/{id}/roles/{roleId}` | Assign role to user (system or custom) | — |
|
||||
| DELETE | `/users/{id}/roles/{roleId}` | Remove role from user | — |
|
||||
| POST | `/users/{id}/groups/{groupId}` | Add user to group | — |
|
||||
| DELETE | `/users/{id}/groups/{groupId}` | Remove user from group | — |
|
||||
| DELETE | `/users/{id}` | Delete user | — |
|
||||
|
||||
**Groups** — response type: `GroupDetail`
|
||||
|
||||
| Method | Path | Description | Request Body |
|
||||
|---|---|---|---|
|
||||
| GET | `/groups` | List all groups with hierarchy | — |
|
||||
| GET | `/groups/{id}` | Full group detail | — |
|
||||
| POST | `/groups` | Create group | `{ name, parentGroupId? }` |
|
||||
| PUT | `/groups/{id}` | Update group | `{ name?, parentGroupId? }` — returns 409 on cycle |
|
||||
| DELETE | `/groups/{id}` | Delete group — cascades role/member associations; child groups become top-level (parent set to null) | — |
|
||||
| POST | `/groups/{id}/roles/{roleId}` | Assign role to group | — |
|
||||
| DELETE | `/groups/{id}/roles/{roleId}` | Remove role from group | — |
|
||||
|
||||
**Roles** — response type: `RoleDetail`
|
||||
|
||||
| Method | Path | Description | Request Body |
|
||||
|---|---|---|---|
|
||||
| GET | `/roles` | List all roles (system + custom) | — |
|
||||
| GET | `/roles/{id}` | Role detail | — |
|
||||
| POST | `/roles` | Create custom role | `{ name, description?, scope? }` |
|
||||
| PUT | `/roles/{id}` | Update custom role (rejects system roles) | `{ name?, description?, scope? }` |
|
||||
| DELETE | `/roles/{id}` | Delete custom role (rejects system roles) | — |
|
||||
|
||||
**Dashboard:**
|
||||
|
||||
| Method | Path | Description |
|
||||
|---|---|---|
|
||||
| GET | `/rbac/stats` | `{ userCount, activeUserCount, groupCount, maxGroupDepth, roleCount }` |
|
||||
|
||||
---
|
||||
|
||||
## Frontend
|
||||
|
||||
### Routing
|
||||
|
||||
New route at `/admin/rbac` in `router.tsx`, lazy-loaded:
|
||||
|
||||
```tsx
|
||||
const RbacPage = lazy(() => import('./pages/admin/rbac/RbacPage').then(m => ({ default: m.RbacPage })));
|
||||
// ...
|
||||
{ path: 'admin/rbac', element: <Suspense fallback={null}><RbacPage /></Suspense> }
|
||||
```
|
||||
|
||||
Update `AppSidebar` ADMIN_LINKS to add `{ to: '/admin/rbac', label: 'User Management' }`.
|
||||
|
||||
### Component Structure
|
||||
|
||||
```
|
||||
pages/admin/rbac/
|
||||
├── RbacPage.tsx ← ADMIN role gate + tab navigation
|
||||
├── RbacPage.module.css ← All RBAC-specific styles
|
||||
├── DashboardTab.tsx ← Stat cards + inheritance diagram
|
||||
├── UsersTab.tsx ← Split pane orchestrator
|
||||
├── GroupsTab.tsx ← Split pane orchestrator
|
||||
├── RolesTab.tsx ← Split pane orchestrator
|
||||
├── components/
|
||||
│ ├── EntityListPane.tsx ← Reusable: search input + scrollable card list
|
||||
│ ├── EntityCard.tsx ← Single list row: avatar, name, meta, tags, status dot
|
||||
│ ├── UserDetail.tsx ← Header, fields, groups, effective roles, group tree
|
||||
│ ├── GroupDetail.tsx ← Header, fields, members, children, roles, hierarchy
|
||||
│ ├── RoleDetail.tsx ← Header, fields, assigned groups/users, effective principals
|
||||
│ ├── InheritanceChip.tsx ← Chip with dashed border + "↑ Source" annotation
|
||||
│ ├── GroupTree.tsx ← Indented tree with corner connectors
|
||||
│ ├── EntityAvatar.tsx ← Circle (user), rounded-square (group/role), color by type
|
||||
│ ├── OidcBadge.tsx ← Small badge showing OIDC provider origin
|
||||
│ ├── InheritanceDiagram.tsx ← Three-column Groups→Roles→Users read-only diagram
|
||||
│ └── InheritanceNote.tsx ← Green-bordered explanation block
|
||||
api/queries/admin/
|
||||
│ └── rbac.ts ← useUsers, useUser, useGroups, useGroup, useRoles, useRole, useRbacStats + mutation hooks
|
||||
```
|
||||
|
||||
### Tab Navigation
|
||||
|
||||
`RbacPage` uses a horizontal tab bar (Dashboard | Users | Groups | Roles) with URL-synced active state via query parameter (`?tab=users`). Each tab renders its content below the tab bar in the full main panel area.
|
||||
|
||||
### Split Pane Layout
|
||||
|
||||
Users, Groups, and Roles tabs share the same layout:
|
||||
- **Left (52%)**: `EntityListPane` with search input + scrollable entity cards
|
||||
- **Right (48%)**: Detail pane showing selected entity, or empty state prompt
|
||||
- Resizable via `ResizableDivider` (existing shared component)
|
||||
|
||||
### Entity Card Patterns
|
||||
|
||||
**User card:** Circle avatar (initials, blue tint) + name + email/primary-group meta + role tags (amber) + group tags (green) + status dot + OIDC badge if `provider !== "local"`
|
||||
|
||||
**Group card:** Rounded-square avatar (initials, green/amber/red by domain) + name + parent/member-count meta + role tags (direct solid, inherited faded+italic)
|
||||
|
||||
**Role card:** Rounded-square avatar (initials, amber tint) + name + description/assignment-count meta + assigned-to tags. System roles show a lock icon.
|
||||
|
||||
### Badge/Chip Styling
|
||||
|
||||
Following the spec and existing CSS token system:
|
||||
|
||||
| Chip type | Background | Border | Text |
|
||||
|---|---|---|---|
|
||||
| Role (direct) | `var(--amber-dim)` | solid `var(--amber)` | amber text |
|
||||
| Role (inherited) | transparent | dashed `var(--amber)` | faded amber, italic |
|
||||
| Group | `var(--green-dim)` / `#E1F5EE` | solid green | green text |
|
||||
| OIDC badge | `var(--cyan-dim)` | solid cyan | cyan text, shows provider |
|
||||
| System role | Same as role but with lock icon | — | — |
|
||||
|
||||
Inherited role chips include `↑ GroupName` annotation in the detail pane.
|
||||
|
||||
### OIDC Badge
|
||||
|
||||
Displayed on user cards and user detail when `provider !== "local"`. Shows a small cyan-tinted pill with the provider name (e.g., "OIDC" or the issuer hostname). Positioned after the user's name in the card, and as a field in the detail pane.
|
||||
|
||||
### Search
|
||||
|
||||
Client-side filtering on entity list panes — filter by any visible text (name, email, group, role). Sufficient for the expected user count.
|
||||
|
||||
### State Management
|
||||
|
||||
- React Query for all server state (users, groups, roles, stats)
|
||||
- Local `useState` for selected entity, search filter, active tab
|
||||
- Mutations invalidate related queries (e.g., updating a user's groups invalidates both user and group queries)
|
||||
|
||||
---
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
1. Delete all V1–V10 migration files
|
||||
2. Create single `V1__init.sql` containing the full consolidated schema
|
||||
3. Deployed environments: drop and recreate the database (data loss accepted)
|
||||
4. CI/CD: no special handling — clean database on deploy
|
||||
5. Update `application.yml` if needed: `spring.flyway.clean-on-validation-error: true` or manual DB drop
|
||||
|
||||
---
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Permission-based access control (custom roles don't gate endpoints — system roles do)
|
||||
- Audit log panel within RBAC (existing audit log page covers this)
|
||||
- Bulk import/export of users or groups
|
||||
- SCIM provisioning
|
||||
- Role negation / deny rules
|
||||
261
docs/ui-mocks/camel-developer-review.md
Normal file
261
docs/ui-mocks/camel-developer-review.md
Normal file
@@ -0,0 +1,261 @@
|
||||
# Cameleer3 Dashboard Review -- Senior Camel Developer Perspective
|
||||
|
||||
**Reviewer**: Senior Apache Camel Developer (10+ years, Java DSL / Spring Boot)
|
||||
**Artifact reviewed**: `mock-v2-light.html` -- Operations Dashboard (v2 synthesis)
|
||||
**Date**: 2026-03-17
|
||||
|
||||
---
|
||||
|
||||
## 1. What the Dashboard Gets RIGHT
|
||||
|
||||
### Business ID as First-Class Citizen
|
||||
The Order ID and Customer columns in the execution table are exactly what I need. When support calls me about "order OP-88421", I can paste that into the search and find the execution immediately. Every other monitoring tool I have used forces me to map business IDs to correlation IDs manually. This alone would save me 10-15 minutes per incident.
|
||||
|
||||
### Inline Error Previews
|
||||
Showing the exception message directly in the table row without requiring a click-through is genuinely useful. The two error examples in the mock (`HttpOperationFailedException` with a 504, `SQLTransientConnectionException` with HikariPool exhaustion) are realistic Camel exceptions. I can scan the error list and immediately tell whether it is a downstream timeout or a connection pool issue. That distinction determines whether I investigate our code or page the DBA.
|
||||
|
||||
### Processor Timeline (Gantt View)
|
||||
The processor timeline in the detail panel is the single most valuable feature. Seeing that `to(payment-api)` consumed 280ms out of a 412ms total execution, while `enrich(inventory)` took 85ms, immediately tells me WHERE the bottleneck is. In my experience, 95% of Camel performance issues are in external calls, and this view pinpoints them. The color coding (green/yellow/red) for processor bars makes the slow step obvious at a glance.
|
||||
|
||||
### SLA Awareness Baked In
|
||||
The SLA threshold line on the latency chart, the "SLA" tag on slow durations, and the "CLOSE" warning on the p99 card are exactly the kind of proactive indicators I want. Most monitoring tools show me raw numbers; this dashboard shows me numbers in context. I know immediately that 287ms p99 is dangerously close to our 300ms SLA.
|
||||
|
||||
### Shift-Aware Time Context
|
||||
The "since 06:00" shift concept is something I have never seen in a developer tool but actually matches how production support works. When I start my day shift, I want to see what happened overnight and what is happening now, not a rolling 24-hour window that mixes yesterday afternoon with this morning.
|
||||
|
||||
### Agent Health in Sidebar
|
||||
Seeing agent status (live/stale/dead), throughput per agent, and error rates at a glance in the sidebar is practical. When an agent goes stale, I know to check if a pod restarted or if there is a network partition.
|
||||
|
||||
### Application-to-Route Navigation Hierarchy
|
||||
The sidebar tree (Applications > order-service > Routes > order-intake, order-enrichment, etc.) matches how I think about Camel deployments. I have multiple applications, each with multiple routes. Being able to filter by application first, then drill into routes, is the right hierarchy.
|
||||
|
||||
---
|
||||
|
||||
## 2. What is MISSING or Could Be Better
|
||||
|
||||
### 2.1 Exchange Body/Header Inspection -- CRITICAL GAP
|
||||
|
||||
**Pain point**: The "Exchange" tab exists in the detail panel tabs but its content is not shown. This is the single most important debugging feature for a Camel developer. When a message fails at step 5 of 7, I need to see:
|
||||
- What was the original inbound message (before any transformation)?
|
||||
- What did the exchange body look like at each processor step?
|
||||
- Which headers were present at each step, and which were added/removed?
|
||||
- What was the exception body (often different from the exception message)?
|
||||
|
||||
**How to address it**: The Exchange tab should show a step-by-step diff view of the exchange. For each processor in the route, show the body (with a JSON/XML pretty-printer) and the headers map. Highlight headers that were added at that step. Allow comparing any two steps side-by-side. Show the original inbound message prominently at the top.
|
||||
|
||||
**Priority**: **Must-Have**. Without this, the dashboard is an operations monitor, not a debugging tool. This is the difference between "I can see something failed" and "I can see WHY it failed."
|
||||
|
||||
### 2.2 Route Diagram / Visual Graph -- MENTIONED BUT NOT SHOWN
|
||||
|
||||
**Pain point**: The "View Route Diagram" button exists in the detail actions, but there is no mockup of what the route diagram looks like. As a Camel developer, I need to see the DAG (directed acyclic graph) of my route: from(jms:orders) -> unmarshal -> validate -> choice -> [branch A: enrich -> transform -> to(http)] [branch B: log -> to(dlq)]. I also need to see execution overlay on the diagram -- which path did THIS specific exchange take, and how long did each node take.
|
||||
|
||||
**How to address it**: Add a Route Diagram page/view that shows:
|
||||
- The route definition as an interactive DAG (nodes = processors, edges = flow)
|
||||
- Execution overlay: color-code each node by success/failure for a specific execution
|
||||
- Aggregate overlay: color-code each node by throughput/error rate over a time window
|
||||
- Highlight the path taken by the selected exchange (dim the branches not taken)
|
||||
- Show inter-route connections (e.g., `direct:`, `seda:`, `vm:` endpoints linking routes)
|
||||
|
||||
**Priority**: **Must-Have**. Cameleer already has `RouteGraph` data from agents -- this is the tool's differentiating feature.
|
||||
|
||||
### 2.3 Cross-Route Correlation / Message Tracing
|
||||
|
||||
**Pain point**: A single business transaction (e.g., an order) often spans multiple routes: `order-intake` -> `order-enrichment` -> `payment-process` -> `shipment-dispatch`. The dashboard shows each route execution as a separate row. There is no way to see the full journey of order OP-88421 across all routes.
|
||||
|
||||
**How to address it**: Add a "Transaction Trace" or "Message Flow" view that:
|
||||
- Groups all executions sharing a breadcrumbId or correlation ID
|
||||
- Shows them as a horizontal timeline or waterfall chart
|
||||
- Highlights which route in the chain failed
|
||||
- Works across `direct:`, `seda:`, and `vm:` endpoints that link routes
|
||||
|
||||
The search bar says "Search by Order ID, correlation ID" which is a good start, but the results should show the correlated group, not just individual rows.
|
||||
|
||||
**Priority**: **Must-Have**. Splitter/aggregator patterns and multi-route flows are the norm, not the exception, in real Camel applications.
|
||||
|
||||
### 2.4 Dead Letter Queue Monitoring
|
||||
|
||||
**Pain point**: When messages fail and are routed to a dead letter channel (which is the standard Camel error handling pattern), I need to know: how many messages are in the DLQ, what are they, how long have they been there, and can I retry them?
|
||||
|
||||
**How to address it**: Add a DLQ section or page showing:
|
||||
- Count of messages per dead letter endpoint
|
||||
- Age distribution (how many are from today vs. last week)
|
||||
- Message preview (body + headers + the exception that caused routing to DLQ)
|
||||
- Retry action (re-submit the message to the original route)
|
||||
- Purge action (acknowledge and discard)
|
||||
|
||||
**Priority**: **Must-Have**. DLQ management is a daily production task.
|
||||
|
||||
### 2.5 Per-Processor Statistics (Aggregate View)
|
||||
|
||||
**Pain point**: The processor timeline in the detail panel shows per-processor timing for a single execution. But I also need aggregate statistics: for processor `to(payment-api)`, what is the p50/p95/p99 latency over the last hour? How many times did it fail? Is it getting slower over time?
|
||||
|
||||
**How to address it**: Clicking a processor name in the timeline should show aggregate stats for that processor. Alternatively, the Route Detail page should have a "Processors" tab with a table of all processors in the route, their call count, success rate, and latency percentiles.
|
||||
|
||||
**Priority**: **Must-Have**. Identifying a chronically slow processor is different from identifying a one-off slow execution.
|
||||
|
||||
### 2.6 Error Pattern Grouping / Top Errors
|
||||
|
||||
**Pain point**: The dashboard shows individual error rows. When there are 38 errors, I do not want to scroll through all 38. I want to see: "23 of the 38 errors are `HttpOperationFailedException` on `payment-process`, 10 are `SQLTransientConnectionException` on `order-enrichment`, 5 are `ValidationException` on `order-intake`." The design notes mention "Top error pattern grouping panel" from the operator expert, but it is not in the final mock.
|
||||
|
||||
**How to address it**: Add an error summary panel above or alongside the execution table showing errors grouped by exception class + route. Each group should show count, first/last occurrence, and whether the count is trending up.
|
||||
|
||||
**Priority**: **Must-Have**. Pattern recognition is more important than individual error viewing.
|
||||
|
||||
### 2.7 Route Status Management
|
||||
|
||||
**Pain point**: I need to know which routes are started, stopped, or suspended. And I need the ability to stop/start/suspend individual routes without redeploying. This is routine in production -- temporarily suspending a route that is flooding a downstream system.
|
||||
|
||||
**How to address it**: The sidebar route list should show route status (started/stopped/suspended) with icons. Right-click or action menu on a route should offer start/stop/suspend. This maps directly to Camel's route controller API.
|
||||
|
||||
**Priority**: **Nice-to-Have** for v1, **Must-Have** for v2. Operators will ask for this quickly.
|
||||
|
||||
### 2.8 Route Version Comparison
|
||||
|
||||
**Pain point**: After a deployment, I want to compare the current route definition with the previous version. Did someone add a processor? Change an endpoint URI? Route definition drift is a real source of production issues.
|
||||
|
||||
**How to address it**: Store route graph snapshots per deployment/version. Show a diff view highlighting added/removed/modified processors.
|
||||
|
||||
**Priority**: **Nice-to-Have**. Valuable but less urgent than the above.
|
||||
|
||||
### 2.9 Thread Pool / Resource Monitoring
|
||||
|
||||
**Pain point**: Camel's default thread pool max is 20. When all threads are consumed, messages queue up silently. The HikariPool error in the mock is a perfect example -- pool exhaustion. I need visibility into thread pool utilization, connection pool utilization, and inflight exchange count.
|
||||
|
||||
**How to address it**: Add a "Resources" section (either in the agent detail or a separate page) showing:
|
||||
- Camel thread pool utilization (active/max)
|
||||
- Connection pool utilization (from endpoint components)
|
||||
- Inflight exchange count per route
|
||||
- Consumer prefetch/backlog (for JMS/Kafka consumers)
|
||||
|
||||
**Priority**: **Nice-to-Have** initially, but becomes **Must-Have** when debugging pool exhaustion issues.
|
||||
|
||||
### 2.10 Saved Searches / Alert Rules
|
||||
|
||||
**Pain point**: I find myself searching for the same patterns repeatedly: "errors on payment-process in the last hour", "executions over 500ms for order-enrichment". There is no way to save these as bookmarks or convert them into alert rules.
|
||||
|
||||
**How to address it**: Allow saving filter configurations as named views. Allow converting a saved search into an alerting rule (email/webhook when count exceeds threshold).
|
||||
|
||||
**Priority**: **Nice-to-Have**.
|
||||
|
||||
---
|
||||
|
||||
## 3. Specific Page/Feature Recommendations
|
||||
|
||||
### 3.1 Route Detail Page
|
||||
|
||||
When I click a route name (e.g., `order-intake`) from the sidebar, I should see:
|
||||
|
||||
- **Header**: Route name, status (started/stopped), uptime, route definition source (Java DSL / XML / YAML)
|
||||
- **KPI Strip**: Total executions, success rate, p50/p99 latency, inflight count, throughput -- all for this route only
|
||||
- **Processor Table**: Every processor in the route with columns: name, type, call count, success rate, p50 latency, p99 latency, total time %. Sortable by any column. This is where I find the bottleneck processor.
|
||||
- **Route Diagram**: Interactive DAG with execution overlay. Nodes sized by throughput, colored by error rate. Clicking a node filters the execution list to that processor.
|
||||
- **Recent Executions**: Filtered version of the main table, showing only this route's executions.
|
||||
- **Error Patterns**: Top errors for this route, grouped by exception class.
|
||||
|
||||
### 3.2 Exchange / Message Inspector
|
||||
|
||||
When I click "Exchange" tab in the detail panel:
|
||||
|
||||
- **Inbound Message**: The original message as received by the route's consumer. Body + headers. Shown prominently, always visible.
|
||||
- **Step-by-Step Trace**: For each processor, show the exchange state AFTER that processor ran. Diff mode should highlight what changed (body mutations, added headers, removed headers).
|
||||
- **Properties**: Camel exchange properties (not just headers). Properties often carry routing decisions.
|
||||
- **Exception**: If the exchange failed, show the caught exception, the handled flag, and whether it was routed to a dead letter channel.
|
||||
- **Response**: If the route produces a response (e.g., REST endpoint), show the outbound body.
|
||||
|
||||
Display format should auto-detect JSON/XML and pretty-print. Binary payloads should show hex dump with size.
|
||||
|
||||
### 3.3 Metrics Dashboard (Developer vs. Operator KPIs)
|
||||
|
||||
The current metrics (throughput, latency p99, error rate) are operator KPIs. A Camel developer also needs:
|
||||
|
||||
**Developer KPIs** (add a "Developer" metrics view):
|
||||
- Per-processor latency breakdown (stacked bar: which processors consume the most time)
|
||||
- External endpoint response time (HTTP, DB, JMS) -- separate from Camel processing time
|
||||
- Type converter cache hit rate (rarely needed, but valuable when debugging serialization issues)
|
||||
- Redelivery count (how many messages required retries before succeeding)
|
||||
- Content-based router distribution (for `choice()` routes: how many messages went down each branch)
|
||||
|
||||
**Operator KPIs** (already well-covered):
|
||||
- Throughput, error rate, latency percentiles -- these are solid as-is
|
||||
|
||||
### 3.4 Dead Letter Queue View
|
||||
|
||||
A dedicated DLQ page:
|
||||
|
||||
- **Summary Cards**: One card per DLQ endpoint (e.g., `jms:DLQ.orders`, `seda:error-handler`), showing message count, oldest message age, newest message timestamp.
|
||||
- **Message List**: Table with columns: original route, exception class, business ID, timestamp, retry count.
|
||||
- **Message Detail**: Click a DLQ message to see the exchange snapshot (body + headers + exception) at the time of failure.
|
||||
- **Actions**: Retry (re-submit to original endpoint), Retry All (bulk retry for a pattern), Discard, Move to another queue.
|
||||
- **Filters**: By exception type, by route, by age.
|
||||
|
||||
### 3.5 Route Comparison
|
||||
|
||||
Two use cases:
|
||||
|
||||
1. **Version diff**: Compare route graph v3.2.0 vs. v3.2.1. Show added/removed/modified processors as a visual diff on the DAG.
|
||||
2. **Performance comparison**: Compare this week's latency distribution for `payment-process` with last week's. Overlay histograms. Useful for validating that a deployment improved (or degraded) performance.
|
||||
|
||||
---
|
||||
|
||||
## 4. Information Architecture Critique
|
||||
|
||||
### What Works
|
||||
- **Sidebar hierarchy** (Applications > Routes) is correct and matches how Camel projects are structured.
|
||||
- **Health strip at top** provides instant situational awareness without scrolling.
|
||||
- **Master-detail pattern** (table + slide-in panel) avoids page navigation for quick inspection. This keeps context.
|
||||
- **Keyboard shortcuts** (Ctrl+K search, arrow navigation, Esc to close) are the right accelerators for power users.
|
||||
|
||||
### What Needs Adjustment
|
||||
|
||||
**The sidebar is too flat.** It shows applications and routes in the same list, but there is no way to navigate to:
|
||||
- A dedicated Route Detail page (with per-processor stats, diagram, error patterns)
|
||||
- An Agent Detail page (with resource utilization, version info, configuration)
|
||||
- A DLQ page
|
||||
- A Search/Trace page (for cross-route correlation)
|
||||
|
||||
Recommendation: Add top-level navigation items to the sidebar:
|
||||
```
|
||||
Dashboard (the current view)
|
||||
Routes (route list with status, drill into route detail)
|
||||
Traces (cross-route message flow / correlation)
|
||||
Errors (grouped error patterns, DLQ)
|
||||
Agents (agent health, resource utilization)
|
||||
Diagrams (route graph visualization)
|
||||
```
|
||||
|
||||
**Route click should go deeper.** Currently, clicking a route in the sidebar filters the execution table. This is useful, but clicking the route NAME in a table row or in the detail panel should navigate to a dedicated Route Detail page with per-processor aggregate stats and the route diagram.
|
||||
|
||||
**Search results need grouping.** The Ctrl+K search bar says "Search by Order ID, route, error..." but search results should group by correlation ID when searching by business ID. If I search for "OP-88421", I want to see ALL executions related to that order across all routes, not just the one row in `payment-process`.
|
||||
|
||||
**1-click access priorities:**
|
||||
- Health overview: 1 click (current: 0 clicks -- it is the home page -- good)
|
||||
- Filter by errors only: 1 click (current: 1 click on Error pill -- good)
|
||||
- View a specific execution's processor timeline: 2 clicks (current: 1 click on row -- good)
|
||||
- View exchange body/headers: should be 2 clicks (click row, click Exchange tab). Currently not implemented.
|
||||
- View route diagram: should be 2 clicks (click route name, see diagram). Currently requires finding the button in the detail panel.
|
||||
- Cross-route trace: should be 2 clicks (click correlation ID or business ID, see trace). Currently not possible.
|
||||
- DLQ status: should be 1 click from sidebar. Currently not available.
|
||||
|
||||
---
|
||||
|
||||
## 5. Score Card
|
||||
|
||||
| Dimension | Score (1-10) | Notes |
|
||||
|-----------------------------|:---:|-------|
|
||||
| Transaction tracking | 4 | Individual executions visible, but no cross-route transaction view. Correlation ID shown but not actionable. |
|
||||
| Root cause analysis | 6 | Processor timeline identifies the slow/failing step. Error messages shown inline. But no exchange body inspection, no stack trace expansion, no header diff. |
|
||||
| Performance monitoring | 7 | Throughput, latency p99, error rate charts with SLA lines are solid. Missing per-processor aggregate stats and resource utilization. |
|
||||
| Route visualization | 3 | Route names in sidebar, but no actual route diagram/DAG. The "View Route Diagram" button exists with no destination. This is Cameleer's key differentiator -- it must ship. |
|
||||
| Exchange/message visibility | 2 | Exchange tab exists but has no content. No body inspection, no header view, no step-by-step diff. This is the most critical gap. |
|
||||
| Correlation/tracing | 3 | Correlation ID displayed in detail panel, but no way to trace a message across routes. No breadcrumb linking. No transaction waterfall. |
|
||||
| Overall daily usefulness | 5 | As an operations monitor (is anything broken right now?), it scores 7-8. As a developer debugging tool (why is it broken and how do I fix it?), it scores 3-4. The gap is in the debugging/inspection features. |
|
||||
|
||||
### Summary Verdict
|
||||
|
||||
The dashboard is a **strong operations monitor** -- it answers "what is happening right now?" effectively. The health strip, SLA awareness, shift context, business ID columns, and inline error previews are genuinely useful and better than most tools I have used.
|
||||
|
||||
However, it is a **weak debugging tool** -- it does not yet answer "why did this specific message fail?" or "what did the exchange look like at each step?" The Exchange tab, route diagram, cross-route tracing, and error pattern grouping are the features that would make this a daily-driver tool rather than a pretty overview I glance at in the morning.
|
||||
|
||||
The processor Gantt chart in the detail panel is the single best feature in the entire dashboard. Build on that. Make it clickable (click a processor to see the exchange state at that point). Add aggregate stats. Link it to the route diagram. That is where this tool becomes indispensable.
|
||||
|
||||
**Bottom line**: Ship the exchange inspector, the route diagram, and cross-route tracing, and this goes from a 5/10 to an 8/10 daily-use tool.
|
||||
1502
docs/ui-mocks/mock-design-expert.html
Normal file
1502
docs/ui-mocks/mock-design-expert.html
Normal file
File diff suppressed because one or more lines are too long
1651
docs/ui-mocks/mock-operator-expert.html
Normal file
1651
docs/ui-mocks/mock-operator-expert.html
Normal file
File diff suppressed because it is too large
Load Diff
1565
docs/ui-mocks/mock-usability-expert.html
Normal file
1565
docs/ui-mocks/mock-usability-expert.html
Normal file
File diff suppressed because it is too large
Load Diff
1707
docs/ui-mocks/mock-v2-dark.html
Normal file
1707
docs/ui-mocks/mock-v2-dark.html
Normal file
File diff suppressed because one or more lines are too long
2076
docs/ui-mocks/mock-v2-light.html
Normal file
2076
docs/ui-mocks/mock-v2-light.html
Normal file
File diff suppressed because one or more lines are too long
1490
docs/ui-mocks/mock-v3-agent-health.html
Normal file
1490
docs/ui-mocks/mock-v3-agent-health.html
Normal file
File diff suppressed because one or more lines are too long
1945
docs/ui-mocks/mock-v3-exchange-detail.html
Normal file
1945
docs/ui-mocks/mock-v3-exchange-detail.html
Normal file
File diff suppressed because one or more lines are too long
2177
docs/ui-mocks/mock-v3-metrics-dashboard.html
Normal file
2177
docs/ui-mocks/mock-v3-metrics-dashboard.html
Normal file
File diff suppressed because one or more lines are too long
2336
docs/ui-mocks/mock-v3-route-detail.html
Normal file
2336
docs/ui-mocks/mock-v3-route-detail.html
Normal file
File diff suppressed because one or more lines are too long
34
ui/package-lock.json
generated
34
ui/package-lock.json
generated
@@ -8,7 +8,7 @@
|
||||
"name": "ui",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@cameleer/design-system": "^0.0.2",
|
||||
"@cameleer/design-system": "^0.0.3",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"openapi-fetch": "^0.17.0",
|
||||
"react": "^19.2.4",
|
||||
@@ -24,6 +24,7 @@
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.0",
|
||||
"cross-env": "^10.1.0",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
@@ -275,9 +276,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@cameleer/design-system": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://gitea.siegeln.net/api/packages/cameleer/npm/%40cameleer%2Fdesign-system/-/0.0.2/design-system-0.0.2.tgz",
|
||||
"integrity": "sha512-6PbqtrW4E1yVE+ou2BCYVdHItvN88kNStS2pIKHuJhcerY3vCctLNU4pZSORkLUfvB181I+QIkBIEFa1CKSG8Q==",
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://gitea.siegeln.net/api/packages/cameleer/npm/%40cameleer%2Fdesign-system/-/0.0.3/design-system-0.0.3.tgz",
|
||||
"integrity": "sha512-x1mZvgYz7j57xFB26pMh9hn5waSJA1CcRWTgkzleLfaO/CmhekLup1HHlbh0b9SxVci6g2HzbcJldr4kvM1yzg==",
|
||||
"dependencies": {
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
@@ -323,6 +324,13 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@epic-web/invariant": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz",
|
||||
"integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@eslint-community/eslint-utils": {
|
||||
"version": "4.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
|
||||
@@ -1629,6 +1637,24 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-env": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz",
|
||||
"integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@epic-web/invariant": "^1.0.0",
|
||||
"cross-spawn": "^7.0.6"
|
||||
},
|
||||
"bin": {
|
||||
"cross-env": "dist/bin/cross-env.js",
|
||||
"cross-env-shell": "dist/bin/cross-env-shell.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev:local": "cross-env VITE_API_TARGET=http://localhost:8081 vite",
|
||||
"dev:remote": "cross-env VITE_API_TARGET=http://192.168.50.86:30090 vite",
|
||||
"build": "tsc -p tsconfig.app.json --noEmit && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
@@ -12,7 +14,7 @@
|
||||
"generate-api:live": "curl -s http://localhost:8081/api/v1/api-docs -o src/api/openapi.json && openapi-typescript src/api/openapi.json -o src/api/schema.d.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cameleer/design-system": "^0.0.2",
|
||||
"@cameleer/design-system": "^0.0.3",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"openapi-fetch": "^0.17.0",
|
||||
"react": "^19.2.4",
|
||||
@@ -28,6 +30,7 @@
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.0",
|
||||
"cross-env": "^10.1.0",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -3,12 +3,12 @@ import { api } from '../client';
|
||||
import { config } from '../../config';
|
||||
import { useAuthStore } from '../../auth/auth-store';
|
||||
|
||||
export function useAgents(status?: string, group?: string) {
|
||||
export function useAgents(status?: string, application?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['agents', status, group],
|
||||
queryKey: ['agents', status, application],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await api.GET('/agents', {
|
||||
params: { query: { ...(status ? { status } : {}), ...(group ? { group } : {}) } },
|
||||
params: { query: { ...(status ? { status } : {}), ...(application ? { application } : {}) } },
|
||||
});
|
||||
if (error) throw new Error('Failed to load agents');
|
||||
return data!;
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '../client';
|
||||
|
||||
interface DiagramLayout {
|
||||
width?: number;
|
||||
height?: number;
|
||||
nodes?: Array<{ id?: string; label?: string; type?: string; x?: number; y?: number; width?: number; height?: number }>;
|
||||
edges?: Array<{ from?: string; to?: string }>;
|
||||
}
|
||||
|
||||
export function useDiagramLayout(contentHash: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ['diagrams', 'layout', contentHash],
|
||||
@@ -10,22 +17,22 @@ export function useDiagramLayout(contentHash: string | null) {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (error) throw new Error('Failed to load diagram layout');
|
||||
return data!;
|
||||
return data as DiagramLayout;
|
||||
},
|
||||
enabled: !!contentHash,
|
||||
});
|
||||
}
|
||||
|
||||
export function useDiagramByRoute(group: string | undefined, routeId: string | undefined) {
|
||||
export function useDiagramByRoute(application: string | undefined, routeId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ['diagrams', 'byRoute', group, routeId],
|
||||
queryKey: ['diagrams', 'byRoute', application, routeId],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await api.GET('/diagrams', {
|
||||
params: { query: { group: group!, routeId: routeId! } },
|
||||
params: { query: { application: application!, routeId: routeId! } },
|
||||
});
|
||||
if (error) throw new Error('Failed to load diagram for route');
|
||||
return data!;
|
||||
},
|
||||
enabled: !!group && !!routeId,
|
||||
enabled: !!application && !!routeId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,10 +6,10 @@ export function useExecutionStats(
|
||||
timeFrom: string | undefined,
|
||||
timeTo: string | undefined,
|
||||
routeId?: string,
|
||||
group?: string,
|
||||
application?: string,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ['executions', 'stats', timeFrom, timeTo, routeId, group],
|
||||
queryKey: ['executions', 'stats', timeFrom, timeTo, routeId, application],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await api.GET('/search/stats', {
|
||||
params: {
|
||||
@@ -17,7 +17,7 @@ export function useExecutionStats(
|
||||
from: timeFrom!,
|
||||
to: timeTo || undefined,
|
||||
routeId: routeId || undefined,
|
||||
group: group || undefined,
|
||||
application: application || undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -49,10 +49,10 @@ export function useStatsTimeseries(
|
||||
timeFrom: string | undefined,
|
||||
timeTo: string | undefined,
|
||||
routeId?: string,
|
||||
group?: string,
|
||||
application?: string,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ['executions', 'timeseries', timeFrom, timeTo, routeId, group],
|
||||
queryKey: ['executions', 'timeseries', timeFrom, timeTo, routeId, application],
|
||||
queryFn: async () => {
|
||||
const { data, error } = await api.GET('/search/stats/timeseries', {
|
||||
params: {
|
||||
@@ -61,7 +61,7 @@ export function useStatsTimeseries(
|
||||
to: timeTo || undefined,
|
||||
buckets: 24,
|
||||
routeId: routeId || undefined,
|
||||
group: group || undefined,
|
||||
application: application || undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
29
ui/src/api/schema.d.ts
vendored
29
ui/src/api/schema.d.ts
vendored
@@ -625,10 +625,10 @@ export interface paths {
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Find diagram by application group and route ID
|
||||
* @description Resolves group to agent IDs and finds the latest diagram for the route
|
||||
* Find diagram by application and route ID
|
||||
* @description Resolves application to agent IDs and finds the latest diagram for the route
|
||||
*/
|
||||
get: operations["findByGroupAndRoute"];
|
||||
get: operations["findByApplicationAndRoute"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
@@ -683,7 +683,7 @@ export interface paths {
|
||||
};
|
||||
/**
|
||||
* List all agents
|
||||
* @description Returns all registered agents with runtime metrics, optionally filtered by status and/or group
|
||||
* @description Returns all registered agents with runtime metrics, optionally filtered by status and/or application
|
||||
*/
|
||||
get: operations["listAgents"];
|
||||
put?: never;
|
||||
@@ -1079,7 +1079,7 @@ export interface components {
|
||||
routeId?: string;
|
||||
agentId?: string;
|
||||
processorType?: string;
|
||||
group?: string;
|
||||
application?: string;
|
||||
agentIds?: string[];
|
||||
/** Format: int32 */
|
||||
offset?: number;
|
||||
@@ -1092,6 +1092,7 @@ export interface components {
|
||||
executionId: string;
|
||||
routeId: string;
|
||||
agentId: string;
|
||||
applicationName: string;
|
||||
status: string;
|
||||
/** Format: date-time */
|
||||
startTime: string;
|
||||
@@ -1157,7 +1158,7 @@ export interface components {
|
||||
agentId: string;
|
||||
name: string;
|
||||
/** @default default */
|
||||
group: string;
|
||||
application: string;
|
||||
version?: string;
|
||||
routeIds?: string[];
|
||||
capabilities?: {
|
||||
@@ -1326,7 +1327,7 @@ export interface components {
|
||||
errorStackTrace: string;
|
||||
diagramContentHash: string;
|
||||
processors: components["schemas"]["ProcessorNode"][];
|
||||
groupName?: string;
|
||||
applicationName?: string;
|
||||
children?: components["schemas"]["ProcessorNode"][];
|
||||
};
|
||||
ProcessorNode: {
|
||||
@@ -1383,7 +1384,7 @@ export interface components {
|
||||
AgentInstanceResponse: {
|
||||
id: string;
|
||||
name: string;
|
||||
group: string;
|
||||
application: string;
|
||||
status: string;
|
||||
routeIds: string[];
|
||||
/** Format: date-time */
|
||||
@@ -2976,7 +2977,7 @@ export interface operations {
|
||||
from: string;
|
||||
to?: string;
|
||||
routeId?: string;
|
||||
group?: string;
|
||||
application?: string;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
@@ -3002,7 +3003,7 @@ export interface operations {
|
||||
to?: string;
|
||||
buckets?: number;
|
||||
routeId?: string;
|
||||
group?: string;
|
||||
application?: string;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
@@ -3132,10 +3133,10 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
findByGroupAndRoute: {
|
||||
findByApplicationAndRoute: {
|
||||
parameters: {
|
||||
query: {
|
||||
group: string;
|
||||
application: string;
|
||||
routeId: string;
|
||||
};
|
||||
header?: never;
|
||||
@@ -3153,7 +3154,7 @@ export interface operations {
|
||||
"*/*": components["schemas"]["DiagramLayout"];
|
||||
};
|
||||
};
|
||||
/** @description No diagram found for the given group and route */
|
||||
/** @description No diagram found for the given application and route */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
@@ -3238,7 +3239,7 @@ export interface operations {
|
||||
parameters: {
|
||||
query?: {
|
||||
status?: string;
|
||||
group?: string;
|
||||
application?: string;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
|
||||
BIN
ui/src/fonts/dm-sans-400-italic.woff2
Normal file
BIN
ui/src/fonts/dm-sans-400-italic.woff2
Normal file
Binary file not shown.
BIN
ui/src/fonts/dm-sans-400.woff2
Normal file
BIN
ui/src/fonts/dm-sans-400.woff2
Normal file
Binary file not shown.
BIN
ui/src/fonts/dm-sans-500.woff2
Normal file
BIN
ui/src/fonts/dm-sans-500.woff2
Normal file
Binary file not shown.
BIN
ui/src/fonts/dm-sans-600.woff2
Normal file
BIN
ui/src/fonts/dm-sans-600.woff2
Normal file
Binary file not shown.
BIN
ui/src/fonts/dm-sans-700.woff2
Normal file
BIN
ui/src/fonts/dm-sans-700.woff2
Normal file
Binary file not shown.
BIN
ui/src/fonts/jetbrains-mono-400.woff2
Normal file
BIN
ui/src/fonts/jetbrains-mono-400.woff2
Normal file
Binary file not shown.
BIN
ui/src/fonts/jetbrains-mono-500.woff2
Normal file
BIN
ui/src/fonts/jetbrains-mono-500.woff2
Normal file
Binary file not shown.
BIN
ui/src/fonts/jetbrains-mono-600.woff2
Normal file
BIN
ui/src/fonts/jetbrains-mono-600.woff2
Normal file
Binary file not shown.
@@ -1,4 +1,62 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&family=JetBrains+Mono:wght@400;500;600&display=swap');
|
||||
/* DM Sans — self-hosted (GDPR compliant) */
|
||||
@font-face {
|
||||
font-family: 'DM Sans';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('./fonts/dm-sans-400.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'DM Sans';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url('./fonts/dm-sans-500.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'DM Sans';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url('./fonts/dm-sans-600.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'DM Sans';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('./fonts/dm-sans-700.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'DM Sans';
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('./fonts/dm-sans-400-italic.woff2') format('woff2');
|
||||
}
|
||||
|
||||
/* JetBrains Mono — self-hosted (GDPR compliant) */
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('./fonts/jetbrains-mono-400.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url('./fonts/jetbrains-mono-500.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url('./fonts/jetbrains-mono-600.woff2') format('woff2');
|
||||
}
|
||||
|
||||
:root {
|
||||
font-family: 'DM Sans', system-ui, sans-serif;
|
||||
|
||||
26
ui/src/pages/Admin/AdminLayout.tsx
Normal file
26
ui/src/pages/Admin/AdminLayout.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Outlet, useNavigate, useLocation } from 'react-router';
|
||||
import { Tabs } from '@cameleer/design-system';
|
||||
|
||||
const ADMIN_TABS = [
|
||||
{ label: 'User Management', value: '/admin/rbac' },
|
||||
{ label: 'Audit Log', value: '/admin/audit' },
|
||||
{ label: 'OIDC', value: '/admin/oidc' },
|
||||
{ label: 'Database', value: '/admin/database' },
|
||||
{ label: 'OpenSearch', value: '/admin/opensearch' },
|
||||
];
|
||||
|
||||
export default function AdminLayout() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Tabs
|
||||
tabs={ADMIN_TABS}
|
||||
active={location.pathname}
|
||||
onChange={(path) => navigate(path)}
|
||||
/>
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,7 @@ export default function OpenSearchAdminPage() {
|
||||
const indexColumns: Column<any>[] = [
|
||||
{ key: 'name', header: 'Index' },
|
||||
{ key: 'health', header: 'Health', render: (v) => <Badge label={String(v)} color={v === 'green' ? 'success' : v === 'yellow' ? 'warning' : 'error'} /> },
|
||||
{ key: 'docCount', header: 'Documents', sortable: true },
|
||||
{ key: 'docCount', header: 'Documents', sortable: true, render: (v) => Number(v).toLocaleString() },
|
||||
{ key: 'size', header: 'Size' },
|
||||
{ key: 'primaryShards', header: 'Shards' },
|
||||
];
|
||||
|
||||
@@ -1,48 +1,191 @@
|
||||
.statStrip { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-bottom: 16px; }
|
||||
.splitPane { display: grid; grid-template-columns: 52fr 48fr; height: calc(100vh - 280px); }
|
||||
.listPane { overflow-y: auto; border-right: 1px solid var(--border-subtle); padding-right: 16px; }
|
||||
.detailPane { overflow-y: auto; padding-left: 16px; }
|
||||
.listHeader { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
|
||||
.entityList { display: flex; flex-direction: column; gap: 2px; }
|
||||
.statStrip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.splitPane {
|
||||
display: grid;
|
||||
grid-template-columns: 52fr 48fr;
|
||||
gap: 1px;
|
||||
background: var(--border-subtle);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
min-height: 500px;
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.listPane {
|
||||
background: var(--bg-surface);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: var(--radius-lg) 0 0 var(--radius-lg);
|
||||
}
|
||||
|
||||
.detailPane {
|
||||
background: var(--bg-surface);
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
border-radius: 0 var(--radius-lg) var(--radius-lg) 0;
|
||||
}
|
||||
|
||||
.listHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.listHeader input { flex: 1; }
|
||||
|
||||
.entityList {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.entityItem {
|
||||
display: flex; align-items: center; gap: 10px; padding: 8px 10px;
|
||||
cursor: pointer; border-radius: 6px; transition: background 0.1s;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
.entityItem:hover { background: var(--bg-hover); }
|
||||
.entityItemSelected { background: var(--bg-raised); }
|
||||
.entityInfo { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
|
||||
.entityName { font-weight: 600; font-size: 13px; display: flex; align-items: center; gap: 6px; }
|
||||
.entityMeta { font-size: 11px; color: var(--text-muted); }
|
||||
.entityTags { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 2px; }
|
||||
|
||||
.entityItem:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.entityItem:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.entityItemSelected {
|
||||
background: var(--bg-raised);
|
||||
}
|
||||
|
||||
.entityInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.entityName {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.entityMeta {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.entityTags {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.createForm {
|
||||
background: var(--bg-surface); border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg); padding: 12px; margin-bottom: 12px;
|
||||
background: var(--bg-raised);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
padding: 12px;
|
||||
}
|
||||
.createFormActions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 8px; }
|
||||
|
||||
.createFormActions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.detailHeader {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
margin-bottom: 16px; padding-bottom: 16px; border-bottom: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.metaGrid {
|
||||
display: grid; grid-template-columns: 100px 1fr; gap: 6px 12px;
|
||||
font-size: 13px; margin-bottom: 16px;
|
||||
display: grid;
|
||||
grid-template-columns: 100px 1fr;
|
||||
gap: 6px 12px;
|
||||
font-size: 13px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.metaLabel {
|
||||
font-weight: 700; font-size: 10px; text-transform: uppercase;
|
||||
letter-spacing: 0.6px; color: var(--text-muted);
|
||||
}
|
||||
.sectionTags { display: flex; gap: 4px; flex-wrap: wrap; margin-bottom: 8px; }
|
||||
.inheritedNote { font-size: 11px; font-style: italic; color: var(--text-muted); margin-top: 4px; }
|
||||
.securitySection {
|
||||
padding: 12px; border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg); margin-bottom: 16px;
|
||||
}
|
||||
.resetForm { display: flex; gap: 8px; margin-top: 8px; }
|
||||
.emptyDetail {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
height: 100%; color: var(--text-muted); font-size: 13px;
|
||||
font-weight: 700;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 13px; font-weight: 700; color: var(--text-primary);
|
||||
margin-bottom: 8px; margin-top: 16px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.sectionTags {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.inheritedNote {
|
||||
font-size: 11px;
|
||||
font-style: italic;
|
||||
color: var(--text-muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.securitySection {
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.resetForm {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.emptyDetail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.emptySearch {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.providerBadge {
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
@@ -307,7 +307,11 @@ export default function UsersTab() {
|
||||
<Badge label={user.provider} variant="outlined" />
|
||||
)}
|
||||
</div>
|
||||
{user.email && <div className={styles.entityMeta}>{user.email}</div>}
|
||||
<div className={styles.entityMeta}>
|
||||
{user.email || user.userId}
|
||||
{user.directGroups.length > 0 && ` · ${user.directGroups.map((g) => g.name).join(', ')}`}
|
||||
{user.directGroups.length === 0 && ' · no groups'}
|
||||
</div>
|
||||
{(user.directRoles.length > 0 || user.directGroups.length > 0) && (
|
||||
<div className={styles.entityTags}>
|
||||
{user.directRoles.map((r) => (
|
||||
|
||||
@@ -19,15 +19,54 @@
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.instanceRow {
|
||||
/* GroupCard meta strip */
|
||||
.groupMeta {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.groupMeta strong {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Instance table */
|
||||
.instanceTable {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.instanceTable thead tr {
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.instanceTable thead th {
|
||||
padding: 6px 8px;
|
||||
text-align: left;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.thStatus {
|
||||
width: 24px;
|
||||
}
|
||||
|
||||
.tdStatus {
|
||||
width: 24px;
|
||||
padding: 0 4px 0 8px;
|
||||
}
|
||||
|
||||
.instanceRow {
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.instanceRow:last-child {
|
||||
@@ -38,6 +77,15 @@
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.instanceRow td {
|
||||
padding: 7px 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.instanceRowActive {
|
||||
background: var(--bg-selected, var(--bg-hover));
|
||||
}
|
||||
|
||||
.instanceName {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
@@ -49,6 +97,24 @@
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.instanceError {
|
||||
font-size: 11px;
|
||||
color: var(--error);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.instanceHeartbeatDead {
|
||||
font-size: 11px;
|
||||
color: var(--error);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.instanceHeartbeatStale {
|
||||
font-size: 11px;
|
||||
color: var(--warning);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.instanceLink {
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
@@ -178,3 +244,36 @@
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Status breakdown in stat card */
|
||||
.statusBreakdown {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.statusLive { color: var(--success); }
|
||||
.statusStale { color: var(--warning); }
|
||||
.statusDead { color: var(--error); }
|
||||
|
||||
/* Scope trail */
|
||||
.scopeLabel {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* DetailPanel override */
|
||||
.detailPanelOverride {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
height: 100vh;
|
||||
z-index: 100;
|
||||
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.panelDivider {
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useMemo, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router';
|
||||
import {
|
||||
StatCard, StatusDot, Badge, MonoText,
|
||||
GroupCard, EventFeed, Breadcrumb, Alert,
|
||||
GroupCard, EventFeed, Alert,
|
||||
DetailPanel, ProgressBar, LineChart,
|
||||
} from '@cameleer/design-system';
|
||||
import styles from './AgentHealth.module.css';
|
||||
@@ -63,7 +63,7 @@ function AgentOverviewContent({ agent }: { agent: any }) {
|
||||
<dl className={styles.detailList}>
|
||||
<div className={styles.detailRow}>
|
||||
<dt>Application</dt>
|
||||
<dd><MonoText>{agent.group ?? '—'}</MonoText></dd>
|
||||
<dd><MonoText>{agent.application ?? '—'}</MonoText></dd>
|
||||
</div>
|
||||
<div className={styles.detailRow}>
|
||||
<dt>Version</dt>
|
||||
@@ -175,7 +175,7 @@ export default function AgentHealth() {
|
||||
const agentsByApp = useMemo(() => {
|
||||
const map: Record<string, any[]> = {};
|
||||
(agents || []).forEach((a: any) => {
|
||||
const g = a.group;
|
||||
const g = a.application;
|
||||
if (!map[g]) map[g] = [];
|
||||
map[g].push(a);
|
||||
});
|
||||
@@ -185,28 +185,10 @@ export default function AgentHealth() {
|
||||
const liveCount = (agents || []).filter((a: any) => a.status === 'LIVE').length;
|
||||
const staleCount = (agents || []).filter((a: any) => a.status === 'STALE').length;
|
||||
const deadCount = (agents || []).filter((a: any) => a.status === 'DEAD').length;
|
||||
const uniqueApps = new Set((agents || []).map((a: any) => a.group)).size;
|
||||
const uniqueApps = new Set((agents || []).map((a: any) => a.application)).size;
|
||||
const activeRoutes = (agents || []).filter((a: any) => a.status === 'LIVE').reduce((sum: number, a: any) => sum + (a.activeRoutes || 0), 0);
|
||||
const totalTps = (agents || []).filter((a: any) => a.status === 'LIVE').reduce((sum: number, a: any) => sum + (a.tps || 0), 0);
|
||||
|
||||
const groupHealth: 'live' | 'stale' | 'dead' = useMemo(() => {
|
||||
if (!appId) return 'live';
|
||||
const groupAgents = agentsByApp[appId] || [];
|
||||
if (groupAgents.some((a: any) => a.status === 'DEAD')) return 'dead';
|
||||
if (groupAgents.some((a: any) => a.status === 'STALE')) return 'stale';
|
||||
return 'live';
|
||||
}, [appId, agentsByApp]);
|
||||
|
||||
const scopeItems = useMemo(() => {
|
||||
const items: { label: string; href?: string }[] = [
|
||||
{ label: 'Agent Health', href: '/agents' },
|
||||
];
|
||||
if (appId) {
|
||||
items.push({ label: appId });
|
||||
}
|
||||
return items;
|
||||
}, [appId]);
|
||||
|
||||
const feedEvents = useMemo(() =>
|
||||
(events || []).map((e: any) => ({
|
||||
id: String(e.id),
|
||||
@@ -225,64 +207,126 @@ export default function AgentHealth() {
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.statStrip}>
|
||||
<StatCard label="Total Agents" value={(agents || []).length} detail={`${liveCount} live / ${staleCount} stale / ${deadCount} dead`} />
|
||||
<StatCard
|
||||
label="Total Agents"
|
||||
value={(agents || []).length}
|
||||
detail={
|
||||
<span className={styles.statusBreakdown}>
|
||||
<span className={styles.statusLive}>{liveCount} live</span>
|
||||
<span className={styles.statusStale}>{staleCount} stale</span>
|
||||
<span className={styles.statusDead}>{deadCount} dead</span>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<StatCard label="Applications" value={uniqueApps} />
|
||||
<StatCard label="Active Routes" value={activeRoutes} />
|
||||
<StatCard label="Total TPS" value={totalTps.toFixed(1)} />
|
||||
<StatCard label="Dead" value={deadCount} accent={deadCount > 0 ? 'error' : undefined} />
|
||||
<StatCard label="Total TPS" value={totalTps.toFixed(1)} detail="msg/s" />
|
||||
<StatCard label="Dead" value={deadCount} accent={deadCount > 0 ? 'error' : undefined} detail={deadCount > 0 ? 'requires attention' : undefined} />
|
||||
</div>
|
||||
|
||||
<div className={styles.scopeTrail}>
|
||||
<Breadcrumb items={scopeItems} />
|
||||
{!appId && <Badge label={`${liveCount} live`} variant="outlined" />}
|
||||
{appId && (
|
||||
<Badge
|
||||
label={groupHealth}
|
||||
color={groupHealth === 'live' ? 'success' : groupHealth === 'stale' ? 'warning' : 'error'}
|
||||
/>
|
||||
)}
|
||||
<span className={styles.scopeLabel}>{liveCount}/{(agents || []).length} live</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.groupGrid}>
|
||||
{Object.entries(apps).map(([group, groupAgents]) => {
|
||||
const deadInGroup = (groupAgents || []).filter((a: any) => a.status === 'DEAD');
|
||||
const groupTps = (groupAgents || []).reduce((s: number, a: any) => s + (a.tps || 0), 0);
|
||||
const groupActiveRoutes = (groupAgents || []).reduce((s: number, a: any) => s + (a.activeRoutes || 0), 0);
|
||||
const groupTotalRoutes = (groupAgents || []).reduce((s: number, a: any) => s + (a.totalRoutes || 0), 0);
|
||||
const liveInGroup = (groupAgents || []).filter((a: any) => a.status === 'LIVE').length;
|
||||
return (
|
||||
<GroupCard
|
||||
key={group}
|
||||
title={group}
|
||||
headerRight={<Badge label={`${groupAgents?.length ?? 0} instances`} />}
|
||||
headerRight={
|
||||
<Badge
|
||||
label={`${liveInGroup}/${groupAgents?.length ?? 0} LIVE`}
|
||||
color={
|
||||
groupAgents?.some((a: any) => a.status === 'DEAD') ? 'error'
|
||||
: groupAgents?.some((a: any) => a.status === 'STALE') ? 'warning'
|
||||
: 'success'
|
||||
}
|
||||
variant="filled"
|
||||
/>
|
||||
}
|
||||
meta={
|
||||
<div className={styles.groupMeta}>
|
||||
<span><strong>{groupTps.toFixed(1)}</strong> msg/s</span>
|
||||
<span><strong>{groupActiveRoutes}</strong>/{groupTotalRoutes} routes</span>
|
||||
</div>
|
||||
}
|
||||
accent={
|
||||
groupAgents?.some((a: any) => a.status === 'DEAD') ? 'error'
|
||||
: groupAgents?.some((a: any) => a.status === 'STALE') ? 'warning'
|
||||
: 'success'
|
||||
}
|
||||
onClick={() => navigate(`/agents/${group}`)}
|
||||
>
|
||||
{deadInGroup.length > 0 && (
|
||||
<Alert variant="error">{deadInGroup.length} instance(s) unreachable</Alert>
|
||||
)}
|
||||
{(groupAgents || []).map((agent: any) => (
|
||||
<div
|
||||
key={agent.id}
|
||||
className={styles.instanceRow}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedAgent(agent);
|
||||
navigate(`/agents/${group}/${agent.id}`);
|
||||
}}
|
||||
>
|
||||
<StatusDot variant={agent.status === 'LIVE' ? 'live' : agent.status === 'STALE' ? 'stale' : 'dead'} />
|
||||
<span className={styles.instanceName}>{agent.name}</span>
|
||||
<Badge label={agent.status} color={agent.status === 'LIVE' ? 'success' : agent.status === 'STALE' ? 'warning' : 'error'} />
|
||||
<span className={styles.instanceMeta}>{formatUptime(agent.uptimeSeconds)}</span>
|
||||
{agent.tps != null && <span className={styles.instanceMeta}>{(agent.tps || 0).toFixed(1)} tps</span>}
|
||||
{agent.errorRate != null && (
|
||||
<span className={styles.instanceMeta}>{(agent.errorRate * 100).toFixed(1)}% err</span>
|
||||
)}
|
||||
<span className={styles.instanceMeta}>{formatRelativeTime(agent.lastHeartbeat)}</span>
|
||||
<span className={styles.instanceLink} aria-label="View instance">›</span>
|
||||
</div>
|
||||
))}
|
||||
<table className={styles.instanceTable}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={styles.thStatus} />
|
||||
<th>Instance</th>
|
||||
<th>State</th>
|
||||
<th>Uptime</th>
|
||||
<th>TPS</th>
|
||||
<th>Errors</th>
|
||||
<th>Heartbeat</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(groupAgents || []).map((agent: any) => (
|
||||
<tr
|
||||
key={agent.id}
|
||||
className={[
|
||||
styles.instanceRow,
|
||||
selectedAgent?.id === agent.id ? styles.instanceRowActive : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
onClick={() => {
|
||||
setSelectedAgent(agent);
|
||||
navigate(`/agents/${group}/${agent.id}`);
|
||||
}}
|
||||
>
|
||||
<td className={styles.tdStatus}>
|
||||
<StatusDot variant={agent.status === 'LIVE' ? 'live' : agent.status === 'STALE' ? 'stale' : 'dead'} />
|
||||
</td>
|
||||
<td>
|
||||
<MonoText size="sm" className={styles.instanceName}>{agent.name ?? agent.id}</MonoText>
|
||||
</td>
|
||||
<td>
|
||||
<Badge
|
||||
label={agent.status}
|
||||
color={agent.status === 'LIVE' ? 'success' : agent.status === 'STALE' ? 'warning' : 'error'}
|
||||
variant="filled"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<span className={styles.instanceMeta}>{formatUptime(agent.uptimeSeconds)}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span className={styles.instanceMeta}>{agent.tps != null ? `${(agent.tps as number).toFixed(1)}/s` : '—'}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span className={agent.errorRate != null ? styles.instanceError : styles.instanceMeta}>
|
||||
{agent.errorRate != null ? `${((agent.errorRate as number) * 100).toFixed(1)}%` : '—'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span className={
|
||||
agent.status === 'DEAD' ? styles.instanceHeartbeatDead
|
||||
: agent.status === 'STALE' ? styles.instanceHeartbeatStale
|
||||
: styles.instanceMeta
|
||||
}>
|
||||
{formatRelativeTime(agent.lastHeartbeat)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</GroupCard>
|
||||
);
|
||||
})}
|
||||
@@ -290,29 +334,26 @@ export default function AgentHealth() {
|
||||
|
||||
{feedEvents.length > 0 && (
|
||||
<div className={styles.eventCard}>
|
||||
<div className={styles.eventCardHeader}>Event Log</div>
|
||||
<div className={styles.eventCardHeader}>
|
||||
<span>Timeline</span>
|
||||
<Badge label={`${feedEvents.length} events`} variant="outlined" />
|
||||
</div>
|
||||
<EventFeed events={feedEvents} maxItems={100} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedAgent && (
|
||||
<DetailPanel
|
||||
open={!!selectedAgent}
|
||||
key={selectedAgent.id}
|
||||
open={true}
|
||||
title={selectedAgent.name ?? selectedAgent.id}
|
||||
onClose={() => setSelectedAgent(null)}
|
||||
tabs={[
|
||||
{
|
||||
label: 'Overview',
|
||||
value: 'overview',
|
||||
content: <AgentOverviewContent agent={selectedAgent} />,
|
||||
},
|
||||
{
|
||||
label: 'Performance',
|
||||
value: 'performance',
|
||||
content: <AgentPerformanceContent agent={selectedAgent} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
className={styles.detailPanelOverride}
|
||||
>
|
||||
<AgentOverviewContent agent={selectedAgent} />
|
||||
<div className={styles.panelDivider} />
|
||||
<AgentPerformanceContent agent={selectedAgent} />
|
||||
</DetailPanel>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -110,9 +110,66 @@
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.scopeTrail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 13px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.scopeLink {
|
||||
color: var(--text-accent, var(--text-primary));
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.scopeLink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.scopeSep {
|
||||
color: var(--text-muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.scopeCurrent {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.paneTitle {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.chartMeta {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.bottomSection {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 14px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.eventCount {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.emptyEvents {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@@ -122,10 +122,35 @@ export default function AgentInstance() {
|
||||
|
||||
<div className={styles.statStrip}>
|
||||
<StatCard label="CPU" value={cpuPct != null ? `${(cpuPct * 100).toFixed(0)}%` : '—'} />
|
||||
<StatCard label="Memory" value={memPct != null ? `${memPct.toFixed(0)}%` : '—'} />
|
||||
<StatCard
|
||||
label="Memory"
|
||||
value={memPct != null ? `${memPct.toFixed(0)}%` : '—'}
|
||||
detail={heapUsed != null && heapMax != null ? `${(heapUsed / (1024 * 1024)).toFixed(0)} MB / ${(heapMax / (1024 * 1024)).toFixed(0)} MB` : undefined}
|
||||
/>
|
||||
<StatCard label="Throughput" value={agent?.tps != null ? `${agent.tps.toFixed(1)}/s` : '—'} />
|
||||
<StatCard label="Errors" value={agent?.errorRate != null ? `${(agent.errorRate * 100).toFixed(1)}%` : '—'} accent={agent?.errorRate > 0 ? 'error' : undefined} />
|
||||
<StatCard label="Uptime" value={formatUptime(agent?.uptimeSeconds)} />
|
||||
<StatCard
|
||||
label="Uptime"
|
||||
value={formatUptime(agent?.uptimeSeconds)}
|
||||
detail={agent?.registeredAt ? `since ${new Date(agent.registeredAt).toLocaleDateString()}` : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.scopeTrail}>
|
||||
<a href="/agents" className={styles.scopeLink}>All Agents</a>
|
||||
<span className={styles.scopeSep}>▸</span>
|
||||
<a href={`/agents/${appId}`} className={styles.scopeLink}>{appId}</a>
|
||||
<span className={styles.scopeSep}>▸</span>
|
||||
<span className={styles.scopeCurrent}>{agent.name}</span>
|
||||
<Badge
|
||||
label={agent.status.toUpperCase()}
|
||||
color={agent.status === 'LIVE' ? 'success' : agent.status === 'STALE' ? 'warning' : 'error'}
|
||||
/>
|
||||
{agent.version && <Badge label={agent.version} variant="outlined" />}
|
||||
<Badge
|
||||
label={`${agent.activeRoutes ?? (agent.routeIds?.length ?? 0)}/${agent.totalRoutes ?? (agent.routeIds?.length ?? 0)} routes`}
|
||||
color={(agent.activeRoutes ?? 0) < (agent.totalRoutes ?? 0) ? 'warning' : 'success'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Card className={styles.infoCard}>
|
||||
@@ -175,54 +200,76 @@ export default function AgentInstance() {
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className={styles.sectionTitle}>Performance</div>
|
||||
<div className={styles.chartsGrid}>
|
||||
<div className={styles.chartCard}>
|
||||
<div className={styles.chartHeader}><div className={styles.chartTitle}>CPU Usage</div></div>
|
||||
<div className={styles.chartHeader}>
|
||||
<div className={styles.chartTitle}>CPU Usage</div>
|
||||
<div className={styles.chartMeta}>{cpuPct != null ? `${(cpuPct * 100).toFixed(0)}% current` : ''}</div>
|
||||
</div>
|
||||
{cpuSeries
|
||||
? <AreaChart series={cpuSeries} yLabel="%" height={200} />
|
||||
: <EmptyState title="No data" description="No CPU metrics available" />}
|
||||
</div>
|
||||
<div className={styles.chartCard}>
|
||||
<div className={styles.chartHeader}><div className={styles.chartTitle}>Memory Heap</div></div>
|
||||
<div className={styles.chartHeader}>
|
||||
<div className={styles.chartTitle}>Memory (Heap)</div>
|
||||
<div className={styles.chartMeta}>{heapUsed != null && heapMax != null ? `${(heapUsed / (1024 * 1024)).toFixed(0)} MB / ${(heapMax / (1024 * 1024)).toFixed(0)} MB` : ''}</div>
|
||||
</div>
|
||||
{heapSeries
|
||||
? <AreaChart series={heapSeries} yLabel="MB" height={200} />
|
||||
: <EmptyState title="No data" description="No heap metrics available" />}
|
||||
</div>
|
||||
<div className={styles.chartCard}>
|
||||
<div className={styles.chartHeader}><div className={styles.chartTitle}>Throughput</div></div>
|
||||
<div className={styles.chartHeader}>
|
||||
<div className={styles.chartTitle}>Throughput</div>
|
||||
<div className={styles.chartMeta}>{agent?.tps != null ? `${agent.tps.toFixed(1)} msg/s` : ''}</div>
|
||||
</div>
|
||||
{throughputSeries
|
||||
? <AreaChart series={throughputSeries} height={200} />
|
||||
? <AreaChart series={throughputSeries} yLabel="msg/s" height={200} />
|
||||
: <EmptyState title="No data" description="No throughput data in range" />}
|
||||
</div>
|
||||
<div className={styles.chartCard}>
|
||||
<div className={styles.chartHeader}><div className={styles.chartTitle}>Error Rate</div></div>
|
||||
<div className={styles.chartHeader}>
|
||||
<div className={styles.chartTitle}>Error Rate</div>
|
||||
<div className={styles.chartMeta}>{agent?.errorRate != null ? `${(agent.errorRate * 100).toFixed(1)}%` : ''}</div>
|
||||
</div>
|
||||
{errorSeries
|
||||
? <LineChart series={errorSeries} height={200} />
|
||||
? <LineChart series={errorSeries} yLabel="%" height={200} />
|
||||
: <EmptyState title="No data" description="No error data in range" />}
|
||||
</div>
|
||||
<div className={styles.chartCard}>
|
||||
<div className={styles.chartHeader}><div className={styles.chartTitle}>Thread Count</div></div>
|
||||
<div className={styles.chartHeader}>
|
||||
<div className={styles.chartTitle}>Thread Count</div>
|
||||
{threadSeries && <div className={styles.chartMeta}>{threadSeries[0].data[threadSeries[0].data.length - 1]?.y.toFixed(0)} active</div>}
|
||||
</div>
|
||||
{threadSeries
|
||||
? <LineChart series={threadSeries} height={200} />
|
||||
? <LineChart series={threadSeries} yLabel="threads" height={200} />
|
||||
: <EmptyState title="No data" description="No thread metrics available" />}
|
||||
</div>
|
||||
<div className={styles.chartCard}>
|
||||
<div className={styles.chartHeader}><div className={styles.chartTitle}>GC Pauses</div></div>
|
||||
<div className={styles.chartHeader}>
|
||||
<div className={styles.chartTitle}>GC Pauses</div>
|
||||
</div>
|
||||
{gcSeries
|
||||
? <BarChart series={gcSeries} yLabel="ms" height={200} />
|
||||
: <EmptyState title="No data" description="No GC metrics available" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{feedEvents.length > 0 && (
|
||||
<div className={styles.eventCard}>
|
||||
<div className={styles.eventCardHeader}>Events</div>
|
||||
<EventFeed events={feedEvents} maxItems={50} />
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.bottomSection}>
|
||||
<EmptyState title="Application Log" description="Application log streaming is not yet available" />
|
||||
|
||||
<div className={styles.eventCard}>
|
||||
<div className={styles.eventCardHeader}>
|
||||
<span>Timeline</span>
|
||||
<span className={styles.eventCount}>{feedEvents.length} events</span>
|
||||
</div>
|
||||
{feedEvents.length > 0
|
||||
? <EventFeed events={feedEvents} maxItems={50} />
|
||||
: <div className={styles.emptyEvents}>No events in the selected time range.</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EmptyState title="Application Logs" description="Application log streaming is not yet available" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -58,6 +58,18 @@
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.panelSectionMeta {
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.overviewGrid {
|
||||
@@ -82,3 +94,46 @@
|
||||
flex-shrink: 0;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.inspectLink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.inspectLink:hover {
|
||||
color: var(--accent, #c6820e);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.detailPanelOverride {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
height: 100vh;
|
||||
z-index: 100;
|
||||
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.openDetailLink {
|
||||
display: inline-block;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--accent, #c6820e);
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.openDetailLink:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@@ -1,27 +1,33 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
import { useParams, useNavigate } from 'react-router';
|
||||
import {
|
||||
StatCard, StatusDot, Badge, MonoText, Sparkline,
|
||||
StatCard, StatusDot, Badge, MonoText,
|
||||
DataTable, DetailPanel, ProcessorTimeline, RouteFlow,
|
||||
Alert, Collapsible, CodeBlock,
|
||||
Alert, Collapsible, CodeBlock, ShortcutsBar,
|
||||
} from '@cameleer/design-system';
|
||||
import type { Column } from '@cameleer/design-system';
|
||||
import { useSearchExecutions, useExecutionStats, useStatsTimeseries, useExecutionDetail, useProcessorSnapshot } from '../../api/queries/executions';
|
||||
import { useSearchExecutions, useExecutionStats, useStatsTimeseries, useExecutionDetail } from '../../api/queries/executions';
|
||||
import { useDiagramLayout } from '../../api/queries/diagrams';
|
||||
import { useGlobalFilters } from '@cameleer/design-system';
|
||||
import type { ExecutionSummary } from '../../api/types';
|
||||
import { mapDiagramToRouteNodes } from '../../utils/diagram-mapping';
|
||||
import styles from './Dashboard.module.css';
|
||||
|
||||
interface Row extends ExecutionSummary { id: string }
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
return `${(ms / 1000).toFixed(1)}s`;
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const { appId, routeId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { timeRange } = useGlobalFilters();
|
||||
const timeFrom = timeRange.start.toISOString();
|
||||
const timeTo = timeRange.end.toISOString();
|
||||
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [detailTab, setDetailTab] = useState('overview');
|
||||
const [processorIdx, setProcessorIdx] = useState<number | null>(null);
|
||||
|
||||
const timeWindowSeconds = (timeRange.end.getTime() - timeRange.start.getTime()) / 1000;
|
||||
|
||||
@@ -30,63 +36,195 @@ export default function Dashboard() {
|
||||
const { data: searchResult } = useSearchExecutions({
|
||||
timeFrom, timeTo,
|
||||
routeId: routeId || undefined,
|
||||
group: appId || undefined,
|
||||
application: appId || undefined,
|
||||
offset: 0, limit: 50,
|
||||
}, true);
|
||||
const { data: detail } = useExecutionDetail(selectedId);
|
||||
const { data: snapshot } = useProcessorSnapshot(selectedId, processorIdx);
|
||||
|
||||
const rows: Row[] = useMemo(() =>
|
||||
(searchResult?.data || []).map((e: ExecutionSummary) => ({ ...e, id: e.executionId })),
|
||||
[searchResult],
|
||||
);
|
||||
|
||||
const sparklineData = useMemo(() =>
|
||||
(timeseries?.buckets || []).map((b: any) => b.totalCount as number),
|
||||
[timeseries],
|
||||
);
|
||||
const { data: diagram } = useDiagramLayout(detail?.diagramContentHash ?? null);
|
||||
|
||||
const totalCount = stats?.totalCount ?? 0;
|
||||
const failedCount = stats?.failedCount ?? 0;
|
||||
const successRate = totalCount > 0 ? ((totalCount - failedCount) / totalCount * 100) : 100;
|
||||
const throughput = timeWindowSeconds > 0 ? totalCount / timeWindowSeconds : 0;
|
||||
|
||||
const sparkExchanges = useMemo(() =>
|
||||
(timeseries?.buckets || []).map((b: any) => b.totalCount as number), [timeseries]);
|
||||
const sparkErrors = useMemo(() =>
|
||||
(timeseries?.buckets || []).map((b: any) => b.failedCount as number), [timeseries]);
|
||||
const sparkLatency = useMemo(() =>
|
||||
(timeseries?.buckets || []).map((b: any) => b.p99DurationMs as number), [timeseries]);
|
||||
const sparkThroughput = useMemo(() =>
|
||||
(timeseries?.buckets || []).map((b: any) => {
|
||||
const bucketSeconds = timeWindowSeconds / Math.max((timeseries?.buckets || []).length, 1);
|
||||
return bucketSeconds > 0 ? (b.totalCount as number) / bucketSeconds : 0;
|
||||
}), [timeseries, timeWindowSeconds]);
|
||||
|
||||
const prevTotal = stats?.prevTotalCount ?? 0;
|
||||
const prevFailed = stats?.prevFailedCount ?? 0;
|
||||
const exchangeTrend = prevTotal > 0 ? ((totalCount - prevTotal) / prevTotal * 100) : 0;
|
||||
const prevSuccessRate = prevTotal > 0 ? ((prevTotal - prevFailed) / prevTotal * 100) : 100;
|
||||
const successRateDelta = successRate - prevSuccessRate;
|
||||
const errorDelta = failedCount - prevFailed;
|
||||
|
||||
const columns: Column<Row>[] = [
|
||||
{
|
||||
key: 'status', header: 'Status', width: '80px',
|
||||
render: (v) => <StatusDot variant={v === 'COMPLETED' ? 'success' : v === 'FAILED' ? 'error' : 'running'} />,
|
||||
render: (v, row) => (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<StatusDot variant={v === 'COMPLETED' ? 'success' : v === 'FAILED' ? 'error' : 'running'} />
|
||||
<MonoText size="xs">{v === 'COMPLETED' ? 'OK' : v === 'FAILED' ? 'ERR' : 'RUN'}</MonoText>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{ key: 'routeId', header: 'Route', render: (v) => <MonoText size="sm">{String(v)}</MonoText> },
|
||||
{ key: 'groupName', header: 'App', render: (v) => <Badge label={String(v)} color="auto" /> },
|
||||
{ key: 'executionId', header: 'Exchange ID', render: (v) => <MonoText size="xs">{String(v).slice(0, 12)}</MonoText> },
|
||||
{ key: 'startTime', header: 'Started', sortable: true, render: (v) => new Date(v as string).toLocaleTimeString() },
|
||||
{
|
||||
key: '_inspect' as any, header: '', width: '36px',
|
||||
render: (_v, row) => (
|
||||
<a
|
||||
href={`/exchanges/${row.executionId}`}
|
||||
onClick={(e) => { e.stopPropagation(); e.preventDefault(); navigate(`/exchanges/${row.executionId}`); }}
|
||||
className={styles.inspectLink}
|
||||
title="Open full details"
|
||||
>↗</a>
|
||||
),
|
||||
},
|
||||
{ key: 'routeId', header: 'Route', sortable: true, render: (v) => <span>{String(v)}</span> },
|
||||
{ key: 'applicationName', header: 'Application', sortable: true, render: (v) => <span>{String(v ?? '')}</span> },
|
||||
{ key: 'executionId', header: 'Exchange ID', sortable: true, render: (v) => <MonoText size="xs">{String(v)}</MonoText> },
|
||||
{ key: 'startTime', header: 'Started', sortable: true, render: (v) => <MonoText size="xs">{new Date(v as string).toLocaleString()}</MonoText> },
|
||||
{
|
||||
key: 'durationMs', header: 'Duration', sortable: true,
|
||||
render: (v) => `${v}ms`,
|
||||
render: (v) => <MonoText size="sm">{formatDuration(v as number)}</MonoText>,
|
||||
},
|
||||
{
|
||||
key: 'agentId', header: 'Agent',
|
||||
render: (v) => v ? <Badge label={String(v)} color="auto" /> : null,
|
||||
},
|
||||
];
|
||||
|
||||
const detailTabs = detail ? [
|
||||
{
|
||||
label: 'Overview', value: 'overview',
|
||||
content: (
|
||||
<>
|
||||
const procList = detail ? (detail.processors?.length ? detail.processors : (detail.children ?? [])) : [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.healthStrip}>
|
||||
<StatCard
|
||||
label="Exchanges"
|
||||
value={totalCount.toLocaleString()}
|
||||
detail={`${successRate.toFixed(1)}% success rate`}
|
||||
trend={exchangeTrend > 0 ? 'up' : exchangeTrend < 0 ? 'down' : 'neutral'}
|
||||
trendValue={exchangeTrend > 0 ? `+${exchangeTrend.toFixed(0)}%` : `${exchangeTrend.toFixed(0)}%`}
|
||||
sparkline={sparkExchanges}
|
||||
accent="amber"
|
||||
/>
|
||||
<StatCard
|
||||
label="Success Rate"
|
||||
value={`${successRate.toFixed(1)}%`}
|
||||
detail={`${(totalCount - failedCount).toLocaleString()} ok / ${failedCount} error`}
|
||||
trend={successRateDelta >= 0 ? 'up' : 'down'}
|
||||
trendValue={`${successRateDelta >= 0 ? '+' : ''}${successRateDelta.toFixed(1)}%`}
|
||||
accent="success"
|
||||
/>
|
||||
<StatCard
|
||||
label="Errors"
|
||||
value={failedCount}
|
||||
detail={`${failedCount} errors in selected period`}
|
||||
trend={errorDelta > 0 ? 'up' : errorDelta < 0 ? 'down' : 'neutral'}
|
||||
trendValue={errorDelta > 0 ? `+${errorDelta}` : `${errorDelta}`}
|
||||
sparkline={sparkErrors}
|
||||
accent="error"
|
||||
/>
|
||||
<StatCard
|
||||
label="Throughput"
|
||||
value={throughput.toFixed(1)}
|
||||
detail={`${throughput.toFixed(1)} msg/s`}
|
||||
sparkline={sparkThroughput}
|
||||
accent="running"
|
||||
/>
|
||||
<StatCard
|
||||
label="Latency p99"
|
||||
value={(stats?.p99LatencyMs ?? 0).toLocaleString()}
|
||||
detail={`${(stats?.p99LatencyMs ?? 0).toLocaleString()}ms`}
|
||||
sparkline={sparkLatency}
|
||||
accent="warning"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.tableSection}>
|
||||
<div className={styles.tableHeader}>
|
||||
<span className={styles.tableTitle}>Recent Exchanges</span>
|
||||
<div className={styles.tableRight}>
|
||||
<span className={styles.tableMeta}>{rows.length} of {searchResult?.total ?? 0} exchanges</span>
|
||||
<Badge label="LIVE" color="success" />
|
||||
</div>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
onRowClick={(row) => { setSelectedId(row.id); }}
|
||||
selectedId={selectedId ?? undefined}
|
||||
sortable
|
||||
pageSize={25}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedId && detail && (
|
||||
<DetailPanel
|
||||
key={selectedId}
|
||||
open={true}
|
||||
onClose={() => setSelectedId(null)}
|
||||
title={`${detail.routeId} — ${selectedId.slice(0, 12)}`}
|
||||
className={styles.detailPanelOverride}
|
||||
>
|
||||
{/* Open full details link */}
|
||||
<div className={styles.panelSection}>
|
||||
<div className={styles.panelSectionTitle}>Details</div>
|
||||
<button
|
||||
className={styles.openDetailLink}
|
||||
onClick={() => navigate(`/exchanges/${detail.executionId}`)}
|
||||
>
|
||||
Open full details →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Overview */}
|
||||
<div className={styles.panelSection}>
|
||||
<div className={styles.panelSectionTitle}>Overview</div>
|
||||
<div className={styles.overviewGrid}>
|
||||
<div className={styles.overviewRow}>
|
||||
<span className={styles.overviewLabel}>Exchange ID</span>
|
||||
<MonoText size="sm">{detail.executionId}</MonoText>
|
||||
<span className={styles.overviewLabel}>Status</span>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<StatusDot variant={detail.status === 'COMPLETED' ? 'success' : 'error'} />
|
||||
<span>{detail.status}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.overviewRow}>
|
||||
<span className={styles.overviewLabel}>Status</span>
|
||||
<Badge label={detail.status} color={detail.status === 'COMPLETED' ? 'success' : 'error'} />
|
||||
<span className={styles.overviewLabel}>Duration</span>
|
||||
<MonoText size="sm">{formatDuration(detail.durationMs)}</MonoText>
|
||||
</div>
|
||||
<div className={styles.overviewRow}>
|
||||
<span className={styles.overviewLabel}>Route</span>
|
||||
<span>{detail.routeId}</span>
|
||||
</div>
|
||||
<div className={styles.overviewRow}>
|
||||
<span className={styles.overviewLabel}>Duration</span>
|
||||
<span>{detail.durationMs}ms</span>
|
||||
<span className={styles.overviewLabel}>Agent</span>
|
||||
<MonoText size="sm">{detail.agentId ?? '—'}</MonoText>
|
||||
</div>
|
||||
<div className={styles.overviewRow}>
|
||||
<span className={styles.overviewLabel}>Correlation</span>
|
||||
<MonoText size="xs">{detail.correlationId ?? '—'}</MonoText>
|
||||
</div>
|
||||
<div className={styles.overviewRow}>
|
||||
<span className={styles.overviewLabel}>Timestamp</span>
|
||||
<MonoText size="xs">{detail.startTime ? new Date(detail.startTime).toLocaleString() : '—'}</MonoText>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Errors */}
|
||||
{detail.errorMessage && (
|
||||
<div className={styles.panelSection}>
|
||||
<div className={styles.panelSectionTitle}>Errors</div>
|
||||
@@ -101,77 +239,33 @@ export default function Dashboard() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Processors', value: 'processors',
|
||||
content: (() => {
|
||||
const procList = detail.processors?.length ? detail.processors : (detail.children ?? []);
|
||||
return procList.length ? (
|
||||
<ProcessorTimeline
|
||||
processors={flattenProcessors(procList)}
|
||||
totalMs={detail.durationMs}
|
||||
onProcessorClick={(_p, i) => setProcessorIdx(i)}
|
||||
selectedIndex={processorIdx ?? undefined}
|
||||
/>
|
||||
) : <div style={{ padding: '1rem' }}>No processor data</div>;
|
||||
})(),
|
||||
},
|
||||
] : [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.healthStrip}>
|
||||
<StatCard
|
||||
label="Throughput"
|
||||
value={timeWindowSeconds > 0 ? `${((stats?.totalCount ?? 0) / timeWindowSeconds).toFixed(2)} ex/s` : '0.00 ex/s'}
|
||||
sparkline={sparklineData}
|
||||
/>
|
||||
<StatCard
|
||||
label="Error Rate"
|
||||
value={(stats?.totalCount ?? 0) > 0 ? `${((stats?.failedCount ?? 0) / stats!.totalCount * 100).toFixed(1)}%` : '0.0%'}
|
||||
accent="error"
|
||||
/>
|
||||
<StatCard
|
||||
label="Avg Latency"
|
||||
value={`${stats?.avgDurationMs ?? 0}ms`}
|
||||
/>
|
||||
<StatCard
|
||||
label="P99 Latency"
|
||||
value={`${stats?.p99LatencyMs ?? 0}ms`}
|
||||
accent="warning"
|
||||
/>
|
||||
<StatCard
|
||||
label="In-Flight"
|
||||
value={stats?.activeCount ?? 0}
|
||||
accent="running"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.tableSection}>
|
||||
<div className={styles.tableHeader}>
|
||||
<span className={styles.tableTitle}>Recent Exchanges</span>
|
||||
<div className={styles.tableRight}>
|
||||
<span className={styles.tableMeta}>{rows.length} results</span>
|
||||
{/* Route Flow */}
|
||||
<div className={styles.panelSection}>
|
||||
<div className={styles.panelSectionTitle}>Route Flow</div>
|
||||
{diagram ? (
|
||||
<RouteFlow
|
||||
nodes={mapDiagramToRouteNodes(diagram.nodes || [], procList)}
|
||||
onNodeClick={(_node, _i) => {}}
|
||||
/>
|
||||
) : <div style={{ color: 'var(--text-muted)', fontSize: 12 }}>No diagram available</div>}
|
||||
</div>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
onRowClick={(row) => { setSelectedId(row.id); setProcessorIdx(null); }}
|
||||
selectedId={selectedId ?? undefined}
|
||||
sortable
|
||||
pageSize={25}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DetailPanel
|
||||
open={!!selectedId}
|
||||
onClose={() => setSelectedId(null)}
|
||||
title={selectedId ? `Exchange ${selectedId.slice(0, 12)}...` : ''}
|
||||
tabs={detailTabs}
|
||||
/>
|
||||
{/* Processor Timeline */}
|
||||
<div className={styles.panelSection}>
|
||||
<div className={styles.panelSectionTitle}>
|
||||
Processor Timeline
|
||||
<span className={styles.panelSectionMeta}>{formatDuration(detail.durationMs)}</span>
|
||||
</div>
|
||||
{procList.length ? (
|
||||
<ProcessorTimeline
|
||||
processors={flattenProcessors(procList)}
|
||||
totalMs={detail.durationMs}
|
||||
/>
|
||||
) : <div style={{ color: 'var(--text-muted)', fontSize: 12 }}>No processor data</div>}
|
||||
</div>
|
||||
</DetailPanel>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,37 @@
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.exchangeId {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.exchangeRoute {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.routeLink {
|
||||
color: var(--accent, #c6820e);
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.routeLink:hover {
|
||||
color: var(--amber-deep, #a36b0b);
|
||||
}
|
||||
|
||||
.headerDivider {
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.headerRight {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
@@ -47,6 +78,62 @@
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Correlation Chain */
|
||||
.correlationChain {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-top: 12px;
|
||||
margin-top: 12px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.chainLabel {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-muted);
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.chainNode {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
border: 1px solid var(--border-subtle);
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
cursor: pointer;
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.12s;
|
||||
}
|
||||
|
||||
.chainNode:hover {
|
||||
border-color: var(--text-faint);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.chainNodeCurrent {
|
||||
background: var(--amber-bg, rgba(198, 130, 14, 0.08));
|
||||
border-color: var(--accent, #c6820e);
|
||||
color: var(--accent, #c6820e);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chainNodeSuccess { border-left: 3px solid var(--success); }
|
||||
.chainNodeError { border-left: 3px solid var(--error); }
|
||||
.chainNodeRunning { border-left: 3px solid var(--running); }
|
||||
.chainNodeWarning { border-left: 3px solid var(--warning); }
|
||||
|
||||
.chainMore { color: var(--text-muted); font-size: 11px; font-style: italic; }
|
||||
|
||||
/* Timeline Section */
|
||||
.timelineSection {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
@@ -68,12 +155,59 @@
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.procCount {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
padding: 1px 8px;
|
||||
border-radius: 10px;
|
||||
background: var(--bg-inset);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.timelineToggle {
|
||||
display: inline-flex;
|
||||
gap: 0;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.toggleBtn {
|
||||
padding: 4px 12px;
|
||||
font-size: 11px;
|
||||
font-family: var(--font-body);
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
transition: all 0.12s;
|
||||
}
|
||||
|
||||
.toggleBtn:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.toggleBtnActive {
|
||||
background: var(--accent, #c6820e);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.toggleBtnActive:hover {
|
||||
background: var(--amber-deep, #a36b0b);
|
||||
}
|
||||
|
||||
.timelineBody {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
/* Detail Split (IN / OUT panels) */
|
||||
.detailSplit {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
@@ -89,6 +223,10 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.detailPanelError {
|
||||
border-color: var(--error-border, rgba(220, 38, 38, 0.3));
|
||||
}
|
||||
|
||||
.panelHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -96,18 +234,66 @@
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
background: var(--bg-raised);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.detailPanelError .panelHeader {
|
||||
background: var(--error-bg, rgba(220, 38, 38, 0.06));
|
||||
border-bottom-color: var(--error-border, rgba(220, 38, 38, 0.3));
|
||||
}
|
||||
|
||||
.panelTitle {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.arrowIn {
|
||||
color: var(--success);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.arrowOut {
|
||||
color: var(--running);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.arrowError {
|
||||
color: var(--error);
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.panelTag {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 8px;
|
||||
background: var(--bg-inset);
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.panelBody {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* Headers section */
|
||||
.headersSection {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.headerList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.headerKvRow {
|
||||
display: grid;
|
||||
grid-template-columns: 140px 1fr;
|
||||
@@ -124,6 +310,9 @@
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.headerValue {
|
||||
@@ -131,6 +320,12 @@
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Body section */
|
||||
.bodySection {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.sectionLabel {
|
||||
@@ -140,44 +335,50 @@
|
||||
letter-spacing: 0.6px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.correlationChain { margin-bottom: 16px; }
|
||||
|
||||
.chainRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
padding: 12px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.chainArrow { color: var(--text-muted); font-size: 16px; flex-shrink: 0; }
|
||||
|
||||
.chainCard {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
text-decoration: none;
|
||||
color: var(--text-primary);
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chainCard:hover { background: var(--bg-hover); }
|
||||
.count {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
padding: 0 5px;
|
||||
border-radius: 8px;
|
||||
background: var(--bg-inset);
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.chainCardActive { border-color: var(--accent); background: var(--bg-hover); }
|
||||
/* Error panel styles */
|
||||
.errorMessageBox {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
background: var(--error-bg, rgba(220, 38, 38, 0.06));
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
border: 1px solid var(--error-border, rgba(220, 38, 38, 0.3));
|
||||
margin-bottom: 12px;
|
||||
line-height: 1.5;
|
||||
word-break: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.chainRoute { font-weight: 600; }
|
||||
.errorDetailGrid {
|
||||
display: grid;
|
||||
grid-template-columns: 120px 1fr;
|
||||
gap: 4px 12px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.chainDuration { color: var(--text-muted); font-family: var(--font-mono); font-size: 11px; }
|
||||
.errorDetailLabel {
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.chainMore { color: var(--text-muted); font-size: 11px; font-style: italic; }
|
||||
.errorDetailValue {
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
@@ -2,11 +2,11 @@ import React, { useState, useMemo } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router';
|
||||
import {
|
||||
Badge, StatusDot, MonoText, CodeBlock, InfoCallout,
|
||||
ProcessorTimeline, Breadcrumb, Spinner, SegmentedTabs, RouteFlow,
|
||||
ProcessorTimeline, Breadcrumb, Spinner, RouteFlow,
|
||||
} from '@cameleer/design-system';
|
||||
import { useExecutionDetail, useProcessorSnapshot } from '../../api/queries/executions';
|
||||
import { useCorrelationChain } from '../../api/queries/correlation';
|
||||
import { useDiagramByRoute } from '../../api/queries/diagrams';
|
||||
import { useDiagramLayout } from '../../api/queries/diagrams';
|
||||
import { mapDiagramToRouteNodes } from '../../utils/diagram-mapping';
|
||||
import styles from './ExchangeDetail.module.css';
|
||||
|
||||
@@ -14,18 +14,53 @@ function countProcessors(nodes: any[]): number {
|
||||
return nodes.reduce((sum, n) => sum + 1 + countProcessors(n.children || []), 0);
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms >= 60_000) return `${(ms / 1000).toFixed(0)}s`;
|
||||
if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`;
|
||||
return `${ms}ms`;
|
||||
}
|
||||
|
||||
function parseHeaders(raw: string | undefined | null): Record<string, string> {
|
||||
if (!raw) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (typeof parsed === 'object' && parsed !== null) {
|
||||
const result: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(parsed)) {
|
||||
result[k] = typeof v === 'string' ? v : JSON.stringify(v);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return {};
|
||||
}
|
||||
|
||||
export default function ExchangeDetail() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { data: detail, isLoading } = useExecutionDetail(id ?? null);
|
||||
const [selectedProcessor, setSelectedProcessor] = useState<number | null>(null);
|
||||
const [viewMode, setViewMode] = useState<'timeline' | 'flow'>('timeline');
|
||||
const { data: snapshot } = useProcessorSnapshot(id ?? null, selectedProcessor);
|
||||
const [timelineView, setTimelineView] = useState<'gantt' | 'flow'>('gantt');
|
||||
const { data: correlationData } = useCorrelationChain(detail?.correlationId ?? null);
|
||||
const { data: diagram } = useDiagramByRoute(detail?.groupName, detail?.routeId);
|
||||
const { data: diagram } = useDiagramLayout(detail?.diagramContentHash ?? null);
|
||||
|
||||
const procList = detail ? (detail.processors?.length ? detail.processors : (detail.children ?? [])) : [];
|
||||
|
||||
// Auto-select first failed processor, or 0
|
||||
const defaultIndex = useMemo(() => {
|
||||
if (!procList.length) return 0;
|
||||
const failIdx = procList.findIndex((p: any) =>
|
||||
(p.status || '').toUpperCase() === 'FAILED' || p.status === 'fail'
|
||||
);
|
||||
return failIdx >= 0 ? failIdx : 0;
|
||||
}, [procList]);
|
||||
|
||||
const [selectedProcessorIndex, setSelectedProcessorIndex] = useState<number | null>(null);
|
||||
const activeIndex = selectedProcessorIndex ?? defaultIndex;
|
||||
|
||||
const { data: snapshot } = useProcessorSnapshot(id ?? null, procList.length > 0 ? activeIndex : null);
|
||||
|
||||
const processors = useMemo(() => {
|
||||
if (!detail?.children) return [];
|
||||
if (!procList.length) return [];
|
||||
const result: any[] = [];
|
||||
let offset = 0;
|
||||
function walk(node: any) {
|
||||
@@ -39,9 +74,18 @@ export default function ExchangeDetail() {
|
||||
offset += node.durationMs ?? 0;
|
||||
if (node.children) node.children.forEach(walk);
|
||||
}
|
||||
detail.children.forEach(walk);
|
||||
procList.forEach(walk);
|
||||
return result;
|
||||
}, [detail]);
|
||||
}, [procList]);
|
||||
|
||||
const selectedProc = processors[activeIndex];
|
||||
const isSelectedFailed = selectedProc?.status === 'fail';
|
||||
|
||||
// Parse snapshot headers
|
||||
const inputHeaders = parseHeaders(snapshot?.inputHeaders);
|
||||
const outputHeaders = parseHeaders(snapshot?.outputHeaders);
|
||||
const inputBody = snapshot?.inputBody ?? null;
|
||||
const outputBody = snapshot?.outputBody ?? null;
|
||||
|
||||
if (isLoading) return <div style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}><Spinner size="lg" /></div>;
|
||||
if (!detail) return <InfoCallout variant="warning">Exchange not found</InfoCallout>;
|
||||
@@ -50,93 +94,120 @@ export default function ExchangeDetail() {
|
||||
<div>
|
||||
<Breadcrumb items={[
|
||||
{ label: 'Dashboard', href: '/apps' },
|
||||
{ label: detail.groupName || 'App', href: `/apps/${detail.groupName}` },
|
||||
{ label: detail.applicationName || 'App', href: `/apps/${detail.applicationName}` },
|
||||
{ label: id?.slice(0, 12) || '' },
|
||||
]} />
|
||||
|
||||
{/* Exchange header card */}
|
||||
<div className={styles.exchangeHeader}>
|
||||
<div className={styles.headerRow}>
|
||||
<div className={styles.headerLeft}>
|
||||
<StatusDot variant={detail.status === 'COMPLETED' ? 'success' : detail.status === 'FAILED' ? 'error' : 'running'} />
|
||||
<div>
|
||||
<Badge label={detail.status} color={detail.status === 'COMPLETED' ? 'success' : 'error'} />
|
||||
<MonoText>{id}</MonoText>
|
||||
<div className={styles.exchangeId}>
|
||||
<MonoText size="md">{id}</MonoText>
|
||||
<Badge label={detail.status} color={detail.status === 'COMPLETED' ? 'success' : 'error'} variant="filled" />
|
||||
</div>
|
||||
<div className={styles.exchangeRoute}>
|
||||
Route: <span className={styles.routeLink} onClick={() => navigate(`/apps/${detail.applicationName}/${detail.routeId}`)}>{detail.routeId}</span>
|
||||
{detail.applicationName && (
|
||||
<>
|
||||
<span className={styles.headerDivider}>·</span>
|
||||
App: <MonoText size="xs">{detail.applicationName}</MonoText>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.headerRight}>
|
||||
<div className={styles.headerStat}>
|
||||
<div className={styles.headerStatLabel}>Duration</div>
|
||||
<div className={styles.headerStatValue}>{detail.durationMs}ms</div>
|
||||
<div className={styles.headerStatValue}>{formatDuration(detail.durationMs)}</div>
|
||||
</div>
|
||||
<div className={styles.headerStat}>
|
||||
<div className={styles.headerStatLabel}>Agent</div>
|
||||
<div className={styles.headerStatValue}>{detail.agentId}</div>
|
||||
</div>
|
||||
<div className={styles.headerStat}>
|
||||
<div className={styles.headerStatLabel}>Started</div>
|
||||
<div className={styles.headerStatValue}>
|
||||
{detail.startTime ? new Date(detail.startTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }) : '—'}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.headerStat}>
|
||||
<div className={styles.headerStatLabel}>Processors</div>
|
||||
<div className={styles.headerStatValue}>{countProcessors(detail.processors || detail.children || [])}</div>
|
||||
</div>
|
||||
<div className={styles.headerStat}>
|
||||
<div className={styles.headerStatLabel}>Route</div>
|
||||
<div className={styles.headerStatValue}>{detail.routeId}</div>
|
||||
</div>
|
||||
<div className={styles.headerStat}>
|
||||
<div className={styles.headerStatLabel}>Application</div>
|
||||
<div className={styles.headerStatValue}>{detail.groupName || 'unknown'}</div>
|
||||
<div className={styles.headerStatValue}>{countProcessors(procList)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{correlationData?.data && correlationData.data.length > 1 && (
|
||||
<div className={styles.correlationChain}>
|
||||
<div className={styles.panelHeader}>
|
||||
<span className={styles.panelTitle}>Correlation Chain</span>
|
||||
</div>
|
||||
<div className={styles.chainRow}>
|
||||
{correlationData.data.map((exec, i) => (
|
||||
<React.Fragment key={exec.executionId}>
|
||||
{i > 0 && <span className={styles.chainArrow}>→</span>}
|
||||
<a
|
||||
href={`/exchanges/${exec.executionId}`}
|
||||
className={`${styles.chainCard} ${exec.executionId === id ? styles.chainCardActive : ''}`}
|
||||
onClick={(e) => { e.preventDefault(); navigate(`/exchanges/${exec.executionId}`); }}
|
||||
{/* Correlation Chain */}
|
||||
{correlationData?.data && correlationData.data.length > 1 && (
|
||||
<div className={styles.correlationChain}>
|
||||
<span className={styles.chainLabel}>Correlated Exchanges</span>
|
||||
{correlationData.data.map((exec: any) => {
|
||||
const isCurrent = exec.executionId === id;
|
||||
const variant = exec.status === 'COMPLETED' ? 'success' : exec.status === 'FAILED' ? 'error' : 'running';
|
||||
const statusCls =
|
||||
variant === 'success' ? styles.chainNodeSuccess
|
||||
: variant === 'error' ? styles.chainNodeError
|
||||
: styles.chainNodeRunning;
|
||||
return (
|
||||
<button
|
||||
key={exec.executionId}
|
||||
className={`${styles.chainNode} ${statusCls} ${isCurrent ? styles.chainNodeCurrent : ''}`}
|
||||
onClick={() => { if (!isCurrent) navigate(`/exchanges/${exec.executionId}`); }}
|
||||
title={`${exec.executionId} — ${exec.routeId}`}
|
||||
>
|
||||
<StatusDot variant={exec.status === 'COMPLETED' ? 'success' : exec.status === 'FAILED' ? 'error' : 'warning'} />
|
||||
<span className={styles.chainRoute}>{exec.routeId}</span>
|
||||
<span className={styles.chainDuration}>{exec.durationMs}ms</span>
|
||||
</a>
|
||||
</React.Fragment>
|
||||
))}
|
||||
<StatusDot variant={variant as any} />
|
||||
<span>{exec.routeId}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{correlationData.total > 20 && (
|
||||
<span className={styles.chainMore}>+{correlationData.total - 20} more</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error callout */}
|
||||
{detail.errorMessage && (
|
||||
<InfoCallout variant="error">
|
||||
{detail.errorMessage}
|
||||
</InfoCallout>
|
||||
)}
|
||||
|
||||
{/* Processor Timeline / Flow Section */}
|
||||
<div className={styles.timelineSection}>
|
||||
<div className={styles.timelineHeader}>
|
||||
<span className={styles.timelineTitle}>Processors</span>
|
||||
<SegmentedTabs
|
||||
tabs={[
|
||||
{ label: 'Timeline', value: 'timeline' },
|
||||
{ label: 'Flow', value: 'flow' },
|
||||
]}
|
||||
active={viewMode}
|
||||
onChange={(v) => setViewMode(v as 'timeline' | 'flow')}
|
||||
/>
|
||||
<span className={styles.timelineTitle}>
|
||||
Processor Timeline
|
||||
<span className={styles.procCount}>{processors.length} processors</span>
|
||||
</span>
|
||||
<div className={styles.timelineToggle}>
|
||||
<button
|
||||
className={`${styles.toggleBtn} ${timelineView === 'gantt' ? styles.toggleBtnActive : ''}`}
|
||||
onClick={() => setTimelineView('gantt')}
|
||||
>
|
||||
Timeline
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.toggleBtn} ${timelineView === 'flow' ? styles.toggleBtnActive : ''}`}
|
||||
onClick={() => setTimelineView('flow')}
|
||||
>
|
||||
Flow
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.timelineBody}>
|
||||
{viewMode === 'timeline' ? (
|
||||
{timelineView === 'gantt' ? (
|
||||
processors.length > 0 ? (
|
||||
<ProcessorTimeline
|
||||
processors={processors}
|
||||
totalMs={detail.durationMs}
|
||||
onProcessorClick={(_p, i) => setSelectedProcessor(i)}
|
||||
selectedIndex={selectedProcessor ?? undefined}
|
||||
onProcessorClick={(_p, i) => setSelectedProcessorIndex(i)}
|
||||
selectedIndex={activeIndex}
|
||||
/>
|
||||
) : (
|
||||
<InfoCallout>No processor data available</InfoCallout>
|
||||
@@ -144,9 +215,9 @@ export default function ExchangeDetail() {
|
||||
) : (
|
||||
diagram ? (
|
||||
<RouteFlow
|
||||
nodes={mapDiagramToRouteNodes(diagram.nodes || [], detail.processors || detail.children || [])}
|
||||
onNodeClick={(_node, i) => setSelectedProcessor(i)}
|
||||
selectedIndex={selectedProcessor ?? undefined}
|
||||
nodes={mapDiagramToRouteNodes(diagram.nodes || [], procList)}
|
||||
onNodeClick={(_node, i) => setSelectedProcessorIndex(i)}
|
||||
selectedIndex={activeIndex}
|
||||
/>
|
||||
) : (
|
||||
<Spinner />
|
||||
@@ -155,46 +226,102 @@ export default function ExchangeDetail() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{snapshot && (
|
||||
<>
|
||||
<div className={styles.sectionLabel}>Exchange Snapshot</div>
|
||||
<div className={styles.detailSplit}>
|
||||
<div className={styles.detailPanel}>
|
||||
<div className={styles.panelHeader}>
|
||||
<span className={styles.panelTitle}>Input Body</span>
|
||||
</div>
|
||||
<div className={styles.panelBody}>
|
||||
<CodeBlock content={String(snapshot.inputBody ?? 'null')} />
|
||||
</div>
|
||||
{/* Processor Detail: Message IN / Message OUT or Error */}
|
||||
{selectedProc && snapshot && (
|
||||
<div className={styles.detailSplit}>
|
||||
{/* Message IN */}
|
||||
<div className={styles.detailPanel}>
|
||||
<div className={styles.panelHeader}>
|
||||
<span className={styles.panelTitle}>
|
||||
<span className={styles.arrowIn}>→</span> Message IN
|
||||
</span>
|
||||
<span className={styles.panelTag}>at processor #{activeIndex + 1} entry</span>
|
||||
</div>
|
||||
<div className={styles.detailPanel}>
|
||||
<div className={styles.panelHeader}>
|
||||
<span className={styles.panelTitle}>Output Body</span>
|
||||
</div>
|
||||
<div className={styles.panelBody}>
|
||||
<CodeBlock content={String(snapshot.outputBody ?? 'null')} />
|
||||
<div className={styles.panelBody}>
|
||||
{Object.keys(inputHeaders).length > 0 && (
|
||||
<div className={styles.headersSection}>
|
||||
<div className={styles.sectionLabel}>
|
||||
Headers <span className={styles.count}>{Object.keys(inputHeaders).length}</span>
|
||||
</div>
|
||||
<div className={styles.headerList}>
|
||||
{Object.entries(inputHeaders).map(([key, value]) => (
|
||||
<div key={key} className={styles.headerKvRow}>
|
||||
<span className={styles.headerKey}>{key}</span>
|
||||
<span className={styles.headerValue}>{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.bodySection}>
|
||||
<div className={styles.sectionLabel}>Body</div>
|
||||
<CodeBlock content={inputBody ?? 'null'} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.detailSplit}>
|
||||
<div className={styles.detailPanel}>
|
||||
|
||||
{/* Message OUT or Error */}
|
||||
{isSelectedFailed ? (
|
||||
<div className={`${styles.detailPanel} ${styles.detailPanelError}`}>
|
||||
<div className={styles.panelHeader}>
|
||||
<span className={styles.panelTitle}>Input Headers</span>
|
||||
<span className={styles.panelTitle}>
|
||||
<span className={styles.arrowError}>×</span> Error at Processor #{activeIndex + 1}
|
||||
</span>
|
||||
<Badge label="FAILED" color="error" variant="filled" />
|
||||
</div>
|
||||
<div className={styles.panelBody}>
|
||||
<CodeBlock content={JSON.stringify(snapshot.inputHeaders ?? {}, null, 2)} />
|
||||
{detail.errorMessage && (
|
||||
<div className={styles.errorMessageBox}>{detail.errorMessage}</div>
|
||||
)}
|
||||
<div className={styles.errorDetailGrid}>
|
||||
<span className={styles.errorDetailLabel}>Processor</span>
|
||||
<span className={styles.errorDetailValue}>{selectedProc.name}</span>
|
||||
<span className={styles.errorDetailLabel}>Duration</span>
|
||||
<span className={styles.errorDetailValue}>{formatDuration(selectedProc.durationMs)}</span>
|
||||
<span className={styles.errorDetailLabel}>Status</span>
|
||||
<span className={styles.errorDetailValue}>{selectedProc.status.toUpperCase()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.detailPanel}>
|
||||
<div className={styles.panelHeader}>
|
||||
<span className={styles.panelTitle}>Output Headers</span>
|
||||
<span className={styles.panelTitle}>
|
||||
<span className={styles.arrowOut}>←</span> Message OUT
|
||||
</span>
|
||||
<span className={styles.panelTag}>after processor #{activeIndex + 1}</span>
|
||||
</div>
|
||||
<div className={styles.panelBody}>
|
||||
<CodeBlock content={JSON.stringify(snapshot.outputHeaders ?? {}, null, 2)} />
|
||||
{Object.keys(outputHeaders).length > 0 && (
|
||||
<div className={styles.headersSection}>
|
||||
<div className={styles.sectionLabel}>
|
||||
Headers <span className={styles.count}>{Object.keys(outputHeaders).length}</span>
|
||||
</div>
|
||||
<div className={styles.headerList}>
|
||||
{Object.entries(outputHeaders).map(([key, value]) => (
|
||||
<div key={key} className={styles.headerKvRow}>
|
||||
<span className={styles.headerKey}>{key}</span>
|
||||
<span className={styles.headerValue}>{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.bodySection}>
|
||||
<div className={styles.sectionLabel}>Body</div>
|
||||
<CodeBlock content={outputBody ?? 'null'} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No snapshot loaded yet - show prompt */}
|
||||
{selectedProc && !snapshot && procList.length > 0 && (
|
||||
<div style={{ color: 'var(--text-muted)', fontSize: 12, textAlign: 'center', padding: 20 }}>
|
||||
Loading exchange snapshot...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -51,7 +51,7 @@ export default function RouteDetail() {
|
||||
timeFrom,
|
||||
timeTo,
|
||||
routeId: routeId || undefined,
|
||||
group: appId || undefined,
|
||||
application: appId || undefined,
|
||||
offset: 0,
|
||||
limit: 50,
|
||||
});
|
||||
@@ -59,7 +59,7 @@ export default function RouteDetail() {
|
||||
timeFrom,
|
||||
timeTo,
|
||||
routeId: routeId || undefined,
|
||||
group: appId || undefined,
|
||||
application: appId || undefined,
|
||||
status: 'FAILED',
|
||||
offset: 0,
|
||||
limit: 200,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
.statStrip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
@@ -47,13 +47,19 @@ export default function RoutesMetrics() {
|
||||
);
|
||||
|
||||
const chartData = useMemo(() =>
|
||||
(timeseries?.buckets || []).map((b: any) => ({
|
||||
time: new Date(b.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
|
||||
throughput: b.totalCount,
|
||||
latency: b.avgDurationMs,
|
||||
errors: b.failedCount,
|
||||
successRate: b.totalCount > 0 ? ((b.totalCount - b.failedCount) / b.totalCount) * 100 : 100,
|
||||
})),
|
||||
(timeseries?.buckets || []).map((b: any, i: number) => {
|
||||
const ts = b.timestamp ? new Date(b.timestamp) : null;
|
||||
const time = ts && !isNaN(ts.getTime())
|
||||
? ts.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
: String(i);
|
||||
return {
|
||||
time,
|
||||
throughput: b.totalCount ?? 0,
|
||||
latency: b.avgDurationMs ?? 0,
|
||||
errors: b.failedCount ?? 0,
|
||||
successRate: b.totalCount > 0 ? ((b.totalCount - b.failedCount) / b.totalCount) * 100 : 100,
|
||||
};
|
||||
}),
|
||||
[timeseries],
|
||||
);
|
||||
|
||||
@@ -81,18 +87,89 @@ export default function RoutesMetrics() {
|
||||
},
|
||||
];
|
||||
|
||||
const errorRate = stats?.totalCount
|
||||
? (((stats.failedCount ?? 0) / stats.totalCount) * 100)
|
||||
: 0;
|
||||
const prevErrorRate = stats?.prevTotalCount
|
||||
? (((stats.prevFailedCount ?? 0) / stats.prevTotalCount) * 100)
|
||||
: 0;
|
||||
const errorTrend: 'up' | 'down' | 'neutral' = errorRate > prevErrorRate ? 'up' : errorRate < prevErrorRate ? 'down' : 'neutral';
|
||||
const errorTrendValue = stats?.prevTotalCount
|
||||
? `${Math.abs(errorRate - prevErrorRate).toFixed(2)}%`
|
||||
: undefined;
|
||||
|
||||
const p99Ms = stats?.p99LatencyMs ?? 0;
|
||||
const prevP99Ms = stats?.prevP99LatencyMs ?? 0;
|
||||
const latencyTrend: 'up' | 'down' | 'neutral' = p99Ms > prevP99Ms ? 'up' : p99Ms < prevP99Ms ? 'down' : 'neutral';
|
||||
const latencyTrendValue = prevP99Ms ? `${Math.abs(p99Ms - prevP99Ms)}ms` : undefined;
|
||||
|
||||
const totalCount = stats?.totalCount ?? 0;
|
||||
const prevTotalCount = stats?.prevTotalCount ?? 0;
|
||||
const throughputTrend: 'up' | 'down' | 'neutral' = totalCount > prevTotalCount ? 'up' : totalCount < prevTotalCount ? 'down' : 'neutral';
|
||||
const throughputTrendValue = prevTotalCount
|
||||
? `${Math.abs(((totalCount - prevTotalCount) / prevTotalCount) * 100).toFixed(0)}%`
|
||||
: undefined;
|
||||
|
||||
const successRate = stats?.totalCount
|
||||
? (((stats.totalCount - (stats.failedCount ?? 0)) / stats.totalCount) * 100)
|
||||
: 100;
|
||||
|
||||
const activeCount = stats?.activeCount ?? 0;
|
||||
|
||||
const errorSparkline = (timeseries?.buckets || []).map((b: any) => b.failedCount as number);
|
||||
const latencySparkline = (timeseries?.buckets || []).map((b: any) => b.p99DurationMs as number);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.statStrip}>
|
||||
<StatCard label="Total Throughput" value={stats?.totalCount ?? 0} sparkline={sparklineData} />
|
||||
<StatCard label="Error Rate" value={stats?.totalCount ? `${(((stats.failedCount ?? 0) / stats.totalCount) * 100).toFixed(1)}%` : '0%'} accent="error" />
|
||||
<StatCard label="P99 Latency" value={`${stats?.p99LatencyMs ?? 0}ms`} accent="warning" />
|
||||
<StatCard label="Success Rate" value={stats?.totalCount ? `${(((stats.totalCount - (stats.failedCount ?? 0)) / stats.totalCount) * 100).toFixed(1)}%` : '100%'} accent="success" />
|
||||
<StatCard
|
||||
label="Total Throughput"
|
||||
value={totalCount.toLocaleString()}
|
||||
detail="exchanges"
|
||||
trend={throughputTrend}
|
||||
trendValue={throughputTrendValue}
|
||||
accent="amber"
|
||||
sparkline={sparklineData}
|
||||
/>
|
||||
<StatCard
|
||||
label="System Error Rate"
|
||||
value={`${errorRate.toFixed(2)}%`}
|
||||
detail={`${stats?.failedCount ?? 0} errors / ${totalCount.toLocaleString()} total`}
|
||||
trend={errorTrend}
|
||||
trendValue={errorTrendValue}
|
||||
accent={errorRate < 1 ? 'success' : 'error'}
|
||||
sparkline={errorSparkline}
|
||||
/>
|
||||
<StatCard
|
||||
label="P99 Latency"
|
||||
value={`${p99Ms}ms`}
|
||||
detail={`Avg: ${stats?.avgDurationMs ?? 0}ms`}
|
||||
trend={latencyTrend}
|
||||
trendValue={latencyTrendValue}
|
||||
accent={p99Ms > 300 ? 'error' : p99Ms > 200 ? 'warning' : 'success'}
|
||||
sparkline={latencySparkline}
|
||||
/>
|
||||
<StatCard
|
||||
label="Success Rate"
|
||||
value={`${successRate.toFixed(1)}%`}
|
||||
detail={`${activeCount} active routes`}
|
||||
accent="success"
|
||||
sparkline={sparklineData.map((v, i) => {
|
||||
const failed = errorSparkline[i] ?? 0;
|
||||
return v > 0 ? ((v - failed) / v) * 100 : 100;
|
||||
})}
|
||||
/>
|
||||
<StatCard
|
||||
label="In-Flight"
|
||||
value={activeCount}
|
||||
detail="active exchanges"
|
||||
accent="amber"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.tableSection}>
|
||||
<div className={styles.tableHeader}>
|
||||
<span className={styles.tableTitle}>Route Metrics</span>
|
||||
<span className={styles.tableTitle}>Per-Route Performance</span>
|
||||
<span className={styles.tableMeta}>{rows.length} routes</span>
|
||||
</div>
|
||||
<DataTable
|
||||
@@ -106,20 +183,25 @@ export default function RoutesMetrics() {
|
||||
{chartData.length > 0 && (
|
||||
<div className={styles.chartGrid}>
|
||||
<div className={styles.chartCard}>
|
||||
<div className={styles.chartTitle}>Throughput</div>
|
||||
<AreaChart series={[{ label: 'Throughput', data: chartData.map((d: any, i: number) => ({ x: i, y: d.throughput })) }]} height={200} />
|
||||
<div className={styles.chartTitle}>Throughput (msg/s)</div>
|
||||
<AreaChart series={[{ label: 'Throughput', data: chartData.map((d: any, i: number) => ({ x: i, y: d.throughput })) }]} yLabel="msg/s" height={200} />
|
||||
</div>
|
||||
<div className={styles.chartCard}>
|
||||
<div className={styles.chartTitle}>Latency</div>
|
||||
<LineChart series={[{ label: 'Latency', data: chartData.map((d: any, i: number) => ({ x: i, y: d.latency })) }]} height={200} />
|
||||
<div className={styles.chartTitle}>Latency (ms)</div>
|
||||
<LineChart
|
||||
series={[{ label: 'Latency', data: chartData.map((d: any, i: number) => ({ x: i, y: d.latency })) }]}
|
||||
yLabel="ms"
|
||||
height={200}
|
||||
threshold={{ value: 300, label: 'SLA 300ms' }}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.chartCard}>
|
||||
<div className={styles.chartTitle}>Errors</div>
|
||||
<div className={styles.chartTitle}>Errors by Route</div>
|
||||
<BarChart series={[{ label: 'Errors', data: chartData.map((d: any) => ({ x: d.time as string, y: d.errors })) }]} height={200} />
|
||||
</div>
|
||||
<div className={styles.chartCard}>
|
||||
<div className={styles.chartTitle}>Success Rate</div>
|
||||
<AreaChart series={[{ label: 'Success Rate', data: chartData.map((d: any, i: number) => ({ x: i, y: d.successRate })) }]} height={200} />
|
||||
<div className={styles.chartTitle}>Message Volume (msg/min)</div>
|
||||
<AreaChart series={[{ label: 'Volume', data: chartData.map((d: any, i: number) => ({ x: i, y: d.throughput })) }]} yLabel="msg/min" height={200} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -12,6 +12,7 @@ const RoutesMetrics = lazy(() => import('./pages/Routes/RoutesMetrics'));
|
||||
const RouteDetail = lazy(() => import('./pages/Routes/RouteDetail'));
|
||||
const AgentHealth = lazy(() => import('./pages/AgentHealth/AgentHealth'));
|
||||
const AgentInstance = lazy(() => import('./pages/AgentInstance/AgentInstance'));
|
||||
const AdminLayout = lazy(() => import('./pages/Admin/AdminLayout'));
|
||||
const RbacPage = lazy(() => import('./pages/Admin/RbacPage'));
|
||||
const AuditLogPage = lazy(() => import('./pages/Admin/AuditLogPage'));
|
||||
const OidcConfigPage = lazy(() => import('./pages/Admin/OidcConfigPage'));
|
||||
@@ -47,12 +48,18 @@ export const router = createBrowserRouter([
|
||||
{ path: 'agents', element: <SuspenseWrapper><AgentHealth /></SuspenseWrapper> },
|
||||
{ path: 'agents/:appId', element: <SuspenseWrapper><AgentHealth /></SuspenseWrapper> },
|
||||
{ path: 'agents/:appId/:instanceId', element: <SuspenseWrapper><AgentInstance /></SuspenseWrapper> },
|
||||
{ path: 'admin', element: <Navigate to="/admin/rbac" replace /> },
|
||||
{ path: 'admin/rbac', element: <SuspenseWrapper><RbacPage /></SuspenseWrapper> },
|
||||
{ path: 'admin/audit', element: <SuspenseWrapper><AuditLogPage /></SuspenseWrapper> },
|
||||
{ path: 'admin/oidc', element: <SuspenseWrapper><OidcConfigPage /></SuspenseWrapper> },
|
||||
{ path: 'admin/database', element: <SuspenseWrapper><DatabaseAdminPage /></SuspenseWrapper> },
|
||||
{ path: 'admin/opensearch', element: <SuspenseWrapper><OpenSearchAdminPage /></SuspenseWrapper> },
|
||||
{
|
||||
path: 'admin',
|
||||
element: <SuspenseWrapper><AdminLayout /></SuspenseWrapper>,
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="/admin/rbac" replace /> },
|
||||
{ path: 'rbac', element: <SuspenseWrapper><RbacPage /></SuspenseWrapper> },
|
||||
{ path: 'audit', element: <SuspenseWrapper><AuditLogPage /></SuspenseWrapper> },
|
||||
{ path: 'oidc', element: <SuspenseWrapper><OidcConfigPage /></SuspenseWrapper> },
|
||||
{ path: 'database', element: <SuspenseWrapper><DatabaseAdminPage /></SuspenseWrapper> },
|
||||
{ path: 'opensearch', element: <SuspenseWrapper><OpenSearchAdminPage /></SuspenseWrapper> },
|
||||
],
|
||||
},
|
||||
{ path: 'api-docs', element: <SuspenseWrapper><SwaggerPage /></SuspenseWrapper> },
|
||||
],
|
||||
},
|
||||
|
||||
55
ui/src/utils/diagram-mapping.ts
Normal file
55
ui/src/utils/diagram-mapping.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { RouteNode } from '@cameleer/design-system';
|
||||
|
||||
// Map NodeType strings to RouteNode types
|
||||
function mapNodeType(type: string): RouteNode['type'] {
|
||||
const lower = type?.toLowerCase() || '';
|
||||
if (lower.includes('from') || lower === 'endpoint') return 'from';
|
||||
if (lower.includes('to')) return 'to';
|
||||
if (lower.includes('choice') || lower.includes('when') || lower.includes('otherwise')) return 'choice';
|
||||
if (lower.includes('error') || lower.includes('dead')) return 'error-handler';
|
||||
return 'process';
|
||||
}
|
||||
|
||||
function mapStatus(status: string | undefined): RouteNode['status'] {
|
||||
if (!status) return 'ok';
|
||||
const s = status.toUpperCase();
|
||||
if (s === 'FAILED') return 'fail';
|
||||
if (s === 'RUNNING') return 'slow';
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps diagram PositionedNodes + execution ProcessorNodes to RouteFlow RouteNode[] format.
|
||||
* Joins on diagramNodeId → node.id.
|
||||
*/
|
||||
export function mapDiagramToRouteNodes(
|
||||
diagramNodes: Array<{ id?: string; label?: string; type?: string }>,
|
||||
processors: Array<{ diagramNodeId?: string; processorId?: string; status?: string; durationMs?: number; children?: any[] }>
|
||||
): RouteNode[] {
|
||||
// Flatten processor tree
|
||||
const flatProcessors: typeof processors = [];
|
||||
function flatten(nodes: typeof processors) {
|
||||
for (const n of nodes) {
|
||||
flatProcessors.push(n);
|
||||
if (n.children) flatten(n.children);
|
||||
}
|
||||
}
|
||||
flatten(processors || []);
|
||||
|
||||
// Build lookup: diagramNodeId → processor
|
||||
const procMap = new Map<string, (typeof flatProcessors)[0]>();
|
||||
for (const p of flatProcessors) {
|
||||
if (p.diagramNodeId) procMap.set(p.diagramNodeId, p);
|
||||
}
|
||||
|
||||
return diagramNodes.map(node => {
|
||||
const proc = procMap.get(node.id ?? '');
|
||||
return {
|
||||
name: node.label || node.id || '',
|
||||
type: mapNodeType(node.type ?? ''),
|
||||
durationMs: proc?.durationMs ?? 0,
|
||||
status: mapStatus(proc?.status),
|
||||
isBottleneck: false,
|
||||
};
|
||||
});
|
||||
}
|
||||
1
ui/tsconfig.app.tsbuildinfo
Normal file
1
ui/tsconfig.app.tsbuildinfo
Normal file
@@ -0,0 +1 @@
|
||||
{"root":["./src/config.ts","./src/main.tsx","./src/router.tsx","./src/swagger-ui-dist.d.ts","./src/vite-env.d.ts","./src/api/client.ts","./src/api/schema.d.ts","./src/api/types.ts","./src/api/queries/agents.ts","./src/api/queries/catalog.ts","./src/api/queries/diagrams.ts","./src/api/queries/executions.ts","./src/api/queries/admin/admin-api.ts","./src/api/queries/admin/audit.ts","./src/api/queries/admin/database.ts","./src/api/queries/admin/opensearch.ts","./src/api/queries/admin/rbac.ts","./src/api/queries/admin/thresholds.ts","./src/auth/loginpage.tsx","./src/auth/oidccallback.tsx","./src/auth/protectedroute.tsx","./src/auth/auth-store.ts","./src/auth/use-auth.ts","./src/components/layoutshell.tsx","./src/pages/admin/auditlogpage.tsx","./src/pages/admin/databaseadminpage.tsx","./src/pages/admin/oidcconfigpage.tsx","./src/pages/admin/opensearchadminpage.tsx","./src/pages/admin/rbacpage.tsx","./src/pages/agenthealth/agenthealth.tsx","./src/pages/agentinstance/agentinstance.tsx","./src/pages/dashboard/dashboard.tsx","./src/pages/exchangedetail/exchangedetail.tsx","./src/pages/routes/routesmetrics.tsx","./src/pages/swagger/swaggerpage.tsx"],"version":"5.9.3"}
|
||||
1
ui/tsconfig.node.tsbuildinfo
Normal file
1
ui/tsconfig.node.tsbuildinfo
Normal file
@@ -0,0 +1 @@
|
||||
{"root":["./vite.config.ts"],"version":"5.9.3"}
|
||||
1
ui/tsconfig.tsbuildinfo
Normal file
1
ui/tsconfig.tsbuildinfo
Normal file
@@ -0,0 +1 @@
|
||||
{"root":["./vite.config.ts","./src/config.ts","./src/main.tsx","./src/router.tsx","./src/swagger-ui-dist.d.ts","./src/vite-env.d.ts","./src/api/client.ts","./src/api/schema.d.ts","./src/api/types.ts","./src/api/queries/agents.ts","./src/api/queries/catalog.ts","./src/api/queries/diagrams.ts","./src/api/queries/executions.ts","./src/api/queries/admin/admin-api.ts","./src/api/queries/admin/audit.ts","./src/api/queries/admin/database.ts","./src/api/queries/admin/opensearch.ts","./src/api/queries/admin/rbac.ts","./src/api/queries/admin/thresholds.ts","./src/auth/loginpage.tsx","./src/auth/oidccallback.tsx","./src/auth/protectedroute.tsx","./src/auth/auth-store.ts","./src/auth/use-auth.ts","./src/components/layoutshell.tsx","./src/pages/admin/auditlogpage.tsx","./src/pages/admin/databaseadminpage.tsx","./src/pages/admin/oidcconfigpage.tsx","./src/pages/admin/opensearchadminpage.tsx","./src/pages/admin/rbacpage.tsx","./src/pages/agenthealth/agenthealth.tsx","./src/pages/agentinstance/agentinstance.tsx","./src/pages/dashboard/dashboard.tsx","./src/pages/exchangedetail/exchangedetail.tsx","./src/pages/routes/routesmetrics.tsx","./src/pages/swagger/swaggerpage.tsx"],"errors":true,"version":"5.9.3"}
|
||||
@@ -13,6 +13,11 @@ export default defineConfig({
|
||||
target: apiTarget,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
configure: (proxy) => {
|
||||
proxy.on('proxyReq', (proxyReq) => {
|
||||
proxyReq.removeHeader('origin');
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user